diff --git a/.env.example b/.env.example index 79b2adaf0c8..1e4a5dad337 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ # Get this from your relay deployment. `infra/relay` deploys update it automatically. # T3CODE_RELAY_URL=https://relay.example.com +# Optional: hosted app origin used by the CLI paste-code connect flow. +# Defaults to https://app.t3.codes; override to test against a staging deployment. +# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes + # Public, ingest-only mobile OpenTelemetry configuration. # T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces # T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev diff --git a/.plans/t3-connect-remote-setup.html b/.plans/t3-connect-remote-setup.html new file mode 100644 index 00000000000..3c5b702716f --- /dev/null +++ b/.plans/t3-connect-remote-setup.html @@ -0,0 +1,257 @@ + + + + + +Plan: seamless `npx t3 connect` for remote boxes + + + +
+ +

Seamless npx t3 connect for remote boxes

+

Design principle: the smallest diff that ships the UX. No relay/infra changes, no new backend surface, no new auth primitives — every step reuses code that already exists. One PR, built as four phases with clear commit boundaries — each phase compiles, passes tests, and leaves the product working, so the PR reviews commit-by-commit. (Phase 4, web-triggered update, is an optional follow-up PR.)

+ +
+$ npx t3 connect

+To set up T3 Connect, open this URL and sign in:
+  https://app.t3.codes/connect#B64URL_STATE_AND_CHALLENGE

+Paste your authentication code here: [paste]

+Connected as theo@t3.gg!

+Run T3 Code in the background whenever this machine boots? (y/n): y

+T3 Code is set up and ready to go. +
+ +

Why this is a small change

+

The entire t3 connect data plane already works: Clerk PKCE token exchange, encrypted secret store, cloudflared relay-client install, relay environment linking, DPoP tokens. The only broken piece on an SSH box is the redirect: CliTokenManager.login() hardcodes a loopback callback (http://127.0.0.1:34338/callback) that requires a browser on the same machine.

+

We swap that one leg for a hosted copy/paste page and keep everything else. Because PKCE's code_verifier never leaves the box, the displayed one-time code is useless to anyone who sees it — no new token-minting or storage is needed anywhere.

+ +
+

Reused as-is (zero changes)

+ +
+ +

Auth flow (hosted OOB, Clerk PKCE)

+ +
+ + + + + + Remote box — t3 CLI + Laptop — app.t3.codes + Clerk + + + + + + + 1. gen verifier + challenge + state + + + + + 2. user opens /connect#{state,challenge} + + + + + 3. sign in → /oauth/authorize (PKCE) + + + + + 4. redirect /connect/callback?code&state + + + + 5. shows account + paste code + + + + + 6. user pastes code into terminal + + + + + 7. POST /oauth/token {code + verifier} → access/refresh tokens + + + + 8. store token, set desired link, + install relay client → Connected! + +
The verifier never leaves the box (steps 1→7), so the pasted code is worthless if observed. state/challenge ride the URL fragment — they are not secrets.
+
+ +
+

Details that keep it simple

+ +
+ +

The phases (one PR, one commit each)

+

Ordering is dependency order: each phase is independently revertable and the tree is green at every boundary. Phases 1–3 are the PR; phase 4 ships separately later.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
PhaseScopeFiles~LOC
1Hosted code page (web-only, purely additive, zero risk). Two static routes modeled on pair.tsx: /connect (ensure Clerk session, then client-side redirect to authorize — it's a static SPA, no server 302) and /connect/callback (validate params, show account + copyable code + paste warning). Both routes guard against non-hosted deployments — redirect to / unless isHostedStaticApp(), same pattern as pair.tsx, since this bundle also ships in local instances. Plus the Clerk dashboard redirect-URI entry.apps/web/src/routes/connect.tsx
apps/web/src/routes/connect.callback.tsx
~200
2CLI paste flow + single command. Add an OOB login path to CliTokenManager (print URL, Prompt.text for the code, reuse exchangeToken). Make bare t3 connect a handler = login + link (subcommands untouched). Auto-pick paste mode inside SSH sessions (SSH_CONNECTION/SSH_TTY — nothing else); --paste flag as manual override. Loopback stays the default on desktop — no regression.cloud/CliTokenManager.ts (+60)
cloud/publicConfig.ts (+10)
cli/connect.ts (+60)
~150
3Background on boot — Linux first (the SSH case). One new module: pinned runtime install to ~/.t3/runtime/versions/<v> + current symlink, systemd user unit with absolute node/t3 paths, enable-linger. y/n prompt at the end of connect; teardown in logout. Install and service-start failures must land in a log file (under ~/.t3/userdata/logs/) whose path is printed at connect time — systemd user units fail invisibly otherwise. Unit-file generation is pure string-building → trivially testable. macOS launchd / Windows follow as 3b/3c only if wanted.cloud/bootService.ts (new)
cli/connect.ts (+prompt/teardown)
~250
4Web-triggered update (optional follow-up PR; not part of this one, not needed for the core UX). Web detects daemon version < latest-on-channel using the existing version-skew surface + hosted manifest; one authenticated "update" command — client says update, daemon resolves/verifies the version itself (never client-specified — that would be RCE). Stage install → verify → atomic symlink swap → systemctl --user restart. Progress streams reuse the RelayClientInstallProgressEvent pattern.web banner + one control command + daemon update routinelater
+ +

Runtime layout (phase 3)

+
~/.t3/runtime/
+├── versions/0.0.27/        ← npm install --prefix (gets native deps right: node-pty etc.)
+└── current -> versions/0.0.27
+
+~/.config/systemd/user/t3code.service   ← ExecStart=/abs/path/node .../current/.../t3 serve
+loginctl enable-linger $USER            ← survives SSH logout / reboot
+

Why a real npm install and not "reuse the npx binary": the npx cache is ephemeral and t3 ships native deps (node-pty, @ff-labs/fff-node) that need per-platform prebuilds. Why pinned and not npx t3@latest in the unit: a boot-time registry fetch means the box may simply not come up (network down, PATH-less systemd env, nvm). Deterministic boot; updates happen out-of-band (phase 4 follow-up) or by re-running npx t3 connect.

+ +

Explicitly not doing

+ + +

Risks & checks

+ + +

Decision log

+ + +
+ + diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3ce53391fa6..062641b431a 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -5,10 +5,13 @@ import { type RelayClientInstallProgressStage, } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Terminal from "effect/Terminal"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -16,6 +19,7 @@ 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 * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import { FetchHttpClient, @@ -25,8 +29,10 @@ import { } from "effect/unstable/http"; import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; +import packageJson from "../../package.json" with { type: "json" }; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as BootService from "../cloud/bootService.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"; @@ -42,6 +48,66 @@ const jsonFlag = Flag.boolean("json").pipe( Flag.withDefault(false), ); +const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); + +const pasteFlag = Flag.boolean("paste").pipe( + Flag.withDescription( + "Authorize by pasting a code from the hosted app instead of a local browser callback.", + ), + Flag.withDefault(false), +); + +/** + * Inside an SSH session there is no local browser to complete the loopback + * OAuth callback, so the paste-code flow is the only one that can work. + */ +const detectHeadlessSession = Effect.map( + HostProcessEnvironment, + (env) => env.SSH_CONNECTION !== undefined || env.SSH_TTY !== undefined, +); + +const promptForPastedCode = ({ authorizeUrl, validate }: CliTokenManager.PasteCodePromptInput) => + Console.log(`To set up T3 Connect, open this URL and sign in:\n ${authorizeUrl}\n`).pipe( + Effect.andThen( + Prompt.run(Prompt.text({ message: "Paste your authentication code here", validate })), + ), + ); + +/** Returns the connected account identity, if the flow could determine one. */ +const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { + readonly paste: boolean; +}) { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const paste = options.paste || (yield* detectHeadlessSession); + if (!paste) { + yield* tokens.get; + return null; + } + // A stored credential whose refresh fails (revoked, expired grant) must + // fall through to a fresh paste login, not dead-end the command. + const existing = yield* tokens.getExisting.pipe( + Effect.catchTag("CloudCliCredentialRefreshError", () => + Console.log( + "The stored T3 Connect credential could not be refreshed; signing in again.", + ).pipe(Effect.as(Option.none())), + ), + ); + if (Option.isSome(existing)) { + return null; + } + const { token, identity } = yield* CliTokenManager.pasteCodeLogin(promptForPastedCode).pipe( + Effect.mapError((cause) => + // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI + // cancels quietly instead of dumping an authorization error. + Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause) + ? cause + : new CliTokenManager.CloudCliAuthorizationError({ cause }), + ), + ); + yield* tokens.store(token); + return identity; +}); + function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } @@ -299,6 +365,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { if (options.clearAuthorization) { const tokens = yield* CliTokenManager.CloudCliTokenManager; yield* tokens.clear; + + // uninstall itself no-ops when nothing is installed (and on non-Linux), + // so no status pre-check that could mask a real removal failure. + const bootService = yield* BootService.BootService; + yield* bootService.uninstall.pipe( + Effect.tap((removed) => + removed ? Console.log("Removed the T3 Code background service.") : Effect.void, + ), + Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)), + Effect.catch((error) => + Console.warn(`Could not remove the background service: ${error.message}`).pipe( + Effect.as(false), + ), + ), + ); } yield* reportCloudDisconnectResults({ @@ -321,6 +402,8 @@ const runCloudCommand = ( | CliTokenManager.CloudCliTokenManager | RelayClient.RelayClient | EnvironmentAuth.EnvironmentAuth + | BootService.BootService + | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Prompt.Environment @@ -341,6 +424,11 @@ const runCloudCommand = ( RelayClient.layerCloudflared({ baseDir: config.baseDir }), EnvironmentAuth.runtimeLayer, ServerEnvironment.layer, + BootService.layer({ + baseDir: config.baseDir, + logsDir: config.logsDir, + cliVersion: packageJson.version, + }), headlessRelayClientTracingLayer, ).pipe( Layer.provideMerge(FetchHttpClient.layer), @@ -350,17 +438,41 @@ const runCloudCommand = ( return yield* run.pipe(Effect.provide(runtimeLayer)); }); +const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : ""); + +const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: { + readonly paste: boolean; +}) { + 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 null; + } + yield* Console.log( + `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, + ); + + const identity = yield* authorizeCli(options); + yield* CliState.setCliDesiredCloudLink(true); + return { identity } as const; +}); + const connectLoginCommand = Command.make("login", { ...projectLocationFlags, + paste: pasteFlag, }).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 identity = yield* authorizeCli(flags); + yield* Console.log(`Signed in to T3 Connect${connectedAs(identity)}.`); }), ), ), @@ -368,32 +480,20 @@ const connectLoginCommand = Command.make("login", { const connectLinkCommand = Command.make("link", { ...projectLocationFlags, + paste: pasteFlag, }).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; + const linked = yield* linkEnvironmentForConnect(flags); + if (linked) { + yield* Console.log( + `Authorized T3 Connect${connectedAs(linked.identity)}. This environment will be ` + + "available the next time T3 starts.", + ); } - 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.", - ); }), ), ), @@ -456,8 +556,77 @@ const connectLogoutCommand = Command.make("logout", { ), ); -export const connectCommand = Command.make("connect").pipe( - Command.withDescription("Manage headless T3 Connect access."), +const offerBootService = Effect.gen(function* () { + const bootService = yield* BootService.BootService; + const { supported, installed, current } = yield* bootService.status; + if (!supported) { + // Don't prompt for something that can only fail; background setup is + // Linux/systemd-only for now. + return false; + } + if (installed && current) { + yield* Console.log("T3 Code is already set up to run in the background on this machine."); + return true; + } + const wanted = yield* Prompt.run( + Prompt.confirm({ + message: installed + ? "The installed T3 Code background service is from an older setup. Update it now?" + : "Run T3 Code in the background whenever this machine boots? " + + "It stays reachable through T3 Connect even after you log out.", + initial: true, + }), + ); + if (!wanted) { + return false; + } + const plan = yield* bootService.install; + yield* Console.log(`Background service installed. Logs: ${plan.logPath}`); + return true; +}); + +export const connectCommand = Command.make("connect", { + ...projectLocationFlags, + paste: pasteFlag, +}).pipe( + Command.withDescription("Set up T3 Connect for this machine."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const linked = yield* linkEnvironmentForConnect(flags); + if (!linked) { + return; + } + // Show which account was linked so an unexpected identity (a pasted + // code that authorized a different account) is visible before the + // machine is brought online. + yield* Console.log(`\nConnected${connectedAs(linked.identity)}!`); + + // Connect itself already succeeded; a boot-service failure must not + // fail the command, just tell the user what happened and move on. + const background = yield* offerBootService.pipe( + Effect.catchTags({ + BootServiceUnsupportedError: (error) => + Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), + BootServiceCommandError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as(false), + ), + BootServiceInstallError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe( + Effect.as(false), + ), + }), + ); + yield* Console.log( + background + ? "\nGreat, T3 Code is now set up and ready to go." + : "\nT3 Connect is set up. Start the server with `t3 serve` to make this machine reachable.", + ); + }), + ), + ), Command.withSubcommands([ connectLoginCommand, connectLinkCommand, diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts new file mode 100644 index 00000000000..16d073406db --- /dev/null +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -0,0 +1,151 @@ +import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as CliTokenManager from "./CliTokenManager.ts"; +import type { PasteCodePromptInput } from "./CliTokenManager.ts"; + +// pk_test_ +const TEST_ENV = { + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test", + T3CODE_HOSTED_APP_URL: "https://hosted.example.test", +}; + +interface RecordedTokenRequest { + readonly url: string; + readonly params: URLSearchParams; +} + +// A JWT whose payload claims { email: "theo@example.test" } (signature is not +// verified — the CLI only reads the claim to display the connected account). +const idTokenWithEmail = (() => { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ email: "theo@example.test" })).toString("base64url"); + return `${header}.${payload}.`; +})(); + +const makeTokenEndpointLayer = (requests: Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requests.push({ url: request.url, params: new URLSearchParams(body) }); + return HttpClientResponse.fromWeb( + request, + new Response( + // @effect-diagnostics-next-line preferSchemaOverJson:off - Test fixture matches Clerk's token endpoint response. + JSON.stringify({ + access_token: "access-token-1", + refresh_token: "refresh-token-1", + id_token: idTokenWithEmail, + expires_in: 3600, + token_type: "bearer", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }), + ), + ); + +const provideTestEnv = Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), +); + +const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); + +class PromptRejectedError extends Schema.TaggedErrorClass()( + "PromptRejectedError", + { message: Schema.String }, +) {} + +it.layer(NodeServices.layer)("CliTokenManager.pasteCodeLogin", (it) => { + it.effect("prints a hosted authorize URL and exchanges the pasted code with PKCE", () => + Effect.gen(function* () { + const requests: Array = []; + let seenAuthorizeUrl = ""; + + const { token, identity } = yield* CliTokenManager.pasteCodeLogin( + ({ authorizeUrl, validate }: PasteCodePromptInput) => + Effect.gen(function* () { + seenAuthorizeUrl = authorizeUrl; + const request = readConnectAuthorizeRequest(new URL(authorizeUrl)); + assert.isNotNull(request); + return yield* validate(`clerk-code-123.${request!.state}`).pipe( + Effect.mapError((message) => new PromptRejectedError({ message })), + ); + }), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv); + + const authorizeUrl = new URL(seenAuthorizeUrl); + assert.equal(authorizeUrl.origin, "https://hosted.example.test"); + assert.equal(authorizeUrl.pathname, "/connect"); + const request = readConnectAuthorizeRequest(authorizeUrl); + assert.isNotNull(request); + + assert.equal(token.accessToken, "access-token-1"); + assert.equal(token.refreshToken, "refresh-token-1"); + // The id_token's email claim is surfaced so connect can show the account. + assert.equal(identity, "theo@example.test"); + + assert.lengthOf(requests, 1); + const exchange = requests[0]!; + assert.equal(exchange.url, "https://clerk.example.test/oauth/token"); + assert.equal(exchange.params.get("grant_type"), "authorization_code"); + assert.equal(exchange.params.get("code"), "clerk-code-123"); + assert.equal( + exchange.params.get("redirect_uri"), + "https://hosted.example.test/connect/callback", + ); + assert.equal(exchange.params.get("client_id"), "oauth_client_test"); + // The verifier must hash to the challenge advertised in the authorize URL. + const verifier = exchange.params.get("code_verifier"); + assert.isNotNull(verifier); + const digest = yield* Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier!)), + ); + assert.equal(Buffer.from(digest).toString("base64url"), request!.challenge); + }), + ); + + it.effect("rejects pasted codes whose state does not match the request", () => + Effect.gen(function* () { + const requests: Array = []; + + const validationErrors: Array = []; + const result = yield* CliTokenManager.pasteCodeLogin(({ validate }: PasteCodePromptInput) => + validate("clerk-code-123.wrong-state").pipe( + Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))), + Effect.mapError((message) => new PromptRejectedError({ message })), + ), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); + + assert.lengthOf(requests, 0); + assert.lengthOf(validationErrors, 1); + assert.include(validationErrors[0], "different connect request"); + assert.instanceOf(result, PromptRejectedError); + }), + ); + + it.effect("fails without touching the token endpoint when the prompt returns garbage", () => + Effect.gen(function* () { + const requests: Array = []; + + const result = yield* CliTokenManager.pasteCodeLogin(() => + Effect.succeed("not-a-connect-code"), + ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip); + + assert.lengthOf(requests, 0); + assert.isTrue(isAuthorizationError(result)); + }), + ); +}); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 00709370b26..b833f1571ed 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -21,8 +21,19 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { + buildConnectAuthorizeRequestUrl, + buildConnectClerkAuthorizeUrl, + checkConnectAuthCode, + connectCallbackUrl, +} from "@t3tools/shared/connectAuth"; + import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; +import { + cloudCliOAuthConfig, + hostedAppUrlConfig, + type CloudCliOAuthConfig, +} from "./publicConfig.ts"; const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); @@ -33,7 +44,7 @@ const PersistedToken = Schema.Struct({ refreshToken: Schema.String, expiresAtEpochMs: Schema.Number, }); -type PersistedToken = typeof PersistedToken.Type; +export type PersistedToken = typeof PersistedToken.Type; const PersistedTokenJson = Schema.fromJsonString(PersistedToken); const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson); @@ -42,10 +53,37 @@ const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); const OAuthTokenResponse = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.optional(Schema.String), + id_token: Schema.optional(Schema.String), expires_in: Schema.Number, token_type: Schema.String, }); +/** + * Best-effort read of the `email` (or fallback) claim from an OIDC id_token. + * Only used to show the operator which account they linked, so a malformed + * token degrades to "no identity" rather than an error. + */ +function idTokenIdentity(idToken: string | undefined): string | null { + if (!idToken) return null; + const payload = idToken.split(".")[1]; + if (!payload) return null; + const decoded = Encoding.decodeBase64UrlString(payload); + if (decoded._tag !== "Success") return null; + try { + const claims = JSON.parse(decoded.success) as { + readonly email?: unknown; + readonly preferred_username?: unknown; + readonly sub?: unknown; + }; + for (const value of [claims.email, claims.preferred_username, claims.sub]) { + if (typeof value === "string" && value.length > 0) return value; + } + return null; + } catch { + return null; + } +} + export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass()( "CloudCliCredentialRemovalError", { cause: Schema.Defect() }, @@ -106,6 +144,7 @@ export class CloudCliTokenManager extends Context.Service< readonly get: Effect.Effect; readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; readonly hasCredential: Effect.Effect; + readonly store: (token: PersistedToken) => Effect.Effect; readonly clear: Effect.Effect; } >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} @@ -123,9 +162,95 @@ function bytesToString(value: Uint8Array): string { return new TextDecoder().decode(value); } +const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( + metadata: Pick, + params: Record, +) { + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( + HttpClientRequest.bodyUrlParams(params), + httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), + ); + const now = yield* Clock.currentTimeMillis; + return { + token: { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? params.refresh_token ?? "", + expiresAtEpochMs: now + response.expires_in * 1_000, + } satisfies PersistedToken, + identity: idTokenIdentity(response.id_token), + }; +}); + +const makePkceRequest = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); + const challenge = Encoding.encodeBase64Url( + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const state = yield* crypto.randomUUIDv4; + return { verifier, challenge, state }; +}); + +export interface PasteCodePromptInput { + readonly authorizeUrl: string; + readonly validate: (value: string) => Effect.Effect; +} + +/** + * Out-of-band authorization for machines without a local browser (SSH). The + * user opens the hosted /connect URL elsewhere, signs in, and pastes the + * displayed code back into this terminal. The PKCE verifier never leaves + * this process, so the pasted code is useless to an observer, and the state + * bundled into the blob preserves the loopback flow's CSRF check. + */ +export const pasteCodeLogin = Effect.fn("cloud.cli_token.paste_code_login")(function* ( + promptForCode: (input: PasteCodePromptInput) => Effect.Effect, +) { + const metadata = yield* cloudCliOAuthConfig; + const hostedAppUrl = yield* hostedAppUrlConfig; + const { verifier, challenge, state } = yield* makePkceRequest; + + const pasted = yield* promptForCode({ + authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }), + validate: (value) => { + const checked = checkConnectAuthCode(value, state); + return typeof checked === "string" ? Effect.fail(checked) : Effect.succeed(value); + }, + }).pipe( + // Clerk authorization codes expire on this horizon anyway; matching the + // loopback flow's timeout turns an abandoned prompt into a clear error. + Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), + ), + ); + // promptForCode is caller-supplied, so re-check the returned value rather + // than trusting that the prompt ran validate. + const authCode = checkConnectAuthCode(pasted, state); + if (typeof authCode === "string") { + return yield* new CloudCliAuthorizationError({ cause: authCode }); + } + + return yield* exchangeToken(metadata, { + grant_type: "authorization_code", + code: authCode.code, + redirect_uri: connectCallbackUrl(hostedAppUrl), + client_id: metadata.clientId, + code_verifier: verifier, + }); +}); + export const make = Effect.gen(function* () { + // Capture exactly the services the login/refresh flows need at build time + // (matching the pre-paste-flow behavior of capturing the instances), not + // the whole ambient context. const crypto = yield* Crypto.Crypto; - const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const httpClient = yield* HttpClient.HttpClient; + const services = Context.make(Crypto.Crypto, crypto).pipe( + Context.add(HttpClient.HttpClient, httpClient), + ); const secrets = yield* ServerSecretStore.ServerSecretStore; const semaphore = yield* Semaphore.make(1); const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { @@ -144,39 +269,19 @@ export const make = Effect.gen(function* () { return Option.some(yield* decodePersistedToken(bytesToString(encoded.value))); }); - const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( - metadata: CloudCliOAuthConfig, - params: Record, - ) { - const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( - HttpClientRequest.bodyUrlParams(params), - httpClient.execute, - Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), - ); - const now = yield* Clock.currentTimeMillis; - return { - accessToken: response.access_token, - refreshToken: response.refresh_token ?? params.refresh_token ?? "", - expiresAtEpochMs: now + response.expires_in * 1_000, - } satisfies PersistedToken; - }); - const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { const metadata = yield* cloudCliOAuthConfig; - return yield* exchangeToken(metadata, { + const { token: refreshed } = yield* exchangeToken(metadata, { grant_type: "refresh_token", refresh_token: token.refreshToken, client_id: metadata.clientId, }); + return refreshed; }); const login = Effect.fn("cloud.cli_token.login")(function* () { const metadata = yield* cloudCliOAuthConfig; - const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); - const challenge = Encoding.encodeBase64Url( - yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), - ); - const state = yield* crypto.randomUUIDv4; + const { verifier, challenge, state } = yield* makePkceRequest; const callback = yield* Deferred.make(); const callbackRoute = HttpRouter.add( "GET", @@ -214,15 +319,15 @@ export const make = Effect.gen(function* () { ), Layer.build, ); - const authorizationUrl = new URL(metadata.authorizationEndpoint); - authorizationUrl.searchParams.set("client_id", metadata.clientId); - authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri); - authorizationUrl.searchParams.set("response_type", "code"); - authorizationUrl.searchParams.set("scope", metadata.scopes.join(" ")); - 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 Connect:\n${authorizationUrl.toString()}\n`); + const authorizationUrl = buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: metadata.authorizationEndpoint, + clientId: metadata.clientId, + redirectUri: metadata.redirectUri, + scopes: metadata.scopes, + state, + challenge, + }); + yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl}\n`); const code = yield* Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => @@ -233,13 +338,14 @@ export const make = Effect.gen(function* () { ), ), ); - return yield* exchangeToken(metadata, { + const { token } = yield* exchangeToken(metadata, { grant_type: "authorization_code", code, redirect_uri: metadata.redirectUri, client_id: metadata.clientId, code_verifier: verifier, }); + return token; }); const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { @@ -253,7 +359,10 @@ export const make = Effect.gen(function* () { }); const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), + getExistingNoLock().pipe( + wrapError((cause) => new CloudCliCredentialRefreshError({ cause })), + Effect.provide(services), + ), ); const hasCredential = semaphore.withPermits(1)( read().pipe( @@ -263,14 +372,30 @@ export const make = Effect.gen(function* () { ); const get = semaphore.withPermits(1)( Effect.gen(function* () { - const token = yield* getExistingNoLock(); + // A stored credential that can't be read or refreshed (corrupt, revoked, + // expired grant) must fall through to a fresh login rather than dead-end + // the command — this is the loopback counterpart of authorizeCli's + // paste-side fallback. + const token = yield* getExistingNoLock().pipe( + Effect.orElseSucceed(() => Option.none()), + ); return Option.isSome(token) ? token.value : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), + }).pipe( + wrapError((cause) => new CloudCliAuthorizationError({ cause })), + Effect.provide(services), + ), ); + const store = (token: PersistedToken) => + semaphore.withPermits(1)( + persist(token).pipe( + Effect.asVoid, + wrapError((cause) => new CloudCliAuthorizationError({ cause })), + ), + ); - return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); + return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear }); }); export const layer = Layer.effect(CloudCliTokenManager, make); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts new file mode 100644 index 00000000000..30e24e567f2 --- /dev/null +++ b/apps/server/src/cloud/bootService.test.ts @@ -0,0 +1,375 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests stage fixture directories on the real filesystem. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as BootService from "./bootService.ts"; + +const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError); +const isCommandError = Schema.is(BootService.BootServiceCommandError); + +interface RecordedCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +const makeRecordingRunnerLayer = ( + commands: Array, + options?: { readonly failCommand?: string }, +) => + Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.sync(() => { + commands.push({ command: input.command, args: input.args }); + const failed = input.command === options?.failCommand; + return { + stdout: "", + stderr: failed ? `${input.command} exploded` : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ); + +const makeHost = (entry: string): BootService.BootServiceHost => ({ + execPath: "/usr/local/bin/node", + cliEntryPath: entry, +}); + +const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessEnvironment, { HOME: home, USER: "theo" }), + ), + ); + +const makeTestDirs = () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-boot-service-test-")); + // A real file for the stable-entry cases so status can confirm the entry + // point exists. + const stableEntry = NodePath.join(root, "bin.mjs"); + NodeFS.writeFileSync(stableEntry, "#!/usr/bin/env node\n"); + return { + home: root, + baseDir: NodePath.join(root, ".t3"), + logsDir: NodePath.join(root, ".t3", "userdata", "logs"), + stableEntry, + }; +}; + +it("renders a systemd unit with absolute paths and append-mode logging", () => { + const unit = BootService.renderBootServiceUnit({ + nodePath: "/usr/local/bin/node", + t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + assert.equal( + unit, + [ + "[Unit]", + "Description=T3 Code server (T3 Connect)", + "StartLimitIntervalSec=300", + "StartLimitBurst=5", + "", + "[Service]", + "Type=simple", + "WorkingDirectory=%h", + "Environment=T3CODE_HOME=/home/theo/.t3", + "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve", + "Restart=always", + "RestartSec=5", + "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log", + "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"), + ); +}); + +it("quotes systemd values containing spaces and escapes percent specifiers", () => { + assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path"); + assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"'); + assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu"); + + const unit = BootService.renderBootServiceUnit({ + nodePath: "/home/me/my tools/node", + t3EntryPath: "/home/me/T3 Data/bin.mjs", + baseDir: "/home/me/T3 Data", + logPath: "/home/me/100%logs/boot.log", + unitPath: "/home/me/.config/systemd/user/t3code.service", + }); + assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve'); + assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"'); + // append: paths take the rest of the line literally (spaces are fine, + // quoting is not), but % still goes through specifier expansion. + assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log"); + assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log"); +}); + +it("flags package-manager cache entry points as ephemeral", () => { + assert.isTrue( + BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), + ); + assert.isTrue( + BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"), + ); + assert.isTrue( + BootService.isEphemeralCacheEntry( + "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + ), + ); + assert.isTrue( + BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"), + ); + assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs")); + assert.isFalse( + BootService.isEphemeralCacheEntry( + "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs", + ), + ); +}); + +it.layer(NodeServices.layer)("BootService", (it) => { + it.effect("installs the unit, enables the service, and enables linger", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const plan = yield* service.install; + + // A stable entry point is reused directly — no npm install. + assert.equal(plan.t3EntryPath, dirs.stableEntry); + assert.deepEqual( + commands.map((entry) => [entry.command, ...entry.args].join(" ")), + [ + "systemctl --user daemon-reload", + "systemctl --user enable t3code.service", + // restart (not enable --now) so repairing a stale unit replaces a + // running process instead of leaving the old one until reboot. + "systemctl --user restart t3code.service", + "loginctl enable-linger", + ], + ); + + const unitPath = NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service"); + const unit = NodeFS.readFileSync(unitPath, "utf8"); + assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`); + assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`); + + const status = yield* service.status; + assert.isTrue(status.supported); + assert.isTrue(status.installed); + assert.isTrue(status.current); + + const removed = yield* service.uninstall; + assert.isTrue(removed); + assert.isFalse(NodeFS.existsSync(unitPath)); + const statusAfter = yield* service.status; + assert.isFalse(statusAfter.installed); + const removedAgain = yield* service.uninstall; + assert.isFalse(removedAgain); + }), + ); + + it.effect("pins a runtime via npm install when running from the npx cache", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const plan = yield* service.install; + + const runtimeDir = NodePath.join(dirs.baseDir, "runtime", "versions", "0.0.27"); + assert.equal( + plan.t3EntryPath, + NodePath.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"), + ); + assert.deepEqual(commands[0], { + command: "npm", + args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"], + }); + // Success is recorded via a sentinel so interrupted installs re-run. + assert.isTrue(NodeFS.existsSync(NodePath.join(runtimeDir, ".install-complete"))); + }), + ); + + it.effect("cleans up and fails when the pinned runtime install fails", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + const runtimeDir = NodePath.join(dirs.baseDir, "runtime", "versions", "0.0.27"); + // The half-installed tree must not be reused by the next attempt. + assert.isFalse(NodeFS.existsSync(runtimeDir)); + assert.isFalse(NodeFS.existsSync(NodePath.join(runtimeDir, ".install-complete"))); + }), + ); + + it.effect("reports an installed-but-stale unit so connect can offer a repair", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + const unitDir = NodePath.join(dirs.home, ".config", "systemd", "user"); + NodeFS.mkdirSync(unitDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(unitDir, "t3code.service"), + "[Service]\nExecStart=/old/node /old/t3 serve\n", + ); + + const status = yield* service.status; + assert.isTrue(status.supported); + assert.isTrue(status.installed); + assert.isFalse(status.current); + }), + ); + + it.effect("reports a current unit as stale when its entry point is gone", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost(dirs.stableEntry), + }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home)); + + yield* service.install; + assert.isTrue((yield* service.status).current); + + // The pinned runtime (or global bin) was deleted to reclaim space; the + // unit still matches byte-for-byte but would crashloop at boot. + NodeFS.rmSync(dirs.stableEntry); + const status = yield* service.status; + assert.isTrue(status.installed); + assert.isFalse(status.current); + }), + ); + + it.effect("fails on non-Linux platforms without touching the filesystem", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands)), + provideHostRefs(dirs.home, "darwin"), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isUnsupportedError(error)); + assert.lengthOf(commands, 0); + assert.isFalse( + NodeFS.existsSync(NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + ); + + const status = yield* service.status; + assert.isFalse(status.supported); + assert.isFalse(status.installed); + }), + ); + + it.effect("removes the unit file when an activation step fails", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + // A leftover unit would make the next connect report "already set up" + // even though linger never happened. + assert.isFalse( + NodeFS.existsSync(NodePath.join(dirs.home, ".config", "systemd", "user", "t3code.service")), + ); + const status = yield* service.status; + assert.isFalse(status.installed); + }), + ); + + it.effect("appends failed steps to the boot-service log", () => + Effect.gen(function* () { + const dirs = makeTestDirs(); + const commands: Array = []; + const service = yield* BootService.make({ + baseDir: dirs.baseDir, + logsDir: dirs.logsDir, + cliVersion: "0.0.27", + host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), + }).pipe( + Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })), + provideHostRefs(dirs.home), + ); + + const error = yield* service.install.pipe(Effect.flip); + assert.isTrue(isCommandError(error)); + assert.include(error.message, "systemctl exploded"); + + const logPath = NodePath.join(dirs.logsDir, "boot-service.log"); + assert.isTrue(NodeFS.existsSync(logPath)); + assert.include(NodeFS.readFileSync(logPath, "utf8"), "systemctl exploded"); + }), + ); +}); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts new file mode 100644 index 00000000000..272f6361356 --- /dev/null +++ b/apps/server/src/cloud/bootService.ts @@ -0,0 +1,413 @@ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * Installs T3 Code as a per-user boot service so a connected machine stays + * reachable through T3 Connect after the SSH session ends. Linux-only for + * now: systemd user unit + loginctl enable-linger. The service runs a pinned + * runtime installed under /runtime — never `npx t3`, whose cache is + * ephemeral and whose registry fetch at boot would make startup depend on + * the network. + */ + +const BOOT_SERVICE_NAME = "t3code"; +const BOOT_RUNTIME_DIR = "runtime"; + +const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); + +const EPHEMERAL_CACHE_SEGMENTS = [ + "/_npx/", // npx + "\\_npx\\", + "/pnpm/dlx", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx) + "/.pnpm/dlx", + "/.bun/install/cache/", // bunx +]; + +/** + * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager + * caches that can be evicted at any time — a boot service must never point + * there. Global installs, repo checkouts, and the pinned runtime below are + * all stable. + */ +export function isEphemeralCacheEntry(entryPath: string): boolean { + return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment)); +} + +/** + * systemd expands `%` specifiers in most directive values, including the + * `append:` file paths, which take the rest of the line literally and must + * NOT be quoted. + */ +export function escapeSystemdSpecifiers(value: string): string { + return value.replaceAll("%", "%%"); +} + +/** + * systemd word-splits ExecStart and Environment values and expands `%` + * specifiers, so paths with spaces or percents must be quoted and escaped. + */ +export function quoteSystemdValue(value: string): string { + const escaped = escapeSystemdSpecifiers(value); + return /[\s"'\\]/.test(escaped) + ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` + : escaped; +} + +export interface BootServicePlan { + /** Absolute path of the node binary running this CLI. */ + readonly nodePath: string; + /** Absolute path of the pinned t3 entry point the unit will run. */ + readonly t3EntryPath: string; + readonly baseDir: string; + readonly logPath: string; + readonly unitPath: string; +} + +/** + * Pure so it is testable byte-for-byte. systemd user units run with a + * minimal environment: every path must be absolute, and the service must + * not rely on PATH, nvm shims, or shell profiles. Failures land in + * `logPath` because `systemctl --user` failures are otherwise invisible. + */ +export function renderBootServiceUnit(plan: BootServicePlan): string { + // No After=network-online.target: it does not exist in the systemd *user* + // manager, so ordering on it is silently ignored. The server retries its + // relay connection, and Restart=always covers early-boot failures. + return [ + "[Unit]", + "Description=T3 Code server (T3 Connect)", + // Give up after 5 crashes in 5 minutes so a persistently broken install + // (deleted runtime, broken workspace) stops instead of restarting every + // 5s forever and growing the unrotated append log without bound. + "StartLimitIntervalSec=300", + "StartLimitBurst=5", + "", + "[Service]", + "Type=simple", + "WorkingDirectory=%h", + `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, + `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`, + "Restart=always", + "RestartSec=5", + `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, + `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +export class BootServiceUnsupportedError extends Schema.TaggedErrorClass()( + "BootServiceUnsupportedError", + { platform: Schema.String }, +) { + override get message(): string { + return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`; + } +} + +export class BootServiceCommandError extends Schema.TaggedErrorClass()( + "BootServiceCommandError", + { + step: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Background setup failed while ${this.step}: ${this.detail}`; + } +} + +export class BootServiceInstallError extends Schema.TaggedErrorClass()( + "BootServiceInstallError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not set up the T3 Code background service."; + } +} + +export type BootServiceError = + | BootServiceUnsupportedError + | BootServiceCommandError + | BootServiceInstallError; + +export interface BootServiceStatus { + readonly supported: boolean; + readonly installed: boolean; + /** False when the installed unit no longer matches what install would write. */ + readonly current: boolean; + readonly unitPath: string; + readonly logPath: string; +} + +export class BootService extends Context.Service< + BootService, + { + /** Installs the pinned runtime + unit, enables linger, starts the service. */ + readonly install: Effect.Effect; + /** + * Stops and removes the unit; leaves the pinned runtime for reuse. + * Returns whether a unit was actually removed. + */ + readonly uninstall: Effect.Effect; + readonly status: Effect.Effect; + } +>()("t3/cloud/bootService") {} + +export interface BootServiceHost { + readonly execPath: string; + readonly cliEntryPath: string; +} + +const defaultHost = (): BootServiceHost => ({ + execPath: process.execPath, + // When running the packed CLI this is dist/bin.mjs; when stable (global + // install, repo checkout) the boot service runs this same artifact. + cliEntryPath: process.argv[1] ?? "", +}); + +export const make = Effect.fnUntraced(function* (input: { + readonly baseDir: string; + readonly logsDir: string; + readonly cliVersion: string; + readonly host?: BootServiceHost; +}) { + const host = input.host ?? defaultHost(); + const platform = yield* HostProcessPlatform; + const env = yield* HostProcessEnvironment; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + + const homeDir = env.HOME ?? ""; + const unitDir = path.join(homeDir, ".config", "systemd", "user"); + const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); + const logPath = path.join(input.logsDir, "boot-service.log"); + const runtimeVersionDir = path.join( + input.baseDir, + BOOT_RUNTIME_DIR, + "versions", + input.cliVersion, + ); + const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs"); + const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete"); + + const requireSystemdLinux = Effect.gen(function* () { + if (platform !== "linux" || homeDir === "") { + return yield* new BootServiceUnsupportedError({ platform }); + } + }); + + const runStep = ( + step: string, + command: string, + args: ReadonlyArray, + options?: { readonly timeout?: Duration.Input }, + ) => + runner.run({ command, args, env: { ...env }, timeout: options?.timeout }).pipe( + Effect.mapError( + (cause) => new BootServiceCommandError({ step, detail: String(cause.message) }), + ), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new BootServiceCommandError({ + step, + detail: result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`, + }), + ), + Effect.tapError((error) => + DateTime.now.pipe( + Effect.flatMap((now) => + fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { + flag: "a", + }), + ), + Effect.ignore, + ), + ), + ); + + /** + * Ensures plannedEntryPath exists before the unit points at it. A stable + * install (global bin, repo checkout) is used as-is; an ephemeral cache + * entry is replaced by `npm install --prefix`-ing the exact running + * version into /runtime/versions/. A real install (not a copy + * of bin.mjs) because t3 ships native deps like node-pty. + */ + const ensurePinnedRuntime = Effect.gen(function* () { + if (!isEphemeralCacheEntry(host.cliEntryPath)) { + return; + } + // The sentinel is written only after npm exits 0. Checking the entry + // file alone is not enough: npm extracts files before running native + // builds (node-pty), so a killed install leaves a plausible-looking but + // broken tree behind. + const alreadyPinned = yield* fs + .exists(runtimeSentinelPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + if (alreadyPinned) { + return; + } + yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe( + Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + yield* runStep( + "installing the pinned t3 runtime (this can take a few minutes)", + "npm", + [ + "install", + "--prefix", + runtimeVersionDir, + "--no-fund", + "--no-audit", + `t3@${input.cliVersion}`, + ], + // Native deps (node-pty) can compile from source on slow boxes; the + // ProcessRunner default of 60s would kill a healthy install. + { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT }, + ).pipe( + Effect.tapError(() => + fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + yield* fs + .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + }); + + // Where the unit will point: derivable without touching the network, so + // status can compare units purely; install materializes it first. + const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath) + ? runtimeEntryPath + : host.cliEntryPath; + const plan: BootServicePlan = { + nodePath: host.execPath, + t3EntryPath: plannedEntryPath, + baseDir: input.baseDir, + logPath, + unitPath, + }; + + const install: BootService["Service"]["install"] = Effect.gen(function* () { + yield* requireSystemdLinux; + yield* fs + .makeDirectory(input.logsDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + + yield* ensurePinnedRuntime; + + yield* fs.makeDirectory(unitDir, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + ); + + // If any activation step fails, remove the unit again: a leftover file + // would make the next `t3 connect` report the service as already set up + // even though it was never enabled or lingered. + yield* Effect.gen(function* () { + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + yield* runStep("enabling the service", "systemctl", [ + "--user", + "enable", + BOOT_SERVICE_UNIT_FILE, + ]); + // restart rather than enable --now: --now does not replace an already + // running process, so repairing a stale unit would leave the old + // server running until reboot. restart also starts a stopped service. + yield* runStep("starting the service", "systemctl", [ + "--user", + "restart", + BOOT_SERVICE_UNIT_FILE, + ]); + // Linger keeps the user manager (and this service) running without an + // open session — the whole point on a box reached over SSH. No + // username argument: loginctl defaults to the calling user, which is + // always right, while $USER can be stale (su without -l) or unset. + yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]); + }).pipe(Effect.tapError(() => rollbackFailedInstall)); + + return plan; + }).pipe(Effect.withSpan("cloud.boot_service.install")); + + // If activation fails partway (e.g. enable succeeds but restart/linger + // fails), leave nothing behind: disable removes the enable symlink, remove + // deletes the file, daemon-reload clears the stale definition — otherwise a + // dangling wants/ symlink logs "Failed to load unit" at every boot and the + // next connect misreports the state. + const rollbackFailedInstall = Effect.gen(function* () { + yield* runStep("cleaning up the service", "systemctl", [ + "--user", + "disable", + BOOT_SERVICE_UNIT_FILE, + ]).pipe(Effect.ignore); + yield* fs.remove(unitPath).pipe(Effect.ignore); + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe( + Effect.ignore, + ); + }); + + const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { + yield* requireSystemdLinux; + const exists = yield* fs + .exists(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + if (!exists) { + return false; + } + yield* runStep("stopping the service", "systemctl", [ + "--user", + "disable", + "--now", + BOOT_SERVICE_UNIT_FILE, + ]).pipe(Effect.ignore({ log: true })); + yield* fs + .remove(unitPath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); + return true; + }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); + + const status: BootService["Service"]["status"] = Effect.gen(function* () { + if (platform !== "linux" || homeDir === "") { + return { supported: false, installed: false, current: false, unitPath, logPath }; + } + const unit = yield* fs.readFileString(unitPath).pipe( + Effect.map((content): string | null => content), + Effect.orElseSucceed((): string | null => null), + ); + if (unit === null) { + return { supported: true, installed: false, current: false, unitPath, logPath }; + } + // A unit is current only if it matches what install would write now (an + // older CLI wrote a different runtime/node path) AND the entry point it + // references still exists (a pinned runtime under ~/.t3 can be deleted to + // reclaim space). Either mismatch makes connect offer a repair. + const entryExists = yield* fs.exists(plannedEntryPath).pipe(Effect.orElseSucceed(() => false)); + const current = unit === renderBootServiceUnit(plan) && entryExists; + return { supported: true, installed: true, current, unitPath, logPath }; + }).pipe(Effect.withSpan("cloud.boot_service.status")); + + return BootService.of({ install, uninstall, status }); +}); + +export const layer = (input: { + readonly baseDir: string; + readonly logsDir: string; + readonly cliVersion: string; + readonly host?: BootServiceHost; +}) => Layer.effect(BootService, make(input)).pipe(Layer.provide(ProcessRunner.layer)); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index ed2e5a4cf75..1aee587ad4c 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -194,6 +194,7 @@ describe("reconcileDesiredCloudLink", () => { get: unusedSecretStoreOperation(), getExisting: Effect.succeed(Option.none()), hasCredential: unusedSecretStoreOperation(), + store: () => unusedSecretStoreOperation(), clear: unusedSecretStoreOperation(), }), ), diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 176b31d7566..9f76e87db2f 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,3 +1,4 @@ +import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; @@ -15,7 +16,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefi declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined; const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; -const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; +const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES; function validateRelayUrl(value: string) { const relayUrl = normalizeSecureRelayUrl(value); @@ -100,6 +101,16 @@ export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { export const relayUrlConfig = makeRelayUrlConfig(); +/** + * Hosted app origin used for the paste-code connect flow on headless + * machines. Overridable so staging/nightly builds can point their CLIs at a + * matching hosted deployment. + */ +export const hostedAppUrlConfig = makePublicValueConfig( + "T3CODE_HOSTED_APP_URL", + DEFAULT_HOSTED_APP_URL, +); + function makePublicValueConfig(name: string, fallback: string) { const runtimeConfig = Config.nonEmptyString(name); return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts new file mode 100644 index 00000000000..a6905411208 --- /dev/null +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + buildConnectCliClerkAuthorizeUrl, + hasConnectCliAuthConfig, + readConnectCliCallbackResult, +} from "./connectCliAuth"; + +// Any pk_test_* key decodes to .clerk.accounts.dev. +const TEST_PUBLISHABLE_KEY = `pk_test_${btoa("witty-mole-42.clerk.accounts.dev$")}`; + +describe("connectCliAuth", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("requires both the publishable key and the CLI OAuth client id", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.com"); + expect(hasConnectCliAuthConfig()).toBe(false); + + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + expect(hasConnectCliAuthConfig()).toBe(true); + }); + + it("builds the Clerk authorize URL with the current origin's callback", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const authorizeUrl = buildConnectCliClerkAuthorizeUrl( + { state: "state-1", challenge: "challenge-1" }, + "https://app.t3.codes", + ); + expect(authorizeUrl).not.toBeNull(); + + const url = new URL(authorizeUrl!); + expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("redirect_uri")).toBe("https://app.t3.codes/connect/callback"); + expect(url.searchParams.get("state")).toBe("state-1"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("returns null when the CLI OAuth client id is not configured", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + expect( + buildConnectCliClerkAuthorizeUrl( + { state: "state-1", challenge: "challenge-1" }, + "https://app.t3.codes", + ), + ).toBeNull(); + }); + + it("reads the code and state Clerk echoes back to the callback", () => { + expect( + readConnectCliCallbackResult( + new URL("https://app.t3.codes/connect/callback?code=abc&state=state-1"), + ), + ).toEqual({ code: "abc", state: "state-1" }); + expect( + readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?code=abc")), + ).toBeNull(); + expect( + readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?state=s")), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts new file mode 100644 index 00000000000..78857f874c4 --- /dev/null +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -0,0 +1,94 @@ +import { + buildConnectClerkAuthorizeUrl, + connectCallbackUrl, + CONNECT_OAUTH_SCOPES, + type ConnectAuthorizeRequest, +} from "@t3tools/shared/connectAuth"; +import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; + +import { isHostedStaticApp } from "../hostedPairing"; +import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig"; + +const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; + +export function resolveConnectCliOAuthClientId(): string | null { + return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); +} + +export function hasConnectCliAuthConfig(): boolean { + return Boolean( + resolveCloudPublicConfig().clerkPublishableKey && resolveConnectCliOAuthClientId(), + ); +} + +/** + * Gate for the /connect routes: the CLI handshake only exists on the hosted + * deployment (the same bundle ships inside local instances) and needs the + * Clerk CLI OAuth client configured at build time. + */ +export function connectCliAuthRoutesEnabled(): boolean { + return isHostedStaticApp() && hasCloudPublicConfig() && hasConnectCliAuthConfig(); +} + +/** + * Builds the Clerk authorize URL for a CLI-initiated connect request. The + * state is mirrored into sessionStorage so the callback page can verify the + * response matches a request this browser actually started. + */ +export function buildConnectCliClerkAuthorizeUrl( + request: ConnectAuthorizeRequest, + currentOrigin: string = window.location.origin, +): string | null { + const { clerkPublishableKey } = resolveCloudPublicConfig(); + const clientId = resolveConnectCliOAuthClientId(); + if (!clerkPublishableKey || !clientId) { + return null; + } + return buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`, + clientId, + redirectUri: connectCallbackUrl(currentOrigin), + scopes: CONNECT_OAUTH_SCOPES, + state: request.state, + challenge: request.challenge, + }); +} + +export function rememberConnectCliAuthState(state: string): void { + try { + window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); + } catch { + // Session storage can be unavailable (e.g. blocked). The callback page + // then falls back to trusting the state Clerk echoed back. + } +} + +/** + * Read-only on purpose: this runs during render, where a removal would be + * consumed by React's double-invoked/discarded renders (StrictMode) and + * silently disable the state check. The value is not a secret and is + * overwritten by the next /connect visit. + */ +export function readConnectCliAuthState(): string | null { + try { + return window.sessionStorage.getItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY); + } catch { + return null; + } +} + +export interface ConnectCliCallbackResult { + readonly code: string; + readonly state: string; +} + +export function readConnectCliCallbackResult( + url: URL = new URL(window.location.href), +): ConnectCliCallbackResult | null { + const code = url.searchParams.get("code")?.trim() ?? ""; + const state = url.searchParams.get("state")?.trim() ?? ""; + if (!code || !state) { + return null; + } + return { code, state }; +} diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index d9d0e5f44cb..2caf4f52f36 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -24,7 +24,7 @@ export interface CloudPublicConfig { }; } -function trimNonEmpty(value: string | undefined): string | null { +export function trimNonEmpty(value: string | undefined): string | null { return value?.trim() || null; } diff --git a/apps/web/src/components/auth/AuthSurfaceShell.tsx b/apps/web/src/components/auth/AuthSurfaceShell.tsx new file mode 100644 index 00000000000..1545a2578dd --- /dev/null +++ b/apps/web/src/components/auth/AuthSurfaceShell.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; + +import { APP_DISPLAY_NAME } from "../../branding"; + +/** + * Full-screen card for standalone auth pages, mirroring the pairing surface's + * treatment. Used by the CLI-connect authorize and callback surfaces. + */ +export function AuthSurfaceShell({ children }: { readonly children: ReactNode }) { + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx new file mode 100644 index 00000000000..bba8a56e4c1 --- /dev/null +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -0,0 +1,163 @@ +import { useAuth, useClerk, useUser } from "@clerk/react"; +import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; +import { useEffect, useMemo, useState } from "react"; + +import { + buildConnectCliClerkAuthorizeUrl, + readConnectCliAuthState, + readConnectCliCallbackResult, + rememberConnectCliAuthState, +} from "../../cloud/connectCliAuth"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { AuthSurfaceShell } from "../auth/AuthSurfaceShell"; +import { Button } from "../ui/button"; + +function ConnectCliAuthMessage({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( + <> +

{title}

+

{description}

+ + ); +} + +const invalidLinkMessage = { + title: "This connect link is incomplete", + description: + "The link is missing its authorization request. Re-run `t3 connect` in your terminal and open the freshly printed URL.", +} as const; + +/** + * /connect: the URL a headless CLI prints. Waits for a Clerk session, then + * forwards the CLI's PKCE request to Clerk's authorize endpoint. + */ +export function ConnectCliAuthorizeSurface() { + const request = useMemo(() => readConnectAuthorizeRequest(new URL(window.location.href)), []); + const clerk = useClerk(); + const { isLoaded, isSignedIn } = useAuth(); + const [redirecting, setRedirecting] = useState(false); + + useEffect(() => { + if (!request || !isLoaded || redirecting) { + return; + } + if (!isSignedIn) { + clerk.openSignIn({ forceRedirectUrl: window.location.href }); + return; + } + const authorizeUrl = buildConnectCliClerkAuthorizeUrl(request); + if (!authorizeUrl) { + return; + } + setRedirecting(true); + rememberConnectCliAuthState(request.state); + window.location.assign(authorizeUrl); + }, [clerk, isLoaded, isSignedIn, redirecting, request]); + + if (!request) { + return ( + + + + ); + } + + return ( + + + {isLoaded && !isSignedIn ? ( +
+ +
+ ) : null} +
+ ); +} + +/** + * /connect/callback: Clerk's redirect target. Shows the one-time code the + * user pastes back into the waiting terminal. + */ +export function ConnectCliCallbackSurface() { + const result = useMemo(() => readConnectCliCallbackResult(), []); + const expectedState = useMemo(() => readConnectCliAuthState(), []); + const { user } = useUser(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" }); + + if (!result) { + return ( + + + + ); + } + + // Fail closed: the legitimate callback always lands in the same browser + // that visited /connect (which recorded the state), so a missing or + // mismatched state means this page was reached some other way — the CSRF + // shape the state parameter exists to stop. Refuse to display a code. + if (expectedState === null || expectedState !== result.state) { + return ( + + + + ); + } + + const accountLabel = user?.primaryEmailAddress?.emailAddress ?? user?.username ?? null; + const authCode = encodeConnectAuthCode(result); + + return ( + + + +
+ + {authCode} + +
+ +
+ +
+ +

+ Only paste this code into a terminal session you started yourself. Anyone holding it can + link their machine to your T3 Connect account while it is valid. +

+
+ ); +} diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 2caf844af9b..6a9815b4b33 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -1,6 +1,6 @@ -import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; -const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; +import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl"; export interface HostedPairingRequest { readonly host: string; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..b524fe018e9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' +import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' @@ -20,6 +21,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -33,6 +35,11 @@ const PairRoute = PairRouteImport.update({ path: '/pair', getParentRoute: () => rootRouteImport, } as any) +const ConnectRoute = ConnectRouteImport.update({ + id: '/connect', + path: '/connect', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/_chat', getParentRoute: () => rootRouteImport, @@ -77,6 +84,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ + id: '/connect_/callback', + path: '/connect/callback', + getParentRoute: () => rootRouteImport, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -91,8 +103,10 @@ const ChatEnvironmentIdThreadIdRoute = export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -104,8 +118,10 @@ export interface FileRoutesByFullPath { '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -120,8 +136,10 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren + '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -137,8 +155,10 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/connect' | '/pair' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -150,8 +170,10 @@ export interface FileRouteTypes { | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: + | '/connect' | '/pair' | '/settings' + | '/connect/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -165,8 +187,10 @@ export interface FileRouteTypes { id: | '__root__' | '/_chat' + | '/connect' | '/pair' | '/settings' + | '/connect_/callback' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -181,8 +205,10 @@ export interface FileRouteTypes { } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { @@ -201,6 +227,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PairRouteImport parentRoute: typeof rootRouteImport } + '/connect': { + id: '/connect' + path: '/connect' + fullPath: '/connect' + preLoaderRoute: typeof ConnectRouteImport + parentRoute: typeof rootRouteImport + } '/_chat': { id: '/_chat' path: '' @@ -264,6 +297,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/connect_/callback': { + id: '/connect_/callback' + path: '/connect/callback' + fullPath: '/connect/callback' + preLoaderRoute: typeof ConnectCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -321,8 +361,10 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + ConnectRoute: ConnectRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + ConnectCallbackRoute: ConnectCallbackRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36de3b95706..b7ae1a31977 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -96,7 +96,7 @@ function RootRouteView() { }; }, [pathname]); - if (pathname === "/pair") { + if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) { return ( <> diff --git a/apps/web/src/routes/connect.tsx b/apps/web/src/routes/connect.tsx new file mode 100644 index 00000000000..30750a977ea --- /dev/null +++ b/apps/web/src/routes/connect.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth"; +import { ConnectCliAuthorizeSurface } from "../components/cloud/ConnectCliAuthSurface"; + +export const Route = createFileRoute("/connect")({ + beforeLoad: () => { + if (!connectCliAuthRoutesEnabled()) { + throw redirect({ to: "/", replace: true }); + } + }, + component: ConnectCliAuthorizeSurface, +}); diff --git a/apps/web/src/routes/connect_.callback.tsx b/apps/web/src/routes/connect_.callback.tsx new file mode 100644 index 00000000000..a5beee0b9d3 --- /dev/null +++ b/apps/web/src/routes/connect_.callback.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth"; +import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface"; + +export const Route = createFileRoute("/connect_/callback")({ + beforeLoad: () => { + if (!connectCliAuthRoutesEnabled()) { + throw redirect({ to: "/", replace: true }); + } + }, + component: ConnectCliCallbackSurface, +}); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index d8a6d71b49a..ac1da93f5c8 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -9,6 +9,7 @@ interface ImportMetaEnv { readonly VITE_HOSTED_APP_CHANNEL: string; readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; + readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string; readonly VITE_RELAY_OTLP_TRACES_URL: string; readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e7bfa63204a..093709a7cc9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,6 +18,7 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; +const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || ""; const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; @@ -115,6 +116,9 @@ export default defineConfig(() => { "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), + "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify( + configuredClerkCliOAuthClientId, + ), "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl), "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify( configuredRelayTracingDataset, diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..a4cde40a15e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -131,6 +131,10 @@ "types": "./src/cliArgs.ts", "import": "./src/cliArgs.ts" }, + "./connectAuth": { + "types": "./src/connectAuth.ts", + "import": "./src/connectAuth.ts" + }, "./path": { "types": "./src/path.ts", "import": "./src/path.ts" diff --git a/packages/shared/src/connectAuth.test.ts b/packages/shared/src/connectAuth.test.ts new file mode 100644 index 00000000000..5f8b62e334d --- /dev/null +++ b/packages/shared/src/connectAuth.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildConnectAuthorizeRequestUrl, + buildConnectClerkAuthorizeUrl, + connectCallbackUrl, + encodeConnectAuthCode, + parseConnectAuthCode, + readConnectAuthorizeRequest, +} from "./connectAuth.ts"; + +describe("connectAuth", () => { + it("round-trips state and challenge through the authorize URL fragment", () => { + const url = buildConnectAuthorizeRequestUrl({ + hostedAppUrl: "https://app.t3.codes", + state: "0b5f9a52-83a7-4f6e-9d2a-1c4b8e7f6a3d", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }); + const parsed = new URL(url); + + expect(parsed.origin).toBe("https://app.t3.codes"); + expect(parsed.pathname).toBe("/connect"); + expect(parsed.search).toBe(""); + expect(readConnectAuthorizeRequest(parsed)).toEqual({ + state: "0b5f9a52-83a7-4f6e-9d2a-1c4b8e7f6a3d", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }); + }); + + it("rejects authorize requests missing state or challenge", () => { + expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull(); + expect( + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc")), + ).toBeNull(); + expect( + readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc")), + ).toBeNull(); + }); + + it("builds a PKCE authorize URL against the Clerk endpoint", () => { + const url = new URL( + buildConnectClerkAuthorizeUrl({ + authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize", + clientId: "oauthapp_123", + redirectUri: connectCallbackUrl("https://app.t3.codes"), + scopes: ["openid", "profile", "email"], + state: "state-1", + challenge: "challenge-1", + }), + ); + + expect(url.origin).toBe("https://clerk.t3.codes"); + expect(url.pathname).toBe("/oauth/authorize"); + expect(url.searchParams.get("client_id")).toBe("oauthapp_123"); + expect(url.searchParams.get("redirect_uri")).toBe("https://app.t3.codes/connect/callback"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("scope")).toBe("openid profile email"); + expect(url.searchParams.get("state")).toBe("state-1"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("round-trips the pasted code blob and preserves dots inside the code", () => { + const blob = encodeConnectAuthCode({ code: "az9.code.chunk", state: "state-uuid" }); + expect(parseConnectAuthCode(blob)).toEqual({ code: "az9.code.chunk", state: "state-uuid" }); + expect(parseConnectAuthCode(` ${blob}\n`)).toEqual({ + code: "az9.code.chunk", + state: "state-uuid", + }); + }); + + it("rejects malformed pasted blobs", () => { + expect(parseConnectAuthCode("")).toBeNull(); + expect(parseConnectAuthCode("no-separator")).toBeNull(); + expect(parseConnectAuthCode(".leading")).toBeNull(); + expect(parseConnectAuthCode("trailing.")).toBeNull(); + }); +}); diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts new file mode 100644 index 00000000000..950a69f6861 --- /dev/null +++ b/packages/shared/src/connectAuth.ts @@ -0,0 +1,124 @@ +import { readHashParams } from "./remote.ts"; + +const CONNECT_AUTH_STATE_PARAM = "state"; +const CONNECT_AUTH_CHALLENGE_PARAM = "challenge"; +const CONNECT_AUTH_CODE_SEPARATOR = "."; + +const CONNECT_AUTHORIZE_PATH = "/connect"; +const CONNECT_CALLBACK_PATH = "/connect/callback"; + +/** + * The CLI prints URLs against this origin and the web bundle uses it to + * decide whether it is the hosted deployment — the two must agree, so the + * default lives here. + */ +export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; + +/** + * Requested at authorize time by the hosted page and honored by the CLI's + * token exchange; keep both sides on this single definition. + */ +export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const; + +export interface ConnectAuthorizeRequest { + readonly state: string; + readonly challenge: string; +} + +/** + * The URL a headless CLI prints for the user to open on a machine with a + * browser. `state` and `code_challenge` ride the fragment so they never reach + * the hosted app's server or CDN logs; neither is a secret. + */ +export function buildConnectAuthorizeRequestUrl(input: { + readonly hostedAppUrl: string; + readonly state: string; + readonly challenge: string; +}): string { + const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl); + url.hash = new URLSearchParams([ + [CONNECT_AUTH_STATE_PARAM, input.state], + [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge], + ]).toString(); + return url.toString(); +} + +export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | null { + const params = readHashParams(url); + const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? ""; + const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? ""; + if (!state || !challenge) { + return null; + } + return { state, challenge }; +} + +export function connectCallbackUrl(hostedAppUrl: string): string { + return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString(); +} + +export function buildConnectClerkAuthorizeUrl(input: { + readonly authorizationEndpoint: string; + readonly clientId: string; + readonly redirectUri: string; + readonly scopes: ReadonlyArray; + readonly state: string; + readonly challenge: string; +}): string { + const url = new URL(input.authorizationEndpoint); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", input.scopes.join(" ")); + url.searchParams.set("state", input.state); + url.searchParams.set("code_challenge", input.challenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +export interface ConnectAuthCode { + readonly code: string; + readonly state: string; +} + +/** + * The single blob the hosted callback page displays and the CLI accepts. + * Bundling `state` with the authorization code lets the CLI keep the loopback + * flow's CSRF check without any backend: it verifies the returned state + * matches the one it generated. Clerk authorization codes and the CLI's + * UUID states never contain ".". + */ +export function encodeConnectAuthCode(input: ConnectAuthCode): string { + return `${input.code}${CONNECT_AUTH_CODE_SEPARATOR}${input.state}`; +} + +/** + * Validates a pasted blob against the state of the request this process + * generated. Returns the parsed code or a user-facing error message; both + * the prompt's live validation and the authoritative post-prompt check go + * through here so they cannot drift. + */ +export function checkConnectAuthCode( + blob: string, + expectedState: string, +): ConnectAuthCode | string { + const parsed = parseConnectAuthCode(blob); + if (parsed === null) { + return "That does not look like a T3 Connect code. Copy the full code."; + } + if (parsed.state !== expectedState) { + return "That code belongs to a different connect request. Open the URL above and try again."; + } + return parsed; +} + +export function parseConnectAuthCode(blob: string): ConnectAuthCode | null { + const trimmed = blob.trim(); + const separatorIndex = trimmed.lastIndexOf(CONNECT_AUTH_CODE_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) { + return null; + } + const code = trimmed.slice(0, separatorIndex); + const state = trimmed.slice(separatorIndex + 1); + return { code, state }; +} diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 7347dbc74a1..f722a3945e5 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -5,7 +5,7 @@ const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); -const readHashParams = (url: URL): URLSearchParams => +export const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index 9b988df9c5b..36ca5497f80 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -55,6 +55,7 @@ export function loadRepoEnv({ ...(config.clerkCliOAuthClientId ? { T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, + VITE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, } : {}), ...(config.relayUrl @@ -116,7 +117,11 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP "VITE_CLERK_JWT_TEMPLATE", "EXPO_PUBLIC_CLERK_JWT_TEMPLATE", ), - clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), + clerkCliOAuthClientId: firstNonEmpty( + sources, + "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", + "VITE_CLERK_CLI_OAUTH_CLIENT_ID", + ), relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), mobileOtlpTracesUrl: firstNonEmpty( sources,