diff --git a/.env.example b/.env.example index c80886a8bfa9f312934dab6ac348571edcf8d9a3..067aad9cc6ea3b39999bb63dbbaccd95ac342e6e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -# Optional: T3 Cloud source builds -# Leave these unset to disable optional T3 Cloud features in local source builds. +# Optional: T3 Connect source builds +# Leave these unset to disable optional T3 Connect features in local source builds. # Release builds inject their public values at build time. Do not add server-side # secrets to this file. diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 1f777ed2911c9fce4e596d4d5b00df09270ea9ff..94d4af17e41ad5b764e21660559978d9bd982425 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -1,4 +1,4 @@ -name: Deploy T3 Cloud relay +name: Deploy T3 Connect relay on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67e6aeb51adf4b4046c1806545d12e0335e0d621..e1fb92163bfd921cd3be37211efd8d65f750125d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -167,7 +167,7 @@ jobs: --github-output relay_public_config: - name: Resolve T3 Cloud public config + name: Resolve T3 Connect public config needs: preflight if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 271b35095e4071845a8432a2a93167f44bdf2b90..5e977de2dea923486494566fc8e4382f8bf3e09d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -165,7 +165,7 @@ describe("DesktopWindow", () => { assert.isTrue( DesktopWindow.isSameOriginRendererNavigation({ applicationUrl: "http://127.0.0.1:3773/", - navigationUrl: "http://127.0.0.1:3773/settings/cloud", + navigationUrl: "http://127.0.0.1:3773/settings/connections", }), ); assert.isFalse( diff --git a/apps/mobile/README.md b/apps/mobile/README.md index f5be27c6c3f722075ce26a895d4e3ac684d4444b..ad0242c271413eae7c21ad7fc423286b2a546845 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -16,7 +16,7 @@ This app has three variants: Run commands from `apps/mobile`. -T3 Cloud is optional and disabled in a fresh clone. Public configuration belongs in the +T3 Connect is optional and disabled in a fresh clone. Public configuration belongs in the repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See [`../../.env.example`](../../.env.example). diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 16a7a7453981e30a7815dff619b5c55d2e66bd31..36544cf46cc34a2bfd9e1858a4cdf3c6cfd96220 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -572,7 +572,7 @@ describe("mobile cloud link environment client", () => { if (String(url).endsWith("/v1/client/environment-link-challenges")) { return Promise.resolve(Response.json(validLinkChallengeResponse())); } - if (String(url).endsWith("/api/cloud/link-proof")) { + if (String(url).endsWith("/api/connect/link-proof")) { return Promise.resolve(Response.json(validLinkProof())); } return Promise.resolve(Response.json(validLinkResponse("env-other"))); @@ -631,7 +631,7 @@ describe("mobile cloud link environment client", () => { if (String(url).endsWith("/v1/client/environment-link-challenges")) { return Promise.resolve(Response.json(validLinkChallengeResponse())); } - if (String(url).endsWith("/api/cloud/link-proof")) { + if (String(url).endsWith("/api/connect/link-proof")) { return Promise.resolve(Response.json(validLinkProof())); } return Promise.resolve( @@ -669,7 +669,7 @@ describe("mobile cloud link environment client", () => { if (String(url).endsWith("/v1/client/environment-link-challenges")) { return Promise.resolve(Response.json(validLinkChallengeResponse())); } - if (String(url).endsWith("/api/cloud/link-proof")) { + if (String(url).endsWith("/api/connect/link-proof")) { return Promise.resolve(Response.json(validLinkProof())); } return Promise.resolve( @@ -713,7 +713,7 @@ describe("mobile cloud link environment client", () => { if (String(url).endsWith("/v1/client/environment-link-challenges")) { return Promise.resolve(Response.json(validLinkChallengeResponse())); } - if (String(url).endsWith("/api/cloud/link-proof")) { + if (String(url).endsWith("/api/connect/link-proof")) { return Promise.resolve(Response.json(validLinkProof())); } if (String(url).endsWith("/v1/client/environment-links")) { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index f72b35d17563f20c4dd6b15e346411d13857df34..bca1ac21bc75f5c2176b7b7a35a1ea9286766eb1 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -301,7 +301,7 @@ export function linkEnvironmentToCloud(input: { ), ); const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); - const proof = yield* environmentClient.cloud + const proof = yield* environmentClient.connect .linkProof({ headers: { authorization: `Bearer ${localBearerToken}` }, payload: { @@ -336,7 +336,7 @@ export function linkEnvironmentToCloud(input: { link, }); - yield* environmentClient.cloud + yield* environmentClient.connect .relayConfig({ headers: { authorization: `Bearer ${localBearerToken}` }, payload: { diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5911bfd9874bc55368fe33e0ed3cf89fc0de4c4a..27a1d55e90d510db3498294cfd9901bfc0b9d432 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -38,11 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} -const cloudCli = makeCli({ cloudEnabled: true }); -const noCloudCli = makeCli({ cloudEnabled: false }); +const connectCli = makeCli({ cloudEnabled: true }); +const noConnectCli = makeCli({ cloudEnabled: false }); const runCli = (args: ReadonlyArray, command = cli) => Command.runWith(command, { version: "0.0.0" })(args); -const runCloudCli = (args: ReadonlyArray) => runCli(args, cloudCli); +const runConnectCli = (args: ReadonlyArray) => runCli(args, connectCli); const runCliWithRuntime = (args: ReadonlyArray) => runCli(args).pipe(Effect.provide(CliRuntimeLayer)); @@ -179,9 +179,9 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); - it.effect("rejects cloud commands when public cloud configuration is missing", () => + it.effect("rejects connect commands when public configuration is missing", () => Effect.gen(function* () { - const error = yield* runCli(["cloud", "status"], noCloudCli).pipe(Effect.flip); + const error = yield* runCli(["connect", "status"], noConnectCli).pipe(Effect.flip); if (!CliError.isCliError(error)) { assert.fail(`Expected CliError, got ${String(error)}`); @@ -189,20 +189,20 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { if (error._tag !== "ShowHelp") { assert.fail(`Expected ShowHelp, got ${error._tag}`); } - assert.deepEqual(error.commandPath, ["t3", "cloud"]); - assert.include(error.errors[0]?.message ?? "", "missing T3 Cloud public configuration"); + assert.deepEqual(error.commandPath, ["t3", "connect"]); + assert.include(error.errors[0]?.message ?? "", "missing T3 Connect public configuration"); const output = (yield* TestConsole.errorLines).join("\n"); assert.include(output, "ERROR"); - assert.include(output, "missing T3 Cloud public configuration"); + assert.include(output, "missing T3 Connect public configuration"); }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), ); - it.effect("reports fresh headless cloud state without requiring local configuration", () => + it.effect("reports fresh headless connect state without requiring local configuration", () => Effect.gen(function* () { const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-")); const { output } = yield* captureStdout( - runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]), + runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]), ); // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. const status = JSON.parse(output) as { @@ -221,21 +221,21 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); - it.effect("reports actionable human-readable headless cloud state", () => + it.effect("reports actionable human-readable headless connect state", () => Effect.gen(function* () { const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-")); const { output } = yield* captureStdout( - runCloudCli(["cloud", "status", "--base-dir", baseDir]), + runConnectCli(["connect", "status", "--base-dir", baseDir]), ); - assert.include(output, "T3 Cloud\n Exposure: disabled"); + assert.include(output, "T3 Connect\n Exposure: disabled"); assert.include(output, " Authorization: missing"); assert.include(output, " Environment link: not provisioned"); - assert.include(output, "Next: Run `t3 cloud link` to authorize and enable cloud exposure."); + assert.include(output, "Next: Run `t3 connect link` to authorize and enable T3 Connect."); }), ); - it.effect("logs in to headless cloud without enabling exposure", () => + it.effect("logs in to headless connect without enabling access", () => Effect.gen(function* () { const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-")); const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); @@ -250,9 +250,11 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); - const login = yield* captureStdout(runCloudCli(["cloud", "login", "--base-dir", baseDir])); + const login = yield* captureStdout( + runConnectCli(["connect", "login", "--base-dir", baseDir]), + ); const status = yield* captureStdout( - runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]), + runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]), ); // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. const decoded = JSON.parse(status.output) as { @@ -260,24 +262,24 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { readonly authenticated: boolean; }; - assert.equal(login.output, "Signed in to T3 Cloud."); + assert.equal(login.output, "Signed in to T3 Connect."); assert.isFalse(decoded.desired); assert.isTrue(decoded.authenticated); }), ); - it.effect("disables headless cloud exposure without a running server", () => + it.effect("disables headless connect without a running server", () => Effect.gen(function* () { const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-")); const { output } = yield* captureStdout( - runCloudCli(["cloud", "unlink", "--base-dir", baseDir]), + runConnectCli(["connect", "unlink", "--base-dir", baseDir]), ); - assert.equal(output, "T3 Cloud exposure is disabled locally."); + assert.equal(output, "T3 Connect is disabled locally."); }), ); - it.effect("logs out of headless cloud and removes the stored CLI authorization", () => + it.effect("logs out of headless connect and removes the stored CLI authorization", () => Effect.gen(function* () { const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-")); const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); @@ -286,10 +288,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { writeFileSync(tokenPath, "invalid persisted token"); const { output } = yield* captureStdout( - runCloudCli(["cloud", "logout", "--base-dir", baseDir]), + runConnectCli(["connect", "logout", "--base-dir", baseDir]), ); - assert.equal(output, "Signed out of T3 Cloud locally."); + assert.equal(output, "Signed out of T3 Connect locally."); assert.isFalse(existsSync(tokenPath)); }), ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 04d5bdfadaa55b1dc7c61579c1dda2dec051433c..ddfbf5e3eccecad56cd001633c1ccba46ecf27ef 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -8,7 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; -import { cloudCommand } from "./cli/cloud.ts"; +import { connectCommand } from "./cli/connect.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; @@ -16,23 +16,23 @@ import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -const cloudPublicConfigMissingMessage = - "T3 Cloud commands are unavailable: this build is missing T3 Cloud public configuration."; +const connectPublicConfigMissingMessage = + "T3 Connect commands are unavailable: this build is missing T3 Connect public configuration."; -class CloudPublicConfigMissingError extends CliError.UserError { +class ConnectPublicConfigMissingError extends CliError.UserError { override get message() { - return cloudPublicConfigMissingMessage; + return connectPublicConfigMissingMessage; } } -const cloudUnavailableCommand = Command.make("cloud").pipe( - Command.withDescription("T3 Cloud is unavailable in builds without public cloud configuration."), +const connectUnavailableCommand = Command.make("connect").pipe( + Command.withDescription("T3 Connect is unavailable in builds without public configuration."), Command.withHidden, Command.withHandler(() => Effect.fail( new CliError.ShowHelp({ - commandPath: ["t3", "cloud"], - errors: [new CloudPublicConfigMissingError({ cause: cloudPublicConfigMissingMessage })], + commandPath: ["t3", "connect"], + errors: [new ConnectPublicConfigMissingError({ cause: connectPublicConfigMissingMessage })], }), ), ), @@ -47,7 +47,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, authCommand, projectCommand, - cloudEnabled ? cloudCommand : cloudUnavailableCommand, + cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/cloud.test.ts b/apps/server/src/cli/cloud.test.ts deleted file mode 100644 index 4dda50c0ce3f357f652018fc1a8d56097a790e44..0000000000000000000000000000000000000000 --- a/apps/server/src/cli/cloud.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as RelayClient from "@t3tools/shared/relayClient"; -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; - -import { acquireRelayClientForLink } from "./cloud.ts"; - -const managedExecutable = { - status: "available", - executablePath: "/tmp/cloudflared", - source: "managed", - version: RelayClient.CLOUDFLARED_VERSION, -} as const; - -it.effect("does not install the relay client when the user declines the managed download", () => - Effect.gen(function* () { - let installCalls = 0; - const result = yield* acquireRelayClientForLink( - { - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }), - install: Effect.sync(() => { - installCalls += 1; - return managedExecutable; - }), - installWithProgress: () => - Effect.sync(() => { - installCalls += 1; - return managedExecutable; - }), - }, - () => Effect.succeed(false), - () => Effect.void, - ); - - assert.isTrue(Option.isNone(result)); - assert.equal(installCalls, 0); - }), -); - -it.effect("installs the relay client after the user accepts the managed download", () => - Effect.gen(function* () { - let installCalls = 0; - const progress: Array = []; - const result = yield* acquireRelayClientForLink( - { - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }), - install: Effect.sync(() => { - installCalls += 1; - return managedExecutable; - }), - installWithProgress: (report) => - report({ type: "progress", stage: "downloading" }).pipe( - Effect.andThen( - Effect.sync(() => { - installCalls += 1; - return managedExecutable; - }), - ), - ), - }, - () => Effect.succeed(true), - (event) => - Effect.sync(() => { - if (event.type === "progress") { - progress.push(event.stage); - } - }), - ); - - assert.deepEqual(Option.getOrThrow(result), managedExecutable); - assert.equal(installCalls, 1); - assert.deepEqual(progress, ["downloading"]); - }), -); - -it.effect("reuses an available relay client executable without prompting", () => - Effect.gen(function* () { - let promptCalls = 0; - const result = yield* acquireRelayClientForLink( - { - resolve: Effect.succeed(managedExecutable), - install: Effect.die("unexpected install"), - installWithProgress: () => Effect.die("unexpected install"), - }, - () => - Effect.sync(() => { - promptCalls += 1; - return false; - }), - () => Effect.void, - ); - - assert.deepEqual(Option.getOrThrow(result), managedExecutable); - assert.equal(promptCalls, 0); - }), -); diff --git a/apps/server/src/cli/cloud.ts b/apps/server/src/cli/cloud.ts deleted file mode 100644 index 78027d1e8027ee76563545aaccb1852e623cea5c..0000000000000000000000000000000000000000 --- a/apps/server/src/cli/cloud.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { - AuthRelayWriteScope, - EnvironmentHttpApi, - type RelayClientInstallProgressEvent, - type RelayClientInstallProgressStage, -} from "@t3tools/contracts"; -import { RelayOkResponse } from "@t3tools/contracts/relay"; -import * as RelayClient from "@t3tools/shared/relayClient"; -import * as Console from "effect/Console"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as References from "effect/References"; -import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; -import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; - -import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as CliState from "../cloud/CliState.ts"; -import * as CliTokenManager from "../cloud/CliTokenManager.ts"; -import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; -import { relayUrlConfig } from "../cloud/publicConfig.ts"; -import { ServerConfig } from "../config.ts"; -import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; -import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; -import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; -import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; - -const jsonFlag = Flag.boolean("json").pipe( - Flag.withDescription("Emit JSON instead of human-readable output."), - Flag.withDefault(false), -); - -function bytesToString(value: Uint8Array): string { - return new TextDecoder().decode(value); -} - -interface CloudCliStatus { - readonly desired: boolean; - readonly authenticated: boolean; - readonly linked: boolean; - readonly cloudUserId: string | null; - readonly relayUrl: string | null; - readonly relayClient: RelayClient.RelayClientStatus; -} - -function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray { - switch (executable.status) { - case "available": { - const source = - executable.source === "path" - ? "PATH" - : executable.source === "managed" - ? "managed install" - : "configured override"; - return [ - ` Relay client: available via ${source}`, - ` Path: ${executable.executablePath}`, - ` Version: ${executable.version}`, - ]; - } - case "missing": - return [" Relay client: not installed"]; - case "unsupported": - return [ - ` Relay client: unsupported on ${executable.platform}-${executable.arch}`, - ` Managed version: ${executable.version}`, - ]; - } -} - -function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string { - if (options?.json) { - return JSON.stringify(status, null, 2); - } - - const provisioned = status.linked - ? "provisioned" - : status.desired && status.authenticated - ? "pending server startup" - : "not provisioned"; - const nextStep = !status.authenticated - ? "Run `t3 cloud link` to authorize and enable cloud exposure." - : !status.desired - ? "Run `t3 cloud link` to enable cloud exposure." - : !status.linked - ? "Start T3 to provision the environment link and launch its managed tunnel." - : undefined; - - return [ - "T3 Cloud", - ` Exposure: ${status.desired ? "enabled" : "disabled"}`, - ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, - ` Environment link: ${provisioned}`, - ` Relay: ${status.relayUrl ?? "not provisioned"}`, - ...formatRelayClientStatus(status.relayClient), - ...(nextStep ? ["", `Next: ${nextStep}`] : []), - ].join("\n"); -} - -const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5); - -const confirmRelayClientInstall = (version: string) => - Prompt.run( - Prompt.confirm({ - message: `The T3 relay client is required for T3 Cloud exposure. Download and install version ${version}?`, - initial: false, - }), - ); - -function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string { - switch (stage) { - case "checking": - return "Checking existing installation"; - case "waiting_for_lock": - return "Waiting for installation lock"; - case "downloading": - return "Downloading"; - case "verifying": - return "Verifying download"; - case "installing": - return "Installing"; - case "validating": - return "Validating executable"; - case "activating": - return "Activating installation"; - } -} - -const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) => - event.type === "progress" - ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`) - : Effect.void; - -export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")( - function* ( - relayClient: RelayClient.RelayClientShape, - confirmInstall: (version: string) => Effect.Effect, - reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect, - ) { - const executable = yield* relayClient.resolve; - if (executable.status === "available") { - return Option.some(executable); - } - if (executable.status === "unsupported") { - return Option.some(yield* relayClient.installWithProgress(reportProgress)); - } - if (!(yield* confirmInstall(executable.version))) { - return Option.none(); - } - return Option.some(yield* relayClient.installWithProgress(reportProgress)); - }, -); - -const withCloudCliSessionToken = ( - environmentAuth: EnvironmentAuth.EnvironmentAuthShape, - run: (token: string) => Effect.Effect, -) => - Effect.acquireUseRelease( - environmentAuth.issueSession({ - scopes: [AuthRelayWriteScope], - subject: "cloud-cli", - label: "t3 cloud cli", - }), - (issued) => run(issued.token), - (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), - ); - -type LiveCloudActionResult = - | { readonly status: "not-running" } - | { readonly status: "succeeded" } - | { readonly status: "failed"; readonly cause: unknown }; - -const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { - const config = yield* ServerConfig; - const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); - if (Option.isNone(runtimeState)) { - return { status: "not-running" } satisfies LiveCloudActionResult; - } - - const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; - const result = yield* Effect.exit( - withCloudCliSessionToken(environmentAuth, (token) => - HttpApiClient.make(EnvironmentHttpApi, { - baseUrl: runtimeState.value.origin, - }).pipe( - Effect.flatMap((client) => - client.cloud.unlink({ headers: { authorization: `Bearer ${token}` } }), - ), - Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT), - ), - ), - ); - return Exit.isSuccess(result) - ? ({ status: "succeeded" } satisfies LiveCloudActionResult) - : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult); -}); - -type RelayUnlinkResult = - | { readonly status: "not-authenticated" } - | { readonly status: "revoked" } - | { readonly status: "not-linked" }; - -const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () { - const tokens = yield* CliTokenManager.CloudCliTokenManager; - const token = yield* tokens.getExisting; - if (Option.isNone(token)) { - return { status: "not-authenticated" } satisfies RelayUnlinkResult; - } - - const environment = yield* ServerEnvironment; - const environmentId = yield* environment.getEnvironmentId; - const relayUrl = yield* relayUrlConfig; - const httpClient = yield* HttpClient.HttpClient; - const response = yield* HttpClientRequest.delete( - `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`, - ).pipe( - HttpClientRequest.bearerToken(token.value.accessToken), - httpClient.execute, - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), - ); - return response.ok - ? ({ status: "revoked" } satisfies RelayUnlinkResult) - : ({ status: "not-linked" } satisfies RelayUnlinkResult); -}); - -const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { - readonly clearAuthorization: boolean; -}) { - yield* CliState.setCliDesiredCloudLink(false); - const liveResult = yield* runLiveCloudUnlink(); - const relayResult = yield* Effect.exit(unlinkRelayEnvironment()); - yield* CliState.clearPersistedCloudLink; - - if (options.clearAuthorization) { - const tokens = yield* CliTokenManager.CloudCliTokenManager; - yield* tokens.clear; - } - - if (liveResult.status === "failed") { - yield* Console.warn( - `T3 Cloud exposure is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`, - ); - } else { - yield* Console.log("T3 Cloud exposure is disabled locally."); - } - - if (Exit.isFailure(relayResult)) { - yield* Console.warn( - options.clearAuthorization - ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.` - : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 cloud unlink\` again when the relay is reachable.`, - ); - } else if (relayResult.value.status === "revoked") { - yield* Console.log("Revoked the relay-side environment record."); - } - - if (options.clearAuthorization) { - yield* Console.log("Signed out of T3 Cloud locally."); - } -}); - -const runCloudCommand = ( - flags: { readonly baseDir: Option.Option }, - run: Effect.Effect< - A, - E, - | ServerSecretStore.ServerSecretStore - | CliTokenManager.CloudCliTokenManager - | RelayClient.RelayClient - | EnvironmentAuth.EnvironmentAuth - | FileSystem.FileSystem - | HttpClient.HttpClient - | Prompt.Environment - | ServerConfig - | ServerEnvironment - >, - options?: { - readonly quietLogs?: boolean; - }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; - const runtimeLayer = Layer.mergeAll( - ServerSecretStore.layer, - CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), - RelayClient.layerCloudflared({ baseDir: config.baseDir }), - EnvironmentAuth.runtimeLayer, - ServerEnvironmentLive, - ).pipe( - Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(Layer.succeed(ServerConfig, config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ); - return yield* run.pipe(Effect.provide(runtimeLayer)); - }); - -const cloudLoginCommand = Command.make("login", { - ...projectLocationFlags, -}).pipe( - Command.withDescription("Authorize the T3 Cloud CLI without enabling cloud exposure."), - Command.withHandler((flags) => - runCloudCommand( - flags, - Effect.gen(function* () { - const tokens = yield* CliTokenManager.CloudCliTokenManager; - yield* tokens.get; - yield* Console.log("Signed in to T3 Cloud."); - }), - ), - ), -); - -const cloudLinkCommand = Command.make("link", { - ...projectLocationFlags, -}).pipe( - Command.withDescription("Authorize this environment for T3 Cloud and expose it on next start."), - Command.withHandler((flags) => - runCloudCommand( - flags, - Effect.gen(function* () { - const relayClient = yield* RelayClient.RelayClient; - const installed = yield* acquireRelayClientForLink( - relayClient, - confirmRelayClientInstall, - reportRelayClientInstallProgress, - ); - if (Option.isNone(installed)) { - yield* Console.log("T3 Cloud link cancelled. The relay client was not installed."); - return; - } - yield* Console.log( - `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, - ); - - const tokens = yield* CliTokenManager.CloudCliTokenManager; - yield* tokens.get; - yield* CliState.setCliDesiredCloudLink(true); - yield* Console.log( - "This T3 environment will be available over T3 Cloud the next time T3 starts.", - ); - }), - ), - ), -); - -const cloudStatusCommand = Command.make("status", { - ...projectLocationFlags, - json: jsonFlag, -}).pipe( - Command.withDescription("Show persisted T3 Cloud and relay client state."), - Command.withHandler((flags) => - runCloudCommand( - flags, - Effect.gen(function* () { - const secrets = yield* ServerSecretStore.ServerSecretStore; - const relayClient = yield* RelayClient.RelayClient; - const tokens = yield* CliTokenManager.CloudCliTokenManager; - const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all( - [ - CliState.readCliDesiredCloudLink, - tokens.hasCredential, - secrets.get(CLOUD_LINKED_USER_ID), - secrets.get(RELAY_URL_SECRET), - relayClient.resolve, - ], - { concurrency: "unbounded" }, - ); - const status: CloudCliStatus = { - desired, - authenticated, - linked: cloudUserId !== null, - cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, - relayUrl: relayUrl ? bytesToString(relayUrl) : null, - relayClient: executable, - }; - yield* Console.log(formatCloudStatus(status, { json: flags.json })); - }), - { - quietLogs: flags.json, - }, - ), - ), -); - -const cloudUnlinkCommand = Command.make("unlink", { - ...projectLocationFlags, -}).pipe( - Command.withDescription("Disable T3 Cloud exposure while retaining the stored authorization."), - Command.withHandler((flags) => - runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })), - ), -); - -const cloudLogoutCommand = Command.make("logout", { - ...projectLocationFlags, -}).pipe( - Command.withDescription("Disable T3 Cloud exposure and clear the stored CLI authorization."), - Command.withHandler((flags) => - runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })), - ), -); - -export const cloudCommand = Command.make("cloud").pipe( - Command.withDescription("Manage headless T3 Cloud exposure."), - Command.withSubcommands([ - cloudLoginCommand, - cloudLinkCommand, - cloudStatusCommand, - cloudUnlinkCommand, - cloudLogoutCommand, - ]), -); diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fce3bc1cd7d65ddf64ce97ab4608189b950d3db --- /dev/null +++ b/apps/server/src/cli/connect.test.ts @@ -0,0 +1,102 @@ +import * as RelayClient from "@t3tools/shared/relayClient"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { acquireRelayClientForLink } from "./connect.ts"; + +const managedExecutable = { + status: "available", + executablePath: "/tmp/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, +} as const; + +it.effect("does not install the relay client when the user declines the managed download", () => + Effect.gen(function* () { + let installCalls = 0; + const result = yield* acquireRelayClientForLink( + { + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + installWithProgress: () => + Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + }, + () => Effect.succeed(false), + () => Effect.void, + ); + + assert.isTrue(Option.isNone(result)); + assert.equal(installCalls, 0); + }), +); + +it.effect("installs the relay client after the user accepts the managed download", () => + Effect.gen(function* () { + let installCalls = 0; + const progress: Array = []; + const result = yield* acquireRelayClientForLink( + { + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + installWithProgress: (report) => + report({ type: "progress", stage: "downloading" }).pipe( + Effect.andThen( + Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + ), + ), + }, + () => Effect.succeed(true), + (event) => + Effect.sync(() => { + if (event.type === "progress") { + progress.push(event.stage); + } + }), + ); + + assert.deepEqual(Option.getOrThrow(result), managedExecutable); + assert.equal(installCalls, 1); + assert.deepEqual(progress, ["downloading"]); + }), +); + +it.effect("reuses an available relay client executable without prompting", () => + Effect.gen(function* () { + let promptCalls = 0; + const result = yield* acquireRelayClientForLink( + { + resolve: Effect.succeed(managedExecutable), + install: Effect.die("unexpected install"), + installWithProgress: () => Effect.die("unexpected install"), + }, + () => + Effect.sync(() => { + promptCalls += 1; + return false; + }), + () => Effect.void, + ); + + assert.deepEqual(Option.getOrThrow(result), managedExecutable); + assert.equal(promptCalls, 0); + }), +); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts new file mode 100644 index 0000000000000000000000000000000000000000..167fb75a37cc18c0fcdd5b3f554876ea6a3e854c --- /dev/null +++ b/apps/server/src/cli/connect.ts @@ -0,0 +1,425 @@ +import { + AuthRelayWriteScope, + EnvironmentHttpApi, + type RelayClientInstallProgressEvent, + type RelayClientInstallProgressStage, +} from "@t3tools/contracts"; +import { RelayOkResponse } from "@t3tools/contracts/relay"; +import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as CliState from "../cloud/CliState.ts"; +import * as CliTokenManager from "../cloud/CliTokenManager.ts"; +import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; +import { relayUrlConfig } from "../cloud/publicConfig.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +function bytesToString(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +interface CloudCliStatus { + readonly desired: boolean; + readonly authenticated: boolean; + readonly linked: boolean; + readonly cloudUserId: string | null; + readonly relayUrl: string | null; + readonly relayClient: RelayClient.RelayClientStatus; +} + +function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray { + switch (executable.status) { + case "available": { + const source = + executable.source === "path" + ? "PATH" + : executable.source === "managed" + ? "managed install" + : "configured override"; + return [ + ` Relay client: available via ${source}`, + ` Path: ${executable.executablePath}`, + ` Version: ${executable.version}`, + ]; + } + case "missing": + return [" Relay client: not installed"]; + case "unsupported": + return [ + ` Relay client: unsupported on ${executable.platform}-${executable.arch}`, + ` Managed version: ${executable.version}`, + ]; + } +} + +function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string { + if (options?.json) { + return JSON.stringify(status, null, 2); + } + + const provisioned = status.linked + ? "provisioned" + : status.desired && status.authenticated + ? "pending server startup" + : "not provisioned"; + const nextStep = !status.authenticated + ? "Run `t3 connect link` to authorize and enable T3 Connect." + : !status.desired + ? "Run `t3 connect link` to enable T3 Connect." + : !status.linked + ? "Start T3 to provision the environment link and launch its managed tunnel." + : undefined; + + return [ + "T3 Connect", + ` Exposure: ${status.desired ? "enabled" : "disabled"}`, + ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, + ` Environment link: ${provisioned}`, + ` Relay: ${status.relayUrl ?? "not provisioned"}`, + ...formatRelayClientStatus(status.relayClient), + ...(nextStep ? ["", `Next: ${nextStep}`] : []), + ].join("\n"); +} + +const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5); + +const confirmRelayClientInstall = (version: string) => + Prompt.run( + Prompt.confirm({ + message: `The T3 relay client is required for T3 Connect. Download and install version ${version}?`, + initial: false, + }), + ); + +function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string { + switch (stage) { + case "checking": + return "Checking existing installation"; + case "waiting_for_lock": + return "Waiting for installation lock"; + case "downloading": + return "Downloading"; + case "verifying": + return "Verifying download"; + case "installing": + return "Installing"; + case "validating": + return "Validating executable"; + case "activating": + return "Activating installation"; + } +} + +const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) => + event.type === "progress" + ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`) + : Effect.void; + +export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")( + function* ( + relayClient: RelayClient.RelayClientShape, + confirmInstall: (version: string) => Effect.Effect, + reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect, + ) { + const executable = yield* relayClient.resolve; + if (executable.status === "available") { + return Option.some(executable); + } + if (executable.status === "unsupported") { + return Option.some(yield* relayClient.installWithProgress(reportProgress)); + } + if (!(yield* confirmInstall(executable.version))) { + return Option.none(); + } + return Option.some(yield* relayClient.installWithProgress(reportProgress)); + }, +); + +const withCloudCliSessionToken = ( + environmentAuth: EnvironmentAuth.EnvironmentAuthShape, + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + environmentAuth.issueSession({ + scopes: [AuthRelayWriteScope], + subject: "cloud-cli", + label: "t3 connect cli", + }), + (issued) => run(issued.token), + (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + +type LiveCloudActionResult = + | { readonly status: "not-running" } + | { readonly status: "succeeded" } + | { readonly status: "failed"; readonly cause: unknown }; + +const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { + const config = yield* ServerConfig; + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return { status: "not-running" } satisfies LiveCloudActionResult; + } + + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const result = yield* Effect.exit( + withCloudCliSessionToken(environmentAuth, (token) => + HttpApiClient.make(EnvironmentHttpApi, { + baseUrl: runtimeState.value.origin, + }).pipe( + Effect.flatMap((client) => + client.connect.unlink({ headers: { authorization: `Bearer ${token}` } }), + ), + Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT), + ), + ), + ); + return Exit.isSuccess(result) + ? ({ status: "succeeded" } satisfies LiveCloudActionResult) + : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult); +}); + +type RelayUnlinkResult = + | { readonly status: "not-authenticated" } + | { readonly status: "revoked" } + | { readonly status: "not-linked" }; + +const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const token = yield* tokens.getExisting; + if (Option.isNone(token)) { + return { status: "not-authenticated" } satisfies RelayUnlinkResult; + } + + const environment = yield* ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const relayUrl = yield* relayUrlConfig; + const httpClient = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.delete( + `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`, + ).pipe( + HttpClientRequest.bearerToken(token.value.accessToken), + httpClient.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), + ); + return response.ok + ? ({ status: "revoked" } satisfies RelayUnlinkResult) + : ({ status: "not-linked" } satisfies RelayUnlinkResult); +}); + +const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { + readonly clearAuthorization: boolean; +}) { + yield* CliState.setCliDesiredCloudLink(false); + const liveResult = yield* runLiveCloudUnlink(); + const relayResult = yield* Effect.exit(unlinkRelayEnvironment()); + yield* CliState.clearPersistedCloudLink; + + if (options.clearAuthorization) { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + yield* tokens.clear; + } + + if (liveResult.status === "failed") { + yield* Console.warn( + `T3 Connect is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`, + ); + } else { + yield* Console.log("T3 Connect is disabled locally."); + } + + if (Exit.isFailure(relayResult)) { + yield* Console.warn( + options.clearAuthorization + ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.` + : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 connect unlink\` again when the relay is reachable.`, + ); + } else if (relayResult.value.status === "revoked") { + yield* Console.log("Revoked the relay-side environment record."); + } + + if (options.clearAuthorization) { + yield* Console.log("Signed out of T3 Connect locally."); + } +}); + +const runCloudCommand = ( + flags: { readonly baseDir: Option.Option }, + run: Effect.Effect< + A, + E, + | ServerSecretStore.ServerSecretStore + | CliTokenManager.CloudCliTokenManager + | RelayClient.RelayClient + | EnvironmentAuth.EnvironmentAuth + | FileSystem.FileSystem + | HttpClient.HttpClient + | Prompt.Environment + | ServerConfig + | ServerEnvironment + >, + options?: { + readonly quietLogs?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + const runtimeLayer = Layer.mergeAll( + ServerSecretStore.layer, + CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), + RelayClient.layerCloudflared({ baseDir: config.baseDir }), + EnvironmentAuth.runtimeLayer, + ServerEnvironmentLive, + ).pipe( + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + return yield* run.pipe(Effect.provide(runtimeLayer)); + }); + +const connectLoginCommand = Command.make("login", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + yield* tokens.get; + yield* Console.log("Signed in to T3 Connect."); + }), + ), + ), +); + +const connectLinkCommand = Command.make("link", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Authorize this environment for T3 Connect on next start."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const relayClient = yield* RelayClient.RelayClient; + const installed = yield* acquireRelayClientForLink( + relayClient, + confirmRelayClientInstall, + reportRelayClientInstallProgress, + ); + if (Option.isNone(installed)) { + yield* Console.log("T3 Connect setup cancelled. The relay client was not installed."); + return; + } + yield* Console.log( + `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, + ); + + const tokens = yield* CliTokenManager.CloudCliTokenManager; + yield* tokens.get; + yield* CliState.setCliDesiredCloudLink(true); + yield* Console.log( + "This T3 environment will be available through T3 Connect the next time T3 starts.", + ); + }), + ), + ), +); + +const connectStatusCommand = Command.make("status", { + ...projectLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("Show persisted T3 Connect and relay client state."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const relayClient = yield* RelayClient.RelayClient; + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all( + [ + CliState.readCliDesiredCloudLink, + tokens.hasCredential, + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_URL_SECRET), + relayClient.resolve, + ], + { concurrency: "unbounded" }, + ); + const status: CloudCliStatus = { + desired, + authenticated, + linked: cloudUserId !== null, + cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, + relayUrl: relayUrl ? bytesToString(relayUrl) : null, + relayClient: executable, + }; + yield* Console.log(formatCloudStatus(status, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const connectUnlinkCommand = Command.make("unlink", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Disable T3 Connect while retaining the stored authorization."), + Command.withHandler((flags) => + runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })), + ), +); + +const connectLogoutCommand = Command.make("logout", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Disable T3 Connect and clear the stored CLI authorization."), + Command.withHandler((flags) => + runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })), + ), +); + +export const connectCommand = Command.make("connect").pipe( + Command.withDescription("Manage headless T3 Connect access."), + Command.withSubcommands([ + connectLoginCommand, + connectLinkCommand, + connectStatusCommand, + connectUnlinkCommand, + connectLogoutCommand, + ]), +); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index c10b8922930687eacb38b2e160f2c4533fd6250a..765ef0583322ff72544ce65bfd035df85f4abda2 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -96,7 +96,7 @@ const make = Effect.gen(function* () { const clear = secrets .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) - .pipe(wrapError("Could not remove the stored T3 Cloud CLI credential.")); + .pipe(wrapError("Could not remove the stored T3 Connect CLI credential.")); const read = Effect.fn("cloud.cli_token.read")(function* () { const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); @@ -146,7 +146,7 @@ const make = Effect.gen(function* () { const url = new URL(request.originalUrl, metadata.redirectUri); const code = url.searchParams.get("code"); if (url.searchParams.get("state") !== state || !code) { - return HttpServerResponse.text("Invalid T3 Cloud authorization callback.", { + return HttpServerResponse.text("Invalid T3 Connect authorization callback.", { status: 400, }); } @@ -154,7 +154,7 @@ const make = Effect.gen(function* () { return yield* HttpServerResponse.html` -

T3 Cloud authorization complete

+

T3 Connect authorization complete

You can close this window and return to your terminal.

@@ -182,13 +182,13 @@ const make = Effect.gen(function* () { authorizationUrl.searchParams.set("state", state); authorizationUrl.searchParams.set("code_challenge", challenge); authorizationUrl.searchParams.set("code_challenge_method", "S256"); - yield* Console.log(`Open this URL to authorize T3 Cloud:\n${authorizationUrl.toString()}\n`); + yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); const code = yield* Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), Effect.catchTag("TimeoutError", () => Effect.fail( new CloudCliTokenManagerError({ - message: "Timed out waiting for T3 Cloud authorization.", + message: "Timed out waiting for T3 Connect authorization.", }), ), ), @@ -213,12 +213,12 @@ const make = Effect.gen(function* () { }); const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError("Could not refresh the T3 Cloud CLI credential.")), + getExistingNoLock().pipe(wrapError("Could not refresh the T3 Connect CLI credential.")), ); const hasCredential = semaphore.withPermits(1)( read().pipe( Effect.map(Option.isSome), - wrapError("Could not read the stored T3 Cloud CLI credential."), + wrapError("Could not read the stored T3 Connect CLI credential."), ), ); const get = semaphore.withPermits(1)( @@ -227,7 +227,7 @@ const make = Effect.gen(function* () { return Option.isSome(token) ? token.value : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError("Could not authorize the T3 Cloud CLI.")), + }).pipe(wrapError("Could not authorize the T3 Connect CLI.")), ); return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 2b7e7d249ef09c9c58553407c54e8a3f7d4a8822..799ab609f433a812898f8659001d9763c9b69957 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -76,7 +76,7 @@ describe("reconcileDesiredCloudLink", () => { expect(error).toMatchObject({ _tag: "EnvironmentHttpUnauthorizedError", - message: "Run `t3 cloud link` to authorize this environment.", + message: "Run `t3 connect link` to authorize this environment.", }); }).pipe( Effect.provideService( diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 38203dae37efc69976ff068bda6a26477eb4e650..896990849b6c757cbd4b1365c2107e57f71ee009 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -509,7 +509,7 @@ const relayClientRequest = ( Effect.mapError( (cause) => new EnvironmentHttpInternalServerError({ - message: `T3 Cloud relay request failed: ${String(cause)}`, + message: `T3 Connect relay request failed: ${String(cause)}`, }), ), ); @@ -535,7 +535,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi onNone: () => Effect.fail( new EnvironmentHttpUnauthorizedError({ - message: "Run `t3 cloud link` to authorize this environment.", + message: "Run `t3 connect link` to authorize this environment.", }), ), onSome: Effect.succeed, @@ -595,7 +595,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi CloudCliTokenManagerError: (error) => failEnvironmentCloudInternalError(error.message)(error.cause), SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Cloud link state.", + "Could not persist desired T3 Connect link state.", ), }), ); @@ -874,7 +874,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") scopes: AuthStandardClientScopes, subject: "cloud-connect", ttl: Duration.minutes(2), - label: "T3 Cloud connect", + label: "T3 Connect connect", proofKeyThumbprint: proof.clientProofKeyThumbprint, }); const responsePayload = { @@ -924,9 +924,9 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }), ); -export const cloudHttpApiLayer = HttpApiBuilder.group( +export const connectHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, - "cloud", + "connect", Effect.fnUntraced(function* (handlers) { const dependencies = yield* cloudHttpDependencies; return handlers diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 6266a39b798b4719a3ad575830c293772ec45821..960f27e752bbcf7a4af8ac575bc352d6b56d4df5 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -303,7 +303,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity publish skipped; T3 Cloud config missing", { + yield* Effect.logDebug("agent activity publish skipped; T3 Connect config missing", { threadId, }); return; @@ -421,7 +421,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity snapshot skipped; T3 Cloud config missing"); + yield* Effect.logDebug("agent activity snapshot skipped; T3 Connect config missing"); return false; } const environmentId = yield* serverEnvironment.getEnvironmentId; @@ -459,7 +459,7 @@ const make = Effect.gen(function* () { function* () { const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logInfo("agent activity publishing standby; T3 Cloud config missing"); + yield* Effect.logInfo("agent activity publishing standby; T3 Connect config missing"); } else { yield* Effect.logInfo("agent activity publishing enabled", { relayUrl: relayConfig.url, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d061578ca68fa0872146b65a97f1b3bd9af50b8c..0bf2f6589f0d7c586c50cca2680bd9ea6d1446b0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -782,7 +782,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({ - get: Effect.die(new Error("Unexpected T3 Cloud CLI authorization request.")), + get: Effect.die(new Error("Unexpected T3 Connect CLI authorization request.")), getExisting: Effect.succeed(Option.none()), hasCredential: Effect.succeed(false), clear: Effect.void, @@ -1713,7 +1713,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const linkProofResponse = yield* fetchEffect(linkProofUrl, { method: "POST", headers: { @@ -1750,7 +1750,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const serverPort = Number(new URL(linkProofUrl).port); const linkProofResponse = yield* fetchEffect(linkProofUrl, { method: "POST", @@ -1762,10 +1762,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { challenge: "relay-link-challenge", relayIssuer: "https://relay.example.test", endpoint: { - httpBaseUrl: linkProofUrl.replace("/api/cloud/link-proof", ""), + httpBaseUrl: linkProofUrl.replace("/api/connect/link-proof", ""), wsBaseUrl: linkProofUrl .replace("http://", "ws://") - .replace("/api/cloud/link-proof", "/ws"), + .replace("/api/connect/link-proof", "/ws"), providerKind: "manual", }, origin: { @@ -1789,9 +1789,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const serverPort = Number(new URL(linkProofUrl).port); - const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { + const linkProofResponse = yield* HttpClient.post("/api/connect/link-proof", { headers: { cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", @@ -1833,9 +1833,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const serverPort = Number(new URL(linkProofUrl).port); - const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { + const linkProofResponse = yield* HttpClient.post("/api/connect/link-proof", { headers: { cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", @@ -1875,9 +1875,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const serverPort = Number(new URL(linkProofUrl).port); - const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { + const linkProofResponse = yield* HttpClient.post("/api/connect/link-proof", { headers: { cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", @@ -1918,7 +1918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const serverPort = Number(new URL(linkProofUrl).port); const linkProofResponse = yield* fetchEffect(linkProofUrl, { method: "POST", @@ -1962,7 +1962,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const credential = (yield* credentialResponse.json) as { readonly credential: string }; const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential); - const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); + const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state"); const response = yield* fetchEffect(linkStateUrl, { headers: { cookie: pairedCookie }, }); @@ -2028,7 +2028,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const preferencesUrl = yield* getHttpServerUrl("/api/cloud/preferences"); + const preferencesUrl = yield* getHttpServerUrl("/api/connect/preferences"); const ownerResponse = yield* fetchEffect(preferencesUrl, { method: "POST", headers: { @@ -2072,7 +2072,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2107,7 +2107,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const postRelayConfig = (body: { readonly relayUrl: string; readonly relayIssuer?: string; @@ -2184,7 +2184,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const postRelayConfig = (cloudUserId: string, environmentCredential: string) => fetchEffect(relayConfigUrl, { method: "POST", @@ -2227,8 +2227,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const initialResponse = yield* fetchEffect(linkStateUrl, { headers: { @@ -2284,7 +2284,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const reconcileUrl = yield* getHttpServerUrl("/api/cloud/reconcile"); + const reconcileUrl = yield* getHttpServerUrl("/api/connect/reconcile"); const response = yield* fetchEffect(reconcileUrl, { method: "POST", }); @@ -2321,9 +2321,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); - const unlinkUrl = yield* getHttpServerUrl("/api/cloud/unlink"); - const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); + const unlinkUrl = yield* getHttpServerUrl("/api/connect/unlink"); + const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", @@ -2398,7 +2398,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2424,7 +2424,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { issuedAt: DateTime.formatIso(now), expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), }); - const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); + const mintUrl = yield* getHttpServerUrl("/api/connect/mint-credential"); const postMint = () => fetchEffect(mintUrl, { method: "POST", @@ -2448,7 +2448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves the documented T3 Cloud mint credential endpoint", () => + it.effect("serves the documented T3 Connect mint credential endpoint", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -2457,7 +2457,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2484,7 +2484,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { issuedAt: DateTime.formatIso(now), expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), }); - const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential"); const response = yield* fetchEffect(mintUrl, { method: "POST", headers: { @@ -2507,7 +2507,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("serves signed T3 Cloud environment health checks", () => + it.effect("serves signed T3 Connect environment health checks", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -2516,7 +2516,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2542,7 +2542,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { issuedAt: DateTime.formatIso(now), expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), }); - const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); const response = yield* fetchEffect(healthUrl, { method: "POST", headers: { @@ -2576,7 +2576,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2602,7 +2602,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { issuedAt: DateTime.formatIso(now), expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), }); - const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); const postHealth = () => fetchEffect(healthUrl, { method: "POST", @@ -2637,7 +2637,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2656,7 +2656,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigResponse.status, 200); const now = yield* DateTime.now; - const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential"); const postMint = (request: ReturnType) => fetchEffect(mintUrl, { method: "POST", @@ -2717,7 +2717,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2756,7 +2756,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { issuedAt: DateTime.formatIso(now), expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), }); - const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); const healthResponse = yield* fetchEffect(healthUrl, { method: "POST", headers: { @@ -2786,7 +2786,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2804,7 +2804,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigResponse.status, 200); const now = yield* DateTime.now; - const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); + const mintUrl = yield* getHttpServerUrl("/api/connect/mint-credential"); const postMint = (request: ReturnType) => fetchEffect(mintUrl, { method: "POST", @@ -2853,7 +2853,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2871,7 +2871,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigResponse.status, 200); const now = yield* DateTime.now; - const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential"); const response = yield* fetchEffect(mintUrl, { method: "POST", headers: { @@ -2904,7 +2904,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2922,7 +2922,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigResponse.status, 200); const now = yield* DateTime.now; - const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const mintUrl = yield* getHttpServerUrl("/api/t3-connect/mint-credential"); const response = yield* fetchEffect(mintUrl, { method: "POST", headers: { @@ -2955,7 +2955,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -2973,7 +2973,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigResponse.status, 200); const now = yield* DateTime.now; - const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); const postHealth = (request: ReturnType) => fetchEffect(healthUrl, { method: "POST", @@ -3020,7 +3020,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -3038,7 +3038,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigResponse.status, 200); const now = yield* DateTime.now; - const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); const response = yield* fetchEffect(healthUrl, { method: "POST", headers: { @@ -3070,7 +3070,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { publicKeyEncoding: { format: "pem", type: "spki" }, }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { method: "POST", headers: { @@ -3088,7 +3088,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigResponse.status, 200); const now = yield* DateTime.now; - const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); const response = yield* fetchEffect(healthUrl, { method: "POST", headers: { @@ -3280,7 +3280,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { config: { devUrl: new URL(crossOriginClientOrigin) }, }); - const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofUrl = yield* getHttpServerUrl("/api/connect/link-proof"); const response = yield* fetchEffect(linkProofUrl, { method: "OPTIONS", headers: { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98bef90bb2e1c534ff8004bbf23e5a118e639b89..94b6cb753a2dbdd8069f1ae1b7daae15499445bd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -69,7 +69,7 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; -import { cloudHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; +import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; @@ -321,7 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( export const makeRoutesLayer = Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( Layer.provide(authHttpApiLayer), - Layer.provide(cloudHttpApiLayer), + Layer.provide(connectHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), @@ -429,9 +429,9 @@ export const makeServerLayer = Layer.unwrap( Effect.sleep("250 millis").pipe( Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), Effect.retry({ times: 4 }), - Effect.tap(() => Effect.logInfo("T3 Cloud desired link reconciled on startup")), + Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), Effect.catch((cause) => - Effect.logWarning("Failed to reconcile T3 Cloud desired link on startup", { + Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { cause, }), ), diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 9baae20a686c9edee71c337c9bd684517bdcb3b4..dc09a7fa0432b106ad39108633d542c32e89a251 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -514,7 +514,7 @@ describe("web cloud link environment client", () => { expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "http://127.0.0.1:3000/api/cloud/link-proof", + "http://127.0.0.1:3000/api/connect/link-proof", ); expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ method: "POST", @@ -553,7 +553,7 @@ describe("web cloud link environment client", () => { }); expect(String(fetchMock.mock.calls[3]?.[0])).toBe( - "http://127.0.0.1:3000/api/cloud/relay-config", + "http://127.0.0.1:3000/api/connect/relay-config", ); expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ method: "POST", @@ -615,7 +615,7 @@ describe("web cloud link environment client", () => { publishAgentActivity: false, }); expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/cloud/link-state", + "http://127.0.0.1:3000/api/connect/link-state", ); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "GET", @@ -654,7 +654,7 @@ describe("web cloud link environment client", () => { }), ); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "POST", credentials: "include", @@ -698,7 +698,7 @@ describe("web cloud link environment client", () => { ); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "POST", credentials: "include", diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index f68849e2765a6a30e26d5c3b85f66ad46733127d..b13b324a41137174f4fd9e766059e0514587b775 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -451,7 +451,7 @@ export function readPrimaryCloudLinkState(): Effect.Effect< return null; } const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); - return yield* client.cloud + return yield* client.connect .linkState({ headers: {} }) .pipe( withPrimaryEnvironmentRequestInit, @@ -465,7 +465,7 @@ export function updatePrimaryCloudPreferences(input: { }): Effect.Effect { return Effect.gen(function* () { const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); - return yield* client.cloud + return yield* client.connect .preferences({ headers: {}, payload: input, @@ -488,7 +488,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { }); } const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); - yield* client.cloud + yield* client.connect .unlink({ headers: {} }) .pipe( withPrimaryEnvironmentRequestInit, @@ -568,7 +568,7 @@ export function linkEnvironmentToCloud(input: { ), ), ); - const proof = yield* environmentClient.cloud + const proof = yield* environmentClient.connect .linkProof({ headers, payload: { @@ -604,7 +604,7 @@ export function linkEnvironmentToCloud(input: { link, }); - yield* environmentClient.cloud + yield* environmentClient.connect .relayConfig({ headers, payload: { @@ -656,7 +656,7 @@ export function linkPrimaryEnvironmentToCloud(input: { ), ), ); - const proof = yield* environmentClient.cloud + const proof = yield* environmentClient.connect .linkProof({ headers: {}, payload: { @@ -695,7 +695,7 @@ export function linkPrimaryEnvironmentToCloud(input: { link, }); - yield* environmentClient.cloud + yield* environmentClient.connect .relayConfig({ headers: {}, payload: { diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index ecc2297595dd6c61b612dab46feab80d86bc9916..095ca8422816bbe257dd72c5913e46127bff4fd7 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -55,7 +55,7 @@ export function usePrimaryCloudLinkState() { let error: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not read T3 Cloud link state."; + error = cause instanceof Error ? cause.message : "Could not read T3 Connect link state."; } return { diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d3f906ef414b288df28842bde7cc6f495fad966e --- /dev/null +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -0,0 +1,60 @@ +import { UserButton, useAuth } from "@clerk/react"; +import { LogInIcon } from "lucide-react"; + +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; + +export function T3ConnectSidebarSignIn() { + if (!hasCloudPublicConfig()) return null; + + return ; +} + +export function T3ConnectSidebarAvatar() { + if (!hasCloudPublicConfig()) return null; + + return ; +} + +function ConfiguredT3ConnectSidebarAvatar() { + const { isLoaded, isSignedIn } = useAuth(); + + if (!isLoaded || !isSignedIn) return null; + + return ( + + ); +} + +function ConfiguredT3ConnectSidebarSignIn() { + const { isLoaded, isSignedIn } = useAuth(); + const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); + + if (!isLoaded || isSignedIn) return null; + + return ( + <> + + + + + Sign in to T3 Connect + + + + {authPrompt} + + ); +} diff --git a/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b38d630dfa3ae48cb7e14a349021370110b66b8e --- /dev/null +++ b/apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx @@ -0,0 +1,29 @@ +import { useClerk } from "@clerk/react"; +import { useState } from "react"; + +import { isElectron } from "../../env"; +import { Dialog, DialogPopup } from "../ui/dialog"; +import { DesktopClerkWaitlist } from "./DesktopClerkWaitlist"; + +export function useT3ConnectAuthPrompt() { + const clerk = useClerk(); + const [desktopAuthOpen, setDesktopAuthOpen] = useState(false); + + const openAuthPrompt = () => { + if (isElectron) { + setDesktopAuthOpen(true); + return; + } + clerk.openWaitlist(); + }; + + const authPrompt = isElectron ? ( + + + + + + ) : null; + + return { authPrompt, openAuthPrompt }; +} diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx index 1ccdf4b10c96d41ec47b4ef9ad28762f03c294f0..78282f659151fc924a8b9cc12a3b4c4ec65dc56a 100644 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx @@ -69,8 +69,8 @@ export function RelayClientInstallDialog() { {isInstalling - ? "T3 Code is preparing this environment for secure access through T3 Cloud." - : "T3 Code needs the relay client to make this environment available through T3 Cloud."} + ? "T3 Code is preparing this environment for secure access through T3 Connect." + : "T3 Code needs the relay client to make this environment available through T3 Connect."} diff --git a/apps/web/src/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx deleted file mode 100644 index c2a1541b1209993aa246618b70bd1c47e400a068..0000000000000000000000000000000000000000 --- a/apps/web/src/components/settings/CloudSettings.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { UserButton, Waitlist, useAuth } from "@clerk/react"; -import { AuthRelayWriteScope } from "@t3tools/contracts"; -import { CloudIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { updatePrimaryCloudPreferences } from "../../cloud/linkEnvironment"; -import { hasCloudPublicConfig } from "../../cloud/publicConfig"; -import { useManagedRelayDevices } from "../../cloud/managedRelayState"; -import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; -import { isElectron } from "../../env"; -import { usePrimarySessionState } from "../../environments/primary"; -import { webRuntime } from "../../lib/runtime"; -import { cn } from "../../lib/utils"; -import { DesktopClerkWaitlist } from "../clerk/DesktopClerkWaitlist"; -import { Button } from "../ui/button"; -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; -import { Skeleton } from "../ui/skeleton"; -import { Switch } from "../ui/switch"; -import { toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; - -const NOTIFICATION_DEVICE_SKELETON_ROWS = ["primary", "secondary"] as const; - -function NotificationDevicesSkeleton() { - return NOTIFICATION_DEVICE_SKELETON_ROWS.map((row) => ( -
-
- - - -
-
- )); -} - -function EmptyNotificationDevices() { - return ( - - - - - - No notification devices - - Sign in on the mobile app to register a device for account-level notifications. - - - - ); -} - -function cloudErrorMessage(error: unknown, fallback: string): string { - return error instanceof Error ? error.message : fallback; -} - -export function CloudSettingsPanel() { - if (!hasCloudPublicConfig()) return null; - - return ; -} - -function ConfiguredCloudSettingsPanel() { - const { isLoaded, isSignedIn } = useAuth(); - - if (!isLoaded) { - return null; - } - - return isSignedIn ? : ; -} - -function CloudWaitlistPanel() { - return ( - - {isElectron ? : } - - ); -} - -function CloudSettingsPanelInner() { - const primaryLinkState = usePrimaryCloudLinkState(); - const primarySessionState = usePrimarySessionState(); - const devicesState = useManagedRelayDevices(); - const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); - const devices = devicesState.data ?? []; - const canManageRelay = - primarySessionState.data?.authenticated === true && - Boolean(primarySessionState.data.scopes?.includes(AuthRelayWriteScope)); - - useEffect(() => { - if (devicesState.error) { - toastManager.add({ - type: "error", - title: "Cloud devices unavailable", - description: devicesState.error, - }); - } - }, [devicesState.error]); - - const updatePublishAgentActivity = async (enabled: boolean) => { - setIsUpdatingPreference(true); - try { - await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); - primaryLinkState.refresh(); - toastManager.add({ - type: "success", - title: enabled ? "Agent activity enabled" : "Agent activity disabled", - description: enabled - ? "This environment can publish agent activity to your notification devices." - : "This environment will stop publishing agent activity.", - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Cloud preference update failed", - description: cloudErrorMessage(error, "Could not update cloud preferences."), - }); - } finally { - setIsUpdatingPreference(false); - } - }; - - return ( - - }> - } - /> - - - void updatePublishAgentActivity(enabled)} - /> - } - /> - - - - - - } - /> - Refresh notification devices - - } - > - {devicesState.data === null ? ( - - ) : devices.length > 0 ? ( - devices.map((device) => ( - - )) - ) : ( - - )} - - - ); -} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 9dab7b61facd9ee42f1a0695f10760d4e860a264..bbffddebfbb3f59879121cb3b74eeadbacc0a93b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -76,6 +76,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { Group, GroupSeparator } from "../ui/group"; import { AnimatedHeight } from "../AnimatedHeight"; import { @@ -121,6 +122,7 @@ import { connectManagedCloudEnvironment, linkPrimaryEnvironmentToCloud, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "~/cloud/linkEnvironment"; import { refreshManagedRelayEnvironments, @@ -1614,7 +1616,7 @@ function CloudLinkSwitch({ }) { const control = ( (null); const [isUpdating, setIsUpdating] = useState(false); + const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); const updateLink = async (enabled: boolean) => { setIsUpdating(true); @@ -1643,7 +1647,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b const clerkToken = await getToken(resolveRelayClerkTokenOptions()); if (enabled) { if (!clerkToken) { - throw new Error("Sign in from T3 Cloud settings before linking this environment."); + throw new Error("Sign in to T3 Connect before linking this environment."); } await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken })); } else { @@ -1655,48 +1659,103 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b refreshManagedRelayEnvironments(); toastManager.add({ type: "success", - title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", + title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", description: enabled - ? "This environment is available through T3 Cloud." - : "This environment is no longer available through T3 Cloud.", + ? "This environment is available through T3 Connect." + : "This environment is no longer available through T3 Connect.", }); } catch (cause) { - const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; + const message = + cause instanceof Error ? cause.message : "Could not update T3 Connect access."; setOperationError(message); toastManager.add({ type: "error", - title: "Could not update T3 Cloud", + title: "Could not update T3 Connect", description: message, }); } finally { setIsUpdating(false); } }; + const updatePublishAgentActivity = async (enabled: boolean) => { + setIsUpdatingPreference(true); + try { + await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); + primaryCloudLinkState.refresh(); + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment can publish agent activity to your notification devices." + : "This environment will stop publishing agent activity.", + }); + } catch (cause) { + toastManager.add({ + type: "error", + title: "Could not update T3 Connect preferences", + description: + cause instanceof Error ? cause.message : "Could not update agent activity publishing.", + }); + } finally { + setIsUpdatingPreference(false); + } + }; const disabledReason = !isSignedIn - ? "Sign in from T3 Cloud settings to manage this environment." + ? "Sign in to T3 Connect" : !canManageRelay - ? "Your session does not have permission to manage T3 Cloud access." + ? "Your session does not have permission to manage T3 Connect access." : null; const linked = primaryCloudLinkState.data?.linked ?? false; return ( - void updateLink(enabled)} + <> + { + if (!isSignedIn) { + openAuthPrompt(); + return; + } + void updateLink(enabled); + }} + /> + } + /> + {linked ? ( + void updatePublishAgentActivity(enabled)} + /> + } /> - } - /> + ) : null} + {authPrompt} + ); } @@ -1704,7 +1763,13 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) return hasCloudPublicConfig() ? : null; } -function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { +function EmptyRemoteEnvironments({ + cloudEnabled = true, + onConnectFromCloud, +}: { + readonly cloudEnabled?: boolean; + readonly onConnectFromCloud?: () => void; +}) { return ( @@ -1713,9 +1778,24 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable No saved remote environments - {cloudEnabled - ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." - : "Click “Add environment” to pair another environment."} + Click “Add environment” to pair another environment + {cloudEnabled ? ( + <> + , or connect one from{" "} + {onConnectFromCloud ? ( + + ) : ( + "T3 Connect" + )} + + ) : null} + . @@ -1743,7 +1823,8 @@ function ConfiguredCloudRemoteEnvironmentRows({ readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironmentIds: ReadonlyArray; }) { - const { getToken } = useAuth(); + const { getToken, isSignedIn } = useAuth(); + const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); const environmentsState = useManagedRelayEnvironments(); const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, @@ -1755,7 +1836,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ try { const clerkToken = await getToken(resolveRelayClerkTokenOptions()); if (!clerkToken) { - throw new Error("Sign in from T3 Cloud settings before connecting this environment."); + throw new Error("Sign in to T3 Connect before connecting this environment."); } const connection = await webRuntime.runPromise( connectManagedCloudEnvironment({ clerkToken, environment }), @@ -1764,14 +1845,14 @@ function ConfiguredCloudRemoteEnvironmentRows({ toastManager.add({ type: "success", title: "Environment connected", - description: `${connection.label} is available through T3 Cloud.`, + description: `${connection.label} is available through T3 Connect.`, }); } catch (cause) { toastManager.add({ type: "error", title: "Could not connect environment", description: - cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment.", + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.", }); } finally { setConnectingEnvironmentId(null); @@ -1789,7 +1870,12 @@ function ConfiguredCloudRemoteEnvironmentRows({ } if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { - return ; + return ( + <> + + {authPrompt} + + ); } return connectableEnvironments.map((environment) => ( @@ -1799,11 +1885,11 @@ function ConfiguredCloudRemoteEnvironmentRows({

{environment.label}

-

T3 Cloud

+

T3 Connect

diff --git a/apps/web/src/routes/settings.cloud.tsx b/apps/web/src/routes/settings.cloud.tsx deleted file mode 100644 index a2854e9719b9f87ab6d9aad7b9fca1cc13ccca74..0000000000000000000000000000000000000000 --- a/apps/web/src/routes/settings.cloud.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; - -import { CloudSettingsPanel } from "../components/settings/CloudSettings"; -import { hasCloudPublicConfig } from "../cloud/publicConfig"; - -export const Route = createFileRoute("/settings/cloud")({ - beforeLoad: () => { - if (!hasCloudPublicConfig()) { - throw redirect({ to: "/settings/general", replace: true }); - } - }, - component: CloudSettingsPanel, -}); diff --git a/docs/README.md b/docs/README.md index f1b7f94fd7ece37ee824506558b577381199b4b6..db32ff8468f8de4ff11aaf0c78f2f8f2c862976a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ - [Getting started](./getting-started/quick-start.md) - [Architecture](./architecture/overview.md) -- [Cloud](./cloud/t3-cloud-clerk.md) +- [T3 Connect](./cloud/t3-connect-clerk.md) - [Integrations](./integrations/source-control-providers.md) - [Mobile](./mobile/app.md) - [Operations](./operations/ci.md) diff --git a/docs/cloud/t3-cloud-clerk.md b/docs/cloud/t3-cloud-clerk.md deleted file mode 100644 index e95c64535592d495e3e776614420eaac2b446545..0000000000000000000000000000000000000000 --- a/docs/cloud/t3-cloud-clerk.md +++ /dev/null @@ -1,174 +0,0 @@ -# T3 Cloud Clerk Setup - -T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts -Clerk JWTs only when they are generated from the `t3-relay` template with the shared -`t3-code-relay` audience. - -## Application Keys - -T3 Cloud is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env` -or `.env.local` file: - -```dotenv -T3CODE_CLERK_PUBLISHABLE_KEY= -T3CODE_CLERK_JWT_TEMPLATE= -T3CODE_CLERK_CLI_OAUTH_CLIENT_ID= -T3CODE_RELAY_URL=https://relay.example.com -``` - -The shared client loader projects these canonical values into framework-specific `VITE_*` and -`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new -client configuration should use the canonical names. - -Configuration precedence is: - -1. Process or CI environment variables. -2. Repository-root `.env.local`. -3. Repository-root `.env`. - -The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public -identifiers, not secrets. -Web, desktop, mobile, and bundled server builds statically inject the values they consume during -their build step. A built artifact does not need an environment file at runtime. CI release builds -should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, -`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and -production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS -environment. - -When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are -absent, the `t3 cloud` CLI command group is omitted. The bundled server still accepts runtime -overrides for self-hosted or operator-managed -deployments. - -For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay -deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`, -`CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in -deployment defaults. -`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads -`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env` -with the deployed HTTPS relay URL. The relay still requires -`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application -environment or commit it to the repository. - -The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference -that database and provision isolated PlanetScale branches, so deploy `prod` before creating a -personal developer stage. - -## Headless CLI OAuth Application - -The `t3 cloud` commands authorize a headless environment with a separate Clerk OAuth application. -This uses an OAuth public client with PKCE, so the CLI stores no client secret. - -In **Clerk Dashboard > OAuth applications**: - -1. Create an OAuth application for the T3 CLI. -2. Enable the **Public** option so authorization-code exchange uses PKCE. -3. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI. -4. Enable the `openid`, `profile`, and `email` scopes. -5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build - environment to the generated public client ID. - -The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's -`/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth -handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. - -The CLI supports these headless operations: - -```sh -t3 cloud login -t3 cloud link -t3 cloud status -t3 cloud unlink -t3 cloud logout -t3 serve -``` - -`t3 cloud login` opens the Clerk authorization flow and stores the CLI credential without enabling -cloud exposure. `t3 cloud link` installs the pinned managed `cloudflared` binary when needed, -authorizes when needed, and records durable intent to expose the environment. It works without a -running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the -managed tunnel. `t3 cloud unlink` records disabled intent immediately, stops a reachable running -connector, and attempts to revoke the relay-side environment record. It retains the stored CLI -authorization so `t3 cloud link` can re-enable exposure without another browser flow. `t3 cloud -logout` performs the same cleanup and removes the stored CLI authorization. - -The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, -forward that port before running `t3 cloud login` or `t3 cloud link`: - -```sh -ssh -L 34338:127.0.0.1:34338 -``` - -A relay-hosted callback broker can remove this port-forward requirement later without changing the -stored PKCE token model. - -## JWT Template - -In **Clerk Dashboard > JWT templates**, create a template with: - -| Setting | Value | -| ------- | ---------------------------- | -| Name | `t3-relay` | -| Claims | `{ "aud": "t3-code-relay" }` | - -Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set -`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and -`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value -is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still -selects the concrete relay deployment, but changing that URL does not require a JWT template change. - -## Desktop OAuth Redirect Allowlist - -The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. -In **Clerk Dashboard > Native applications**, enable native application support and add these -entries under the mobile SSO redirect allowlist: - -```text -t3code-dev://auth/callback -t3code://auth/callback -``` - -The first entry is for local desktop development. The second is for packaged desktop builds. -The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial -sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps -Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback, -and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth -redirect: an external browser cannot use it to reopen the packaged app. - -The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth -flow uses a custom redirect URI, add that exact URI to the same allowlist. - -## Enable Waitlist Access - -For a private beta where people should request access, use **Clerk Dashboard > Waitlist**: - -1. Toggle on **Enable waitlist** and save. -2. Review requests on the same page and select **Invite** or **Deny**. - -Signed-out web and desktop users see Clerk's waitlist enrollment as the T3 Cloud page content, -while approved signed-in users see cloud settings. The browser app also uses `/settings/cloud` as -its Clerk waitlist URL. - -On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the -Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt -`` component is web-only in the Expo SDK. Approved users can use **Sign in** from that -screen. - -## Alternative: Known-User Allowlist - -For a closed beta where all permitted users are known in advance, use an allowlist instead of a -request-and-approval waitlist: - -To restrict the beta to permitted email addresses or domains: - -1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email - domain. -2. Enable the allowlist and save. -3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or - manually created without a waitlist request flow. - -Do not enable an empty allowlist: it blocks all new sign-ups. - -Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud -access. To remove an already-created user's access, ban that user in Clerk so their active -sessions are ended and future sign-ins are rejected. diff --git a/docs/cloud/t3-code-cloud-auth-flow.html b/docs/cloud/t3-code-cloud-auth-flow.html deleted file mode 100644 index 030a9c15dbe119ad27be9a2fcad74ea55c3ce950..0000000000000000000000000000000000000000 --- a/docs/cloud/t3-code-cloud-auth-flow.html +++ /dev/null @@ -1,1160 +0,0 @@ - - - - - - T3 Code Cloud Architecture - - - -
-
-
Implemented architecture
-

T3 Code Cloud Control Plane and Managed Endpoint Flow

-

- T3 Cloud links a locally authorized T3 environment to a signed-in cloud user, provisions a - managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote - connections. The local environment remains the authority that issues environment access - credentials. The hosted relay stores links, reconciles managed endpoint resources, - validates signed proofs, and delivers agent-activity notifications. -

-
- Client -> Relay: Clerk bearer or relay DPoP - Relay -> Environment: relay-signed health and mint proofs - Environment -> Relay: environment bearer plus signed activity proof - Client -> Environment: environment access token plus DPoP -
-
- -
-
-

Security Invariant

-

- A cloud identity is not an environment login. Remote access requires all of the - following: -

-
    -
  • a Clerk-authenticated T3 Cloud user;
  • -
  • an active relay link for that user and environment;
  • -
  • a DPoP proof key held by the remote client;
  • -
  • a relay-signed mint request accepted by the linked local environment; and
  • -
  • an environment-issued credential bound to the remote client's DPoP thumbprint.
  • -
-
- The relay cannot mint an environment access token by itself. It can only ask the local - environment to mint a short-lived bootstrap credential for an authorized cloud user. -
-

During an honest connection flow, the relay cannot act as the remote client:

-
    -
  • - Normal environment API traffic goes directly from the remote client through the - managed endpoint. The relay does not receive the resulting environment access token. -
  • -
  • - The one-time bootstrap credential and resulting environment access token are bound to - the remote client's DPoP public-key thumbprint. Redeeming or using them requires - request proofs signed by the corresponding private key, which stays on the client. -
  • -
  • - Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and - replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal - environment API credentials. -
  • -
  • - Environment health and mint responses are signed by the linked environment and bound - to the request nonce, so a different process behind the tunnel cannot impersonate the - linked environment. -
  • -
-
- The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key. - DPoP prevents credential reuse by the relay or tunnel transport during an honest flow, - but it does not make compromise of the relay signing authority harmless. -
-
- -
-

Product Boundaries

-

- The generic local connector is called the relay client. The current - implementation layer uses cloudflared, but that is an internal transport - detail rather than the product contract. -

-
    -
  • - The relay Worker owns cloud links, managed endpoint allocations, and notifications. -
  • -
  • - The environment server owns local sessions, environment keys, and access tokens. -
  • -
  • The relay client exposes only the environment's loopback HTTP server.
  • -
  • - Web and mobile clients connect directly to the managed environment endpoint after - bootstrap. -
  • -
-
-
- -
-

Current Topology

-
-flowchart LR
-  subgraph Clients["User-facing clients"]
-    Web["Desktop / hosted web UI"]
-    Mobile["Mobile app"]
-    CLI["Headless CLI"]
-  end
-
-  Clerk["Clerk
user identity and OAuth"] - - subgraph Relay["T3 Code Relay on Cloudflare"] - Worker["Relay Worker
HTTP API"] - Queue["APNs delivery queue
with dead-letter queue"] - Hyperdrive["Hyperdrive"] - CF["Cloudflare tunnel + DNS APIs"] - end - - DB["PlanetScale Postgres
prod database + stage branches"] - APNs["Apple Push Notification service"] - Traces["Axiom OTLP traces"] - - subgraph Local["Linked T3 environment"] - Env["Environment server
local auth authority"] - RelayClient["Relay client
cloudflared implementation"] - Secrets["Server secret store
keys + relay config"] - end - - Endpoint["Managed HTTPS/WSS endpoint"] - - Web -->|"Clerk bearer / relay DPoP"| Worker - Mobile -->|"Clerk bearer / relay DPoP"| Worker - CLI -->|"Clerk OAuth bearer"| Worker - Web --> Clerk - Mobile --> Clerk - CLI -->|"PKCE authorization code flow"| Clerk - Worker --> Hyperdrive --> DB - Worker --> Traces - Worker --> CF - Worker --> Queue --> APNs - Worker --> Endpoint - Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env - Env --> Secrets - Web -->|"environment token + DPoP
HTTPS / WSS"| Endpoint - Mobile -->|"environment token + DPoP
HTTPS / WSS"| Endpoint - Env -->|"environment bearer + signed activity proof"| Worker -
-
- -
-

Authentication and Transport Matrix

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
BoundaryTransportAuthenticationPurpose
Web or mobile -> relay link managementHTTPSClerk session-template bearer tokenCreate a link challenge, submit an environment proof, list links, or unlink.
CLI -> relay link managementHTTPSClerk OAuth access token obtained with PKCEReconcile the desired headless cloud link when the server starts.
Web or mobile -> relay protected client endpointsHTTPSRelay-issued DPoP token plus per-request DPoP proof - Check environment status, request a remote connection, and register mobile devices. -
Relay -> managed environment endpointHTTPS over the managed tunnelShort-lived relay-signed JWT request proof - Request a signed health response or ask the local environment to mint a credential. -
Environment -> relay activity publicationHTTPSRelay-issued environment bearer credential plus environment-signed JWT proof - Publish redacted agent-activity state for push notifications and Live Activities. -
Remote client -> environmentHTTPS and WSS over the managed tunnelEnvironment-issued access token bound to the client DPoP keyUse the normal environment HTTP APIs and request a one-time WebSocket ticket.
-
- -
-

Credential Ownership

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CredentialCreated byStored byUse
Clerk session-template JWTClerkWeb or mobile client - Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange. -
CLI OAuth access and refresh tokenClerk OAuthLocal server secret storeAuthorize headless CLI link reconciliation.
Environment Ed25519 key pairLocal environment serverLocal server secret store - Sign link proofs, health responses, mint responses, and activity publication proofs. -
Relay cloud-mint key pairRelay deploymentPrivate key in relay Worker config; public key installed into the environmentSign relay-to-environment health and mint requests.
Relay environment bearer credentialRelayHashed in relay Postgres; plaintext in local server secret storeAuthenticate environment-to-relay agent-activity publication.
Relay DPoP access tokenRelayRemote client memory cache - Authorize status, connect, and mobile registration endpoints with proof of - possession. -
Environment bootstrap credentialLocal environment serverPassed relay -> remote clientOne-time exchange at the environment /oauth/token endpoint.
Environment access tokenLocal environment serverRemote clientAuthorize environment HTTP operations and WebSocket ticket issuance.
WebSocket ticketLocal environment serverRemote client until consumedShort-lived one-time credential for the WSS upgrade.
Relay client connector tokenCloudflare tunnel APILocal server secret store - Start the local relay client. It is passed through TUNNEL_TOKEN, not a - shell argument. -
-
- -
-
-

Flow 1: Link From the Desktop Web UI

-
-sequenceDiagram
-  participant U as Signed-in user
-  participant C as Desktop web UI
-  participant E as Local environment
-  participant R as Relay Worker
-  participant CF as Cloudflare APIs
-  participant RC as Local relay client
-
-  U->>C: Enable T3 Cloud for a locally paired environment
-  C->>E: Check relay client availability
-  alt relay client is missing
-    C->>U: Prompt before download and install
-    U->>C: Confirm
-    C->>E: Stream relay-client install RPC
-    E-->>C: Progress stages
-  end
-  C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
-  R-->>C: Short-lived challenge
-  C->>E: POST /api/cloud/link-proof (local relay:write bearer)
-  E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
-  E-->>C: Environment-signed link proof
-  C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
-  R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
-  R->>CF: Reconcile tunnel, ingress, and CNAME
-  R->>R: Upsert user/environment link and issue environment bearer
-  R-->>C: Managed endpoint, relay config, and connector token
-  C->>E: POST /api/cloud/relay-config (local relay:write bearer)
-  E->>RC: Start relay client with TUNNEL_TOKEN
-          
-
- The local link-proof endpoint requires relay:write, rejects forwarded - authority headers, and accepts only an exact loopback origin. Environment keypair - persistence is atomic across concurrent link attempts. -
-

- A locally paired mobile client uses the same relay challenge, local proof, relay link, - and local relay-config endpoints. The desktop web UI is the surface that checks relay - client availability and offers the managed install dialog. -

-
- -
-

Flow 2: Headless CLI Link

-
-sequenceDiagram
-  participant U as Operator
-  participant CLI as t3 cloud CLI
-  participant Clerk as Clerk OAuth
-  participant S as Environment server on next start
-  participant R as Relay Worker
-  participant RC as Local relay client
-
-  U->>CLI: t3 cloud link
-  CLI->>CLI: Resolve relay client
-  alt relay client is missing
-    CLI->>U: Confirm managed relay-client install
-    U->>CLI: Confirm
-    CLI->>CLI: Install with terminal progress updates
-  end
-  CLI->>Clerk: Browser PKCE authorization-code flow
-  Clerk-->>CLI: OAuth access and refresh tokens
-  CLI->>CLI: Persist desired cloud-link state
-  U->>S: Start T3 environment server
-  S->>R: Create challenge and submit local signed proof
-  R-->>S: Managed endpoint config and connector token
-  S->>RC: Start relay client
-          
-

- t3 cloud login stores authorization without enabling exposure. - t3 cloud unlink disables exposure while retaining authorization. - t3 cloud logout also removes the stored CLI authorization. -

-
- -
-

Flow 3: Remote Connect Bootstrap

-
-sequenceDiagram
-  participant C as Web or mobile client
-  participant Clerk as Clerk
-  participant R as Relay Worker
-  participant E as Managed environment endpoint
-
-  C->>Clerk: Get Clerk session-template JWT
-  C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
-  R-->>C: Relay DPoP access token
-  C->>R: POST /v1/environments/:id/connect (relay DPoP)
-  R->>E: POST /api/t3-cloud/mint-credential (relay-signed proof)
-  E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
-  E-->>R: Bootstrap credential + environment-signed proof
-  R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
-  R-->>C: Managed endpoint + bootstrap credential
-  C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
-  E-->>C: Environment DPoP-bound access token
-  C->>E: POST /api/auth/websocket-ticket
-  E-->>C: One-time WebSocket ticket
-  C->>E: WSS /ws with ticket
-          
-
- Relay-to-environment control requests disable redirects, use only a reconciled managed - endpoint allocation, and require a signed environment response before the relay returns - data to the client. -
-
- -
-

Flow 4: Agent Activity Notifications

-
-sequenceDiagram
-  participant E as Local environment
-  participant R as Relay Worker
-  participant DB as Relay Postgres
-  participant Q as APNs queue
-  participant APNs as Apple Push Notification service
-  participant M as Mobile app
-
-  E->>E: Project publishable thread state and redact failure detail
-  E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
-  Note over E,R: Environment bearer credential + environment-signed per-state proof
-  R->>R: Verify credential, signature, scope, expiry, and nonce
-  R->>DB: Store current activity row
-  R->>Q: Enqueue push or Live Activity delivery jobs
-  Q->>APNs: Deliver signed APNs request
-  APNs-->>M: Push notification or Live Activity update
-          
-

- The local server publishes a deliberately narrow projection. Failed runs use a fixed - redacted summary, and detail strings are capped before publication. -

-
-
- -
-
-

Managed Endpoint Reconciliation

-

- Managed endpoint resources are keyed by (userId, environmentId). The relay - reserves a stable hostname and tunnel name before provisioning, then checkpoints the - tunnel ID, DNS record ID, and ready state in Postgres. -

-
-
- 1. Reserve deterministic allocation - Hostname and tunnel name derive from relay stage, cloud user, and environment ID. -
-
- 2. Reuse or create tunnel - Existing named tunnels are reused. Tunnel ingress is rewritten to the validated - loopback origin. -
-
- 3. Reconcile DNS - Existing CNAMEs are updated and duplicate records are removed. Create conflicts are - recovered by listing and reconciling the winning record. -
-
- 4. Mark ready - Connect and status requests use only ready allocations whose hostname still matches - the relay-managed namespace. -
-
-
- -
-

Unlink Cleanup

-

- Relay unlink removes the user's managed endpoint allocation before revoking the - user/environment link: -

-
    -
  • delete the managed DNS record if present;
  • -
  • delete the Cloudflare tunnel if present;
  • -
  • remove the allocation row;
  • -
  • revoke the user's environment link; and
  • -
  • - revoke the environment publication credential only after the final active link for - that environment key disappears. -
  • -
-

- Local unlink stops the relay client and removes the locally persisted relay URL, issuer, - linked user, environment publication credential, cloud-mint public key, connector - config, and agent-activity preference. -

-
-
- -
-

Cloudflare Tunnel Operational Profile

-

- Managed tunnels are private application backends, not general-purpose public hosting. The - relay provisions one named Cloudflare tunnel and one DNS record per active - (userId, environmentId) allocation. -

-
    -
  • - Lifecycle: the tunnel is created when an environment is linked, reused - across retry-safe reconciliation, kept for the lifetime of that link, and deleted on - unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried. -
  • -
  • - Origin: tunnel ingress is restricted to the linked environment server's - validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are - rejected. -
  • -
  • - Protocols: managed endpoints carry HTTPS request/response traffic and - WSS connections only. Remote clients use the normal environment HTTP APIs and - authenticated WebSocket RPC transport through the tunnel. -
  • -
  • - Relay-generated traffic: the hosted relay sends sparse HTTPS health - checks and credential-mint requests during status checks and remote connection - bootstrap. It does not proxy steady-state remote sessions. -
  • -
  • - Traffic shape: expected load is interactive and user-driven. Active - remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates; - inactive linked environments should carry little or no tunnel traffic. -
  • -
  • - Capacity planning: the project is pre-launch, so there is not yet a - production bandwidth baseline. Tunnel count grows with active linked user/environment - allocations, while bandwidth and concurrent WebSockets grow with active remote use. -
  • -
-
- -
-

SSRF and Tunnel Hardening

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RiskImplemented control
A client asks the relay to tunnel an arbitrary host. - The environment and relay both accept managed tunnel origins only for loopback hosts - and valid ports. -
A forwarded request tricks local origin validation. - The local link-proof handler requires an exact loopback request URL and rejects - forwarded host or protocol headers. -
A stored endpoint is replaced with an arbitrary external URL. - Connect and status resolve a ready allocation from Postgres and require a hostname - under the configured managed endpoint zone. -
A managed endpoint redirects relay egress elsewhere. - Relay-to-environment status and mint requests set redirect handling to - manual. -
A process behind the tunnel returns attacker-controlled data. - The relay verifies environment-signed health and mint responses against the linked - environment public key, expected nonce, and request binding. -
A stolen remote access token is replayed. - Relay and environment access tokens are DPoP-bound; per-request proofs and replay - guards are required. -
The relay client token leaks through process listings or shell parsing. - The relay client is spawned without a shell and receives its connector token in - TUNNEL_TOKEN. -
-
- Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a - dedicated registrable domain with a Public Suffix List entry remains an operational - isolation requirement before broad untrusted use. -
-
- -
-

Relay HTTP API

-

- The relay publishes an OpenAPI document at /openapi.json, interactive API - docs at /docs, and redirects / to the docs. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EndpointAuthenticationPurpose
GET /healthNoneRelay health check.
GET /.well-known/oauth-authorization-serverNoneRelay OAuth token-exchange discovery metadata.
GET /.well-known/oauth-protected-resourceNoneRelay DPoP protected-resource discovery metadata.
GET /v1/environmentsClerk bearerList environments linked to the signed-in user.
POST /v1/client/environment-link-challengesClerk bearerCreate a short-lived user-bound environment link challenge.
POST /v1/client/environment-linksClerk bearer - Verify a local environment proof, reconcile a managed endpoint, and upsert the user - link. -
DELETE /v1/client/environment-links/:environmentIdClerk bearerRemove managed endpoint resources and revoke the user's link.
POST /v1/client/dpop-tokenClerk token in token-exchange payload plus DPoP proofIssue a relay DPoP access token for the requested supported scopes.
POST /v1/environments/:environmentId/statusRelay DPoPRequest and validate a signed environment health response.
POST /v1/environments/:environmentId/connectRelay DPoPRequest and validate an environment bootstrap credential.
POST /v1/mobile/devicesRelay DPoPRegister or update a mobile device for notifications.
POST /v1/mobile/live-activitiesRelay DPoPRegister a Live Activity push token.
DELETE /v1/mobile/devices/:deviceIdRelay DPoPUnregister a mobile device.
- POST /v1/environments/:environmentId/threads/:threadId/agent-activity - Environment bearer plus signed publish proofPublish redacted current agent activity for notification delivery.
-
- -
-

Environment Cloud HTTP API

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
EndpointAuthenticationPurpose
POST /api/cloud/link-proofLocal environment token with relay:writeValidate local origin and sign an environment link proof.
POST /api/cloud/relay-configLocal environment token with relay:writePersist linked relay configuration and start the relay client.
GET /api/cloud/link-stateLocal environment token with relay:readRead the environment's current T3 Cloud link state.
POST /api/cloud/preferencesLocal environment token with relay:writeEnable or disable agent-activity publication.
POST /api/cloud/unlinkLocal environment token with relay:writeStop the relay client and clear local T3 Cloud configuration.
POST /api/t3-cloud/healthRelay-signed proofReturn a signed environment health response.
POST /api/t3-cloud/mint-credentialRelay-signed proofMint a DPoP-bound one-time bootstrap credential for a remote client.
POST /oauth/tokenBootstrap credential plus DPoP proofExchange a bootstrap credential for an environment access token.
POST /api/auth/websocket-ticketEnvironment access tokenIssue a one-time ticket for the WSS upgrade.
-
- -
-
-

Relay Client Installation

-

- Desktop web UI availability checks and installs are local WebSocket RPC methods: - cloud.getRelayClientStatus and streaming - cloud.installRelayClient. The install stream emits the current stage: - checking, waiting for lock, downloading, verifying, installing, validating, and - activating. -

-

- The web UI asks for confirmation before installation and displays a custom progress - dialog. The CLI invokes the same local relay-client service directly and reports the - same progress stages in the terminal. -

-
- -
-

Relay Infrastructure

-
    -
  • Cloudflare Worker for the hosted relay HTTP API.
  • -
  • PlanetScale Postgres database named t3coderelay.
  • -
  • - Production uses the shared database; non-production stages use database branches. -
  • -
  • - Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit. -
  • -
  • Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.
  • -
  • Cloudflare APNs delivery queue plus dead-letter queue.
  • -
  • A five-minute cron job that prunes expired DPoP replay rows.
  • -
  • Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.
  • -
-
-
- -
-

Deployment Configuration

- - - - - - - - - - - - - - - - - - - - - -
BoundaryConfiguration
Relay infrastructure - RELAY_API_ZONE_NAME, RELAY_TUNNEL_ZONE_NAME, optional - RELAY_DOMAIN, CLERK_PUBLISHABLE_KEY, - CLERK_SECRET_KEY, CLERK_JWT_AUDIENCE, and APNs - credentials. -
Source-built desktop/server cloud features - Optional T3CODE_RELAY_URL, T3CODE_CLERK_PUBLISHABLE_KEY, - and T3CODE_CLERK_CLI_OAUTH_CLIENT_ID. Release builds inject public - values at build time. -
Web and mobile cloud clients - Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs - must normalize to an absolute HTTPS origin without credentials, query, fragment, or - non-root path. -
-
- -
-

Implementation Checklist

-
    -
  • - Keep the relay cloud-mint private key hosted-only; install only its public key into - environments. -
  • -
  • - Keep environment signing private keys local and persist keypair creation atomically. -
  • -
  • - Require a local relay:write session before linking or changing relay - config. -
  • -
  • Validate relay URLs as secure absolute HTTPS origins before sending credentials.
  • -
  • Require loopback-only tunnel origins on both sides of the link-proof boundary.
  • -
  • - Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs. -
  • -
  • Disable redirects on relay-to-environment requests.
  • -
  • Validate environment-signed response proofs before consuming tunneled responses.
  • -
  • Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.
  • -
  • - Revoke shared environment publication credentials only after the final active link - disappears. -
  • -
  • Redact and cap local agent failure details before publishing them externally.
  • -
-
- -
-

Standards References

-
    -
  • - RFC 8252: browser-based OAuth for - native apps, used by the CLI PKCE authorization flow. -
  • -
  • - RFC 8693: OAuth token exchange, - used for Clerk-token to relay-DPoP-token exchange. -
  • -
  • - RFC 9449: DPoP proof of possession, - used for relay and environment access tokens. -
  • -
-
-
- - - diff --git a/docs/cloud/t3-code-connect-auth-flow.html b/docs/cloud/t3-code-connect-auth-flow.html new file mode 100644 index 0000000000000000000000000000000000000000..038132ee5cbfee126b30e602f2fb3d5e66c05c66 --- /dev/null +++ b/docs/cloud/t3-code-connect-auth-flow.html @@ -0,0 +1,1160 @@ + + + + + + T3 Connect Architecture + + + +
+
+
Implemented architecture
+

T3 Connect Control Plane and Managed Endpoint Flow

+

+ T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions + a managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote + connections. The local environment remains the authority that issues environment access + credentials. The hosted relay stores links, reconciles managed endpoint resources, + validates signed proofs, and delivers agent-activity notifications. +

+
+ Client -> Relay: Clerk bearer or relay DPoP + Relay -> Environment: relay-signed health and mint proofs + Environment -> Relay: environment bearer plus signed activity proof + Client -> Environment: environment access token plus DPoP +
+
+ +
+
+

Security Invariant

+

+ A cloud identity is not an environment login. Remote access requires all of the + following: +

+
    +
  • a Clerk-authenticated T3 Connect user;
  • +
  • an active relay link for that user and environment;
  • +
  • a DPoP proof key held by the remote client;
  • +
  • a relay-signed mint request accepted by the linked local environment; and
  • +
  • an environment-issued credential bound to the remote client's DPoP thumbprint.
  • +
+
+ The relay cannot mint an environment access token by itself. It can only ask the local + environment to mint a short-lived bootstrap credential for an authorized cloud user. +
+

During an honest connection flow, the relay cannot act as the remote client:

+
    +
  • + Normal environment API traffic goes directly from the remote client through the + managed endpoint. The relay does not receive the resulting environment access token. +
  • +
  • + The one-time bootstrap credential and resulting environment access token are bound to + the remote client's DPoP public-key thumbprint. Redeeming or using them requires + request proofs signed by the corresponding private key, which stays on the client. +
  • +
  • + Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and + replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal + environment API credentials. +
  • +
  • + Environment health and mint responses are signed by the linked environment and bound + to the request nonce, so a different process behind the tunnel cannot impersonate the + linked environment. +
  • +
+
+ The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key. + DPoP prevents credential reuse by the relay or tunnel transport during an honest flow, + but it does not make compromise of the relay signing authority harmless. +
+
+ +
+

Product Boundaries

+

+ The generic local connector is called the relay client. The current + implementation layer uses cloudflared, but that is an internal transport + detail rather than the product contract. +

+
    +
  • + The relay Worker owns cloud links, managed endpoint allocations, and notifications. +
  • +
  • + The environment server owns local sessions, environment keys, and access tokens. +
  • +
  • The relay client exposes only the environment's loopback HTTP server.
  • +
  • + Web and mobile clients connect directly to the managed environment endpoint after + bootstrap. +
  • +
+
+
+ +
+

Current Topology

+
+flowchart LR
+  subgraph Clients["User-facing clients"]
+    Web["Desktop / hosted web UI"]
+    Mobile["Mobile app"]
+    CLI["Headless CLI"]
+  end
+
+  Clerk["Clerk
user identity and OAuth"] + + subgraph Relay["T3 Code Relay on Cloudflare"] + Worker["Relay Worker
HTTP API"] + Queue["APNs delivery queue
with dead-letter queue"] + Hyperdrive["Hyperdrive"] + CF["Cloudflare tunnel + DNS APIs"] + end + + DB["PlanetScale Postgres
prod database + stage branches"] + APNs["Apple Push Notification service"] + Traces["Axiom OTLP traces"] + + subgraph Local["Linked T3 environment"] + Env["Environment server
local auth authority"] + RelayClient["Relay client
cloudflared implementation"] + Secrets["Server secret store
keys + relay config"] + end + + Endpoint["Managed HTTPS/WSS endpoint"] + + Web -->|"Clerk bearer / relay DPoP"| Worker + Mobile -->|"Clerk bearer / relay DPoP"| Worker + CLI -->|"Clerk OAuth bearer"| Worker + Web --> Clerk + Mobile --> Clerk + CLI -->|"PKCE authorization code flow"| Clerk + Worker --> Hyperdrive --> DB + Worker --> Traces + Worker --> CF + Worker --> Queue --> APNs + Worker --> Endpoint + Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env + Env --> Secrets + Web -->|"environment token + DPoP
HTTPS / WSS"| Endpoint + Mobile -->|"environment token + DPoP
HTTPS / WSS"| Endpoint + Env -->|"environment bearer + signed activity proof"| Worker +
+
+ +
+

Authentication and Transport Matrix

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BoundaryTransportAuthenticationPurpose
Web or mobile -> relay link managementHTTPSClerk session-template bearer tokenCreate a link challenge, submit an environment proof, list links, or unlink.
CLI -> relay link managementHTTPSClerk OAuth access token obtained with PKCEReconcile the desired headless cloud link when the server starts.
Web or mobile -> relay protected client endpointsHTTPSRelay-issued DPoP token plus per-request DPoP proof + Check environment status, request a remote connection, and register mobile devices. +
Relay -> managed environment endpointHTTPS over the managed tunnelShort-lived relay-signed JWT request proof + Request a signed health response or ask the local environment to mint a credential. +
Environment -> relay activity publicationHTTPSRelay-issued environment bearer credential plus environment-signed JWT proof + Publish redacted agent-activity state for push notifications and Live Activities. +
Remote client -> environmentHTTPS and WSS over the managed tunnelEnvironment-issued access token bound to the client DPoP keyUse the normal environment HTTP APIs and request a one-time WebSocket ticket.
+
+ +
+

Credential Ownership

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CredentialCreated byStored byUse
Clerk session-template JWTClerkWeb or mobile client + Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange. +
CLI OAuth access and refresh tokenClerk OAuthLocal server secret storeAuthorize headless CLI link reconciliation.
Environment Ed25519 key pairLocal environment serverLocal server secret store + Sign link proofs, health responses, mint responses, and activity publication proofs. +
Relay cloud-mint key pairRelay deploymentPrivate key in relay Worker config; public key installed into the environmentSign relay-to-environment health and mint requests.
Relay environment bearer credentialRelayHashed in relay Postgres; plaintext in local server secret storeAuthenticate environment-to-relay agent-activity publication.
Relay DPoP access tokenRelayRemote client memory cache + Authorize status, connect, and mobile registration endpoints with proof of + possession. +
Environment bootstrap credentialLocal environment serverPassed relay -> remote clientOne-time exchange at the environment /oauth/token endpoint.
Environment access tokenLocal environment serverRemote clientAuthorize environment HTTP operations and WebSocket ticket issuance.
WebSocket ticketLocal environment serverRemote client until consumedShort-lived one-time credential for the WSS upgrade.
Relay client connector tokenCloudflare tunnel APILocal server secret store + Start the local relay client. It is passed through TUNNEL_TOKEN, not a + shell argument. +
+
+ +
+
+

Flow 1: Link From the Desktop Web UI

+
+sequenceDiagram
+  participant U as Signed-in user
+  participant C as Desktop web UI
+  participant E as Local environment
+  participant R as Relay Worker
+  participant CF as Cloudflare APIs
+  participant RC as Local relay client
+
+  U->>C: Enable T3 Connect for a locally paired environment
+  C->>E: Check relay client availability
+  alt relay client is missing
+    C->>U: Prompt before download and install
+    U->>C: Confirm
+    C->>E: Stream relay-client install RPC
+    E-->>C: Progress stages
+  end
+  C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
+  R-->>C: Short-lived challenge
+  C->>E: POST /api/connect/link-proof (local relay:write bearer)
+  E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
+  E-->>C: Environment-signed link proof
+  C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
+  R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
+  R->>CF: Reconcile tunnel, ingress, and CNAME
+  R->>R: Upsert user/environment link and issue environment bearer
+  R-->>C: Managed endpoint, relay config, and connector token
+  C->>E: POST /api/connect/relay-config (local relay:write bearer)
+  E->>RC: Start relay client with TUNNEL_TOKEN
+          
+
+ The local link-proof endpoint requires relay:write, rejects forwarded + authority headers, and accepts only an exact loopback origin. Environment keypair + persistence is atomic across concurrent link attempts. +
+

+ A locally paired mobile client uses the same relay challenge, local proof, relay link, + and local relay-config endpoints. The desktop web UI is the surface that checks relay + client availability and offers the managed install dialog. +

+
+ +
+

Flow 2: Headless CLI Link

+
+sequenceDiagram
+  participant U as Operator
+  participant CLI as t3 connect CLI
+  participant Clerk as Clerk OAuth
+  participant S as Environment server on next start
+  participant R as Relay Worker
+  participant RC as Local relay client
+
+  U->>CLI: t3 connect link
+  CLI->>CLI: Resolve relay client
+  alt relay client is missing
+    CLI->>U: Confirm managed relay-client install
+    U->>CLI: Confirm
+    CLI->>CLI: Install with terminal progress updates
+  end
+  CLI->>Clerk: Browser PKCE authorization-code flow
+  Clerk-->>CLI: OAuth access and refresh tokens
+  CLI->>CLI: Persist desired cloud-link state
+  U->>S: Start T3 environment server
+  S->>R: Create challenge and submit local signed proof
+  R-->>S: Managed endpoint config and connector token
+  S->>RC: Start relay client
+          
+

+ t3 connect login stores authorization without enabling exposure. + t3 connect unlink disables exposure while retaining authorization. + t3 connect logout also removes the stored CLI authorization. +

+
+ +
+

Flow 3: Remote Connect Bootstrap

+
+sequenceDiagram
+  participant C as Web or mobile client
+  participant Clerk as Clerk
+  participant R as Relay Worker
+  participant E as Managed environment endpoint
+
+  C->>Clerk: Get Clerk session-template JWT
+  C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
+  R-->>C: Relay DPoP access token
+  C->>R: POST /v1/environments/:id/connect (relay DPoP)
+  R->>E: POST /api/t3-connect/mint-credential (relay-signed proof)
+  E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
+  E-->>R: Bootstrap credential + environment-signed proof
+  R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
+  R-->>C: Managed endpoint + bootstrap credential
+  C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
+  E-->>C: Environment DPoP-bound access token
+  C->>E: POST /api/auth/websocket-ticket
+  E-->>C: One-time WebSocket ticket
+  C->>E: WSS /ws with ticket
+          
+
+ Relay-to-environment control requests disable redirects, use only a reconciled managed + endpoint allocation, and require a signed environment response before the relay returns + data to the client. +
+
+ +
+

Flow 4: Agent Activity Notifications

+
+sequenceDiagram
+  participant E as Local environment
+  participant R as Relay Worker
+  participant DB as Relay Postgres
+  participant Q as APNs queue
+  participant APNs as Apple Push Notification service
+  participant M as Mobile app
+
+  E->>E: Project publishable thread state and redact failure detail
+  E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
+  Note over E,R: Environment bearer credential + environment-signed per-state proof
+  R->>R: Verify credential, signature, scope, expiry, and nonce
+  R->>DB: Store current activity row
+  R->>Q: Enqueue push or Live Activity delivery jobs
+  Q->>APNs: Deliver signed APNs request
+  APNs-->>M: Push notification or Live Activity update
+          
+

+ The local server publishes a deliberately narrow projection. Failed runs use a fixed + redacted summary, and detail strings are capped before publication. +

+
+
+ +
+
+

Managed Endpoint Reconciliation

+

+ Managed endpoint resources are keyed by (userId, environmentId). The relay + reserves a stable hostname and tunnel name before provisioning, then checkpoints the + tunnel ID, DNS record ID, and ready state in Postgres. +

+
+
+ 1. Reserve deterministic allocation + Hostname and tunnel name derive from relay stage, cloud user, and environment ID. +
+
+ 2. Reuse or create tunnel + Existing named tunnels are reused. Tunnel ingress is rewritten to the validated + loopback origin. +
+
+ 3. Reconcile DNS + Existing CNAMEs are updated and duplicate records are removed. Create conflicts are + recovered by listing and reconciling the winning record. +
+
+ 4. Mark ready + Connect and status requests use only ready allocations whose hostname still matches + the relay-managed namespace. +
+
+
+ +
+

Unlink Cleanup

+

+ Relay unlink removes the user's managed endpoint allocation before revoking the + user/environment link: +

+
    +
  • delete the managed DNS record if present;
  • +
  • delete the Cloudflare tunnel if present;
  • +
  • remove the allocation row;
  • +
  • revoke the user's environment link; and
  • +
  • + revoke the environment publication credential only after the final active link for + that environment key disappears. +
  • +
+

+ Local unlink stops the relay client and removes the locally persisted relay URL, issuer, + linked user, environment publication credential, cloud-mint public key, connector + config, and agent-activity preference. +

+
+
+ +
+

Cloudflare Tunnel Operational Profile

+

+ Managed tunnels are private application backends, not general-purpose public hosting. The + relay provisions one named Cloudflare tunnel and one DNS record per active + (userId, environmentId) allocation. +

+
    +
  • + Lifecycle: the tunnel is created when an environment is linked, reused + across retry-safe reconciliation, kept for the lifetime of that link, and deleted on + unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried. +
  • +
  • + Origin: tunnel ingress is restricted to the linked environment server's + validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are + rejected. +
  • +
  • + Protocols: managed endpoints carry HTTPS request/response traffic and + WSS connections only. Remote clients use the normal environment HTTP APIs and + authenticated WebSocket RPC transport through the tunnel. +
  • +
  • + Relay-generated traffic: the hosted relay sends sparse HTTPS health + checks and credential-mint requests during status checks and remote connection + bootstrap. It does not proxy steady-state remote sessions. +
  • +
  • + Traffic shape: expected load is interactive and user-driven. Active + remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates; + inactive linked environments should carry little or no tunnel traffic. +
  • +
  • + Capacity planning: the project is pre-launch, so there is not yet a + production bandwidth baseline. Tunnel count grows with active linked user/environment + allocations, while bandwidth and concurrent WebSockets grow with active remote use. +
  • +
+
+ +
+

SSRF and Tunnel Hardening

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RiskImplemented control
A client asks the relay to tunnel an arbitrary host. + The environment and relay both accept managed tunnel origins only for loopback hosts + and valid ports. +
A forwarded request tricks local origin validation. + The local link-proof handler requires an exact loopback request URL and rejects + forwarded host or protocol headers. +
A stored endpoint is replaced with an arbitrary external URL. + Connect and status resolve a ready allocation from Postgres and require a hostname + under the configured managed endpoint zone. +
A managed endpoint redirects relay egress elsewhere. + Relay-to-environment status and mint requests set redirect handling to + manual. +
A process behind the tunnel returns attacker-controlled data. + The relay verifies environment-signed health and mint responses against the linked + environment public key, expected nonce, and request binding. +
A stolen remote access token is replayed. + Relay and environment access tokens are DPoP-bound; per-request proofs and replay + guards are required. +
The relay client token leaks through process listings or shell parsing. + The relay client is spawned without a shell and receives its connector token in + TUNNEL_TOKEN. +
+
+ Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a + dedicated registrable domain with a Public Suffix List entry remains an operational + isolation requirement before broad untrusted use. +
+
+ +
+

Relay HTTP API

+

+ The relay publishes an OpenAPI document at /openapi.json, interactive API + docs at /docs, and redirects / to the docs. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointAuthenticationPurpose
GET /healthNoneRelay health check.
GET /.well-known/oauth-authorization-serverNoneRelay OAuth token-exchange discovery metadata.
GET /.well-known/oauth-protected-resourceNoneRelay DPoP protected-resource discovery metadata.
GET /v1/environmentsClerk bearerList environments linked to the signed-in user.
POST /v1/client/environment-link-challengesClerk bearerCreate a short-lived user-bound environment link challenge.
POST /v1/client/environment-linksClerk bearer + Verify a local environment proof, reconcile a managed endpoint, and upsert the user + link. +
DELETE /v1/client/environment-links/:environmentIdClerk bearerRemove managed endpoint resources and revoke the user's link.
POST /v1/client/dpop-tokenClerk token in token-exchange payload plus DPoP proofIssue a relay DPoP access token for the requested supported scopes.
POST /v1/environments/:environmentId/statusRelay DPoPRequest and validate a signed environment health response.
POST /v1/environments/:environmentId/connectRelay DPoPRequest and validate an environment bootstrap credential.
POST /v1/mobile/devicesRelay DPoPRegister or update a mobile device for notifications.
POST /v1/mobile/live-activitiesRelay DPoPRegister a Live Activity push token.
DELETE /v1/mobile/devices/:deviceIdRelay DPoPUnregister a mobile device.
+ POST /v1/environments/:environmentId/threads/:threadId/agent-activity + Environment bearer plus signed publish proofPublish redacted current agent activity for notification delivery.
+
+ +
+

Environment Cloud HTTP API

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointAuthenticationPurpose
POST /api/connect/link-proofLocal environment token with relay:writeValidate local origin and sign an environment link proof.
POST /api/connect/relay-configLocal environment token with relay:writePersist linked relay configuration and start the relay client.
GET /api/connect/link-stateLocal environment token with relay:readRead the environment's current T3 Connect link state.
POST /api/connect/preferencesLocal environment token with relay:writeEnable or disable agent-activity publication.
POST /api/connect/unlinkLocal environment token with relay:writeStop the relay client and clear local T3 Connect configuration.
POST /api/t3-connect/healthRelay-signed proofReturn a signed environment health response.
POST /api/t3-connect/mint-credentialRelay-signed proofMint a DPoP-bound one-time bootstrap credential for a remote client.
POST /oauth/tokenBootstrap credential plus DPoP proofExchange a bootstrap credential for an environment access token.
POST /api/auth/websocket-ticketEnvironment access tokenIssue a one-time ticket for the WSS upgrade.
+
+ +
+
+

Relay Client Installation

+

+ Desktop web UI availability checks and installs are local WebSocket RPC methods: + cloud.getRelayClientStatus and streaming + cloud.installRelayClient. The install stream emits the current stage: + checking, waiting for lock, downloading, verifying, installing, validating, and + activating. +

+

+ The web UI asks for confirmation before installation and displays a custom progress + dialog. The CLI invokes the same local relay-client service directly and reports the + same progress stages in the terminal. +

+
+ +
+

Relay Infrastructure

+
    +
  • Cloudflare Worker for the hosted relay HTTP API.
  • +
  • PlanetScale Postgres database named t3coderelay.
  • +
  • + Production uses the shared database; non-production stages use database branches. +
  • +
  • + Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit. +
  • +
  • Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.
  • +
  • Cloudflare APNs delivery queue plus dead-letter queue.
  • +
  • A five-minute cron job that prunes expired DPoP replay rows.
  • +
  • Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.
  • +
+
+
+ +
+

Deployment Configuration

+ + + + + + + + + + + + + + + + + + + + + +
BoundaryConfiguration
Relay infrastructure + RELAY_API_ZONE_NAME, RELAY_TUNNEL_ZONE_NAME, optional + RELAY_DOMAIN, CLERK_PUBLISHABLE_KEY, + CLERK_SECRET_KEY, CLERK_JWT_AUDIENCE, and APNs + credentials. +
Source-built desktop/server cloud features + Optional T3CODE_RELAY_URL, T3CODE_CLERK_PUBLISHABLE_KEY, + and T3CODE_CLERK_CLI_OAUTH_CLIENT_ID. Release builds inject public + values at build time. +
Web and mobile cloud clients + Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs + must normalize to an absolute HTTPS origin without credentials, query, fragment, or + non-root path. +
+
+ +
+

Implementation Checklist

+
    +
  • + Keep the relay cloud-mint private key hosted-only; install only its public key into + environments. +
  • +
  • + Keep environment signing private keys local and persist keypair creation atomically. +
  • +
  • + Require a local relay:write session before linking or changing relay + config. +
  • +
  • Validate relay URLs as secure absolute HTTPS origins before sending credentials.
  • +
  • Require loopback-only tunnel origins on both sides of the link-proof boundary.
  • +
  • + Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs. +
  • +
  • Disable redirects on relay-to-environment requests.
  • +
  • Validate environment-signed response proofs before consuming tunneled responses.
  • +
  • Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.
  • +
  • + Revoke shared environment publication credentials only after the final active link + disappears. +
  • +
  • Redact and cap local agent failure details before publishing them externally.
  • +
+
+ +
+

Standards References

+
    +
  • + RFC 8252: browser-based OAuth for + native apps, used by the CLI PKCE authorization flow. +
  • +
  • + RFC 8693: OAuth token exchange, + used for Clerk-token to relay-DPoP-token exchange. +
  • +
  • + RFC 9449: DPoP proof of possession, + used for relay and environment access tokens. +
  • +
+
+
+ + + diff --git a/docs/cloud/t3-connect-clerk.md b/docs/cloud/t3-connect-clerk.md new file mode 100644 index 0000000000000000000000000000000000000000..d768c387413648f3f9be77fab6d2296c2717cd1f --- /dev/null +++ b/docs/cloud/t3-connect-clerk.md @@ -0,0 +1,174 @@ +# T3 Connect Clerk Setup + +T3 Connect uses one Clerk application for web, desktop, and mobile authentication. The relay accepts +Clerk JWTs only when they are generated from the `t3-relay` template with the shared +`t3-code-relay` audience. + +## Application Keys + +T3 Connect is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env` +or `.env.local` file: + +```dotenv +T3CODE_CLERK_PUBLISHABLE_KEY= +T3CODE_CLERK_JWT_TEMPLATE= +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID= +T3CODE_RELAY_URL=https://relay.example.com +``` + +The shared client loader projects these canonical values into framework-specific `VITE_*` and +`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new +client configuration should use the canonical names. + +Configuration precedence is: + +1. Process or CI environment variables. +2. Repository-root `.env.local`. +3. Repository-root `.env`. + +The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public +identifiers, not secrets. +Web, desktop, mobile, and bundled server builds statically inject the values they consume during +their build step. A built artifact does not need an environment file at runtime. CI release builds +should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, +`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and +production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS +environment. + +When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are +absent, the `t3 connect` CLI command group is omitted. The bundled server still accepts runtime +overrides for self-hosted or operator-managed +deployments. + +For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay +deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`, +`CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in +deployment defaults. +`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads +`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env` +with the deployed HTTPS relay URL. The relay still requires +`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application +environment or commit it to the repository. + +The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference +that database and provision isolated PlanetScale branches, so deploy `prod` before creating a +personal developer stage. + +## Headless CLI OAuth Application + +The `t3 connect` commands authorize a headless environment with a separate Clerk OAuth application. +This uses an OAuth public client with PKCE, so the CLI stores no client secret. + +In **Clerk Dashboard > OAuth applications**: + +1. Create an OAuth application for the T3 CLI. +2. Enable the **Public** option so authorization-code exchange uses PKCE. +3. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI. +4. Enable the `openid`, `profile`, and `email` scopes. +5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build + environment to the generated public client ID. + +The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's +`/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth +handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. + +The CLI supports these headless operations: + +```sh +t3 connect login +t3 connect link +t3 connect status +t3 connect unlink +t3 connect logout +t3 serve +``` + +`t3 connect login` opens the Clerk authorization flow and stores the CLI credential without enabling +cloud exposure. `t3 connect link` installs the pinned managed `cloudflared` binary when needed, +authorizes when needed, and records durable intent to expose the environment. It works without a +running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the +managed tunnel. `t3 connect unlink` records disabled intent immediately, stops a reachable running +connector, and attempts to revoke the relay-side environment record. It retains the stored CLI +authorization so `t3 connect link` can re-enable exposure without another browser flow. `t3 connect +logout` performs the same cleanup and removes the stored CLI authorization. + +The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, +forward that port before running `t3 connect login` or `t3 connect link`: + +```sh +ssh -L 34338:127.0.0.1:34338 +``` + +A relay-hosted callback broker can remove this port-forward requirement later without changing the +stored PKCE token model. + +## JWT Template + +In **Clerk Dashboard > JWT templates**, create a template with: + +| Setting | Value | +| ------- | ---------------------------- | +| Name | `t3-relay` | +| Claims | `{ "aud": "t3-code-relay" }` | + +Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set +`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and +`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value +is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still +selects the concrete relay deployment, but changing that URL does not require a JWT template change. + +## Desktop OAuth Redirect Allowlist + +The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. +In **Clerk Dashboard > Native applications**, enable native application support and add these +entries under the mobile SSO redirect allowlist: + +```text +t3code-dev://auth/callback +t3code://auth/callback +``` + +The first entry is for local desktop development. The second is for packaged desktop builds. +The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial +sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps +Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback, +and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth +redirect: an external browser cannot use it to reopen the packaged app. + +The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth +flow uses a custom redirect URI, add that exact URI to the same allowlist. + +## Enable Waitlist Access + +For a private beta where people should request access, use **Clerk Dashboard > Waitlist**: + +1. Toggle on **Enable waitlist** and save. +2. Review requests on the same page and select **Invite** or **Deny**. + +Approved signed-in users manage T3 Connect under **Connections**. The web and desktop sidebars do +not expose a dedicated account or waitlist control. Signed-out users reach Clerk's waitlist and +sign-in flow contextually from the T3 Connect controls on the Connections page. + +On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the +Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt +`` component is web-only in the Expo SDK. Approved users can use **Sign in** from that +screen. + +## Alternative: Known-User Allowlist + +For a closed beta where all permitted users are known in advance, use an allowlist instead of a +request-and-approval waitlist: + +To restrict the beta to permitted email addresses or domains: + +1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email + domain. +2. Enable the allowlist and save. +3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or + manually created without a waitlist request flow. + +Do not enable an empty allowlist: it blocks all new sign-ups. + +Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud +access. To remove an already-created user's access, ban that user in Clerk so their active +sessions are ended and future sign-ins are rejected. diff --git a/docs/operations/release.md b/docs/operations/release.md index c9b70f215e6b8769d365ad63b8a77f8b28d65f94..8f149446b66e3a103e77e244a4e9aa12b2477731 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -10,7 +10,7 @@ This document covers the unified release workflow for stable and nightly desktop - scheduled nightly check every three hours - manual `workflow_dispatch` for either channel - Runs quality gates first: lint, typecheck, test. -- Reads the shared production T3 Cloud relay URL and Clerk client configuration before packaging clients. +- Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. - Builds four artifacts in parallel for both channels: - macOS `arm64` DMG - macOS `x64` DMG @@ -30,7 +30,7 @@ This document covers the unified release workflow for stable and nightly desktop - nightly releases are aliased to the `nightly` hosted app channel - Signing is optional and auto-detected per platform from secrets. -## T3 Cloud relay deployment +## T3 Connect relay deployment The relay is a shared control plane versioned separately from client releases. Stable and nightly client builds must point at the same relay so users see the same linked environments when switching diff --git a/infra/relay/README.md b/infra/relay/README.md index 44831cb4c324c27d0e97f508705f43adb96124d6..697fa30cac06848c8985e5063c8cf7a487bbec27 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -1,15 +1,15 @@ -# T3 Code Cloud Relay +# T3 Connect Relay > [!WARNING] -> T3 Code Cloud is currently in private beta. Join the waitlist in the app under Settings > T3 Cloud. +> T3 Connect is currently in private beta. Join the waitlist in the app under Settings > T3 Connect. -The relay is the hosted control plane for T3 Code Cloud. It helps clients discover and connect to +The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to remote environments, manages the cloud-side records needed for those connections, and delivers optional mobile notifications and Live Activities. The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects, regular API and WebSocket traffic goes directly between that client and the selected environment. -See the [cloud architecture overview](../../docs/t3-code-cloud-auth-flow.html) for the larger system +See the [T3 Connect architecture overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the larger system design. ## Responsibilities @@ -159,8 +159,8 @@ and hosted web builds. See: -- [T3 Cloud Clerk Setup](../../docs/t3-cloud-clerk.md) for Clerk keys, JWT templates, and waitlist +- [T3 Connect Clerk Setup](../../docs/cloud/t3-connect-clerk.md) for Clerk keys, JWT templates, and waitlist setup. - [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics. -- [Cloud Architecture Overview](../../docs/t3-code-cloud-auth-flow.html) for the full link, +- [T3 Connect Architecture Overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the full link, connect, endpoint, and notification flows. diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index d79344a0add537936040064a98b0c5196dff9190..c6bec6d4baec2c59619dd4b822ca812eb3313727 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -300,7 +300,7 @@ describe("EnvironmentConnector", () => { environmentId: "env-connector-test", }); - expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/health"]); + expect(seenUrls).toEqual(["https://env.example.test/api/t3-connect/health"]); expect(seenProofs[0]).toMatchObject({ iss: "https://relay.example.test", aud: "t3-env:env-connector-test", @@ -663,7 +663,7 @@ describe("EnvironmentConnector", () => { deviceId: "device-123", }); - expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/mint-credential"]); + expect(seenUrls).toEqual(["https://env.example.test/api/t3-connect/mint-credential"]); expect(seenProofs[0]).toMatchObject({ iss: "https://relay.example.test", aud: "t3-env:env-connector-test", diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index 0eb063a30bf3fc83787540ffb714e7c6ef8e97b1..c62d1166962f360f43e32c4e4146d5072a1475d6 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -458,7 +458,7 @@ const make = Effect.gen(function* () { ); const checkedAt = DateTime.formatIso(now); const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); - const responseOption = yield* environmentClient.cloud.health({ payload: { proof } }).pipe( + const responseOption = yield* environmentClient.connect.health({ payload: { proof } }).pipe( withoutRedirects, Effect.match({ onFailure: (cause) => ({ _tag: "Failure" as const, cause }), @@ -591,30 +591,32 @@ const make = Effect.gen(function* () { ), ); const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); - const decoded = yield* environmentClient.cloud.t3MintCredential({ payload: { proof } }).pipe( - withoutRedirects, - Effect.mapError( - (cause) => - new EnvironmentMintRequestFailed({ - environmentId: input.environmentId, - operation: "connect", - cause, + const decoded = yield* environmentClient.connect + .t3MintCredential({ payload: { proof } }) + .pipe( + withoutRedirects, + Effect.mapError( + (cause) => + new EnvironmentMintRequestFailed({ + environmentId: input.environmentId, + operation: "connect", + cause, + }), + ), + Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EnvironmentMintRequestTimedOut({ + environmentId: input.environmentId, + timeoutMs: ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS, + }), + ), + onSome: Effect.succeed, }), - ), - Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new EnvironmentMintRequestTimedOut({ - environmentId: input.environmentId, - timeoutMs: ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS, - }), - ), - onSome: Effect.succeed, - }), - ), - ); + ), + ); const verified = yield* verifyEnvironmentResponse({ response: decoded, environmentId: input.environmentId, diff --git a/packages/client-runtime/src/managedRelayState.ts b/packages/client-runtime/src/managedRelayState.ts index 7d50f0fdb7ca6a3b5c360d900c3e171200ad6112..f9cfab82594a107eeb1e9678742e4394d1ab2eca 100644 --- a/packages/client-runtime/src/managedRelayState.ts +++ b/packages/client-runtime/src/managedRelayState.ts @@ -53,7 +53,7 @@ export function createManagedRelaySession(input: { try: input.readClerkToken, catch: (cause) => new ManagedRelaySessionError({ - message: "Could not obtain the T3 Cloud session token.", + message: "Could not obtain the T3 Connect session token.", cause, }), }), @@ -76,7 +76,7 @@ function readSessionClerkToken( ? Effect.succeed(token) : Effect.fail( new ManagedRelaySessionError({ - message: "The T3 Cloud session token is unavailable.", + message: "The T3 Connect session token is unavailable.", }), ), ), @@ -121,7 +121,7 @@ function requireClerkToken( if (!session || session.accountId !== accountId) { return Effect.fail( new ManagedRelaySessionError({ - message: "Sign in to T3 Cloud before loading relay data.", + message: "Sign in to T3 Connect before loading relay data.", }), ); } @@ -190,7 +190,7 @@ export function readManagedRelaySnapshotState( let error: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; + error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; } return { data: Option.getOrNull(AsyncResult.value(result)), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 10c9587a0f1d8e77e007aa0a6edeaa9e8e4a9e71..adc5f149cbae1350a58df564ac2e7ad2bf40c149 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -439,9 +439,9 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr }).middleware(EnvironmentAuthenticatedAuth), ) {} -export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud") +export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( - HttpApiEndpoint.post("linkProof", "/api/cloud/link-proof", { + HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", { headers: OptionalBearerHeaders, payload: RelayLinkProofRequest, success: RelayEnvironmentLinkProof, @@ -449,7 +449,7 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud") }).middleware(EnvironmentAuthenticatedAuth), ) .add( - HttpApiEndpoint.post("relayConfig", "/api/cloud/relay-config", { + HttpApiEndpoint.post("relayConfig", "/api/connect/relay-config", { headers: OptionalBearerHeaders, payload: RelayEnvironmentConfigRequest, success: EnvironmentCloudRelayConfigResult, @@ -457,21 +457,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud") }).middleware(EnvironmentAuthenticatedAuth), ) .add( - HttpApiEndpoint.get("linkState", "/api/cloud/link-state", { + HttpApiEndpoint.get("linkState", "/api/connect/link-state", { headers: OptionalBearerHeaders, success: EnvironmentCloudLinkStateResult, error: EnvironmentHttpCloudErrors, }).middleware(EnvironmentAuthenticatedAuth), ) .add( - HttpApiEndpoint.post("unlink", "/api/cloud/unlink", { + HttpApiEndpoint.post("unlink", "/api/connect/unlink", { headers: OptionalBearerHeaders, success: EnvironmentCloudRelayConfigResult, error: EnvironmentHttpCloudErrors, }).middleware(EnvironmentAuthenticatedAuth), ) .add( - HttpApiEndpoint.post("preferences", "/api/cloud/preferences", { + HttpApiEndpoint.post("preferences", "/api/connect/preferences", { headers: OptionalBearerHeaders, payload: EnvironmentCloudPreferencesRequest, success: EnvironmentCloudLinkStateResult, @@ -479,21 +479,21 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud") }).middleware(EnvironmentAuthenticatedAuth), ) .add( - HttpApiEndpoint.post("health", "/api/t3-cloud/health", { + HttpApiEndpoint.post("health", "/api/t3-connect/health", { payload: RelayCloudEnvironmentHealthRequest, success: RelayEnvironmentHealthResponse, error: EnvironmentHttpCloudErrors, }), ) .add( - HttpApiEndpoint.post("mintCredential", "/api/cloud/mint-credential", { + HttpApiEndpoint.post("mintCredential", "/api/connect/mint-credential", { payload: RelayCloudMintCredentialRequest, success: RelayEnvironmentMintResponse, error: EnvironmentHttpCloudErrors, }), ) .add( - HttpApiEndpoint.post("t3MintCredential", "/api/t3-cloud/mint-credential", { + HttpApiEndpoint.post("t3MintCredential", "/api/t3-connect/mint-credential", { payload: RelayCloudMintCredentialRequest, success: RelayEnvironmentMintResponse, error: EnvironmentHttpCloudErrors, @@ -504,4 +504,4 @@ export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) - .add(EnvironmentCloudHttpApi) {} + .add(EnvironmentConnectHttpApi) {} diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index eaec8314fdf92a6c912c591815c907fb9405e7fb..5c8cc1ad001c6e191c152c0db9b28edcf1dee1db 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -537,7 +537,7 @@ export class RelayEnvironmentPrincipal extends Context.Service< const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( HttpApiSecurity.annotate( OpenApi.Description, - "Clerk session or OAuth bearer token for the signed-in T3 Cloud user.", + "Clerk session or OAuth bearer token for the signed-in T3 Connect user.", ), );