diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index de3799ac8a8..eb01da878f8 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -2,8 +2,13 @@ import { CameraView, useCameraPermissions } from "expo-camera"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, ScrollView, View } from "react-native"; +import { + MATRIX_OS_CONNECT_URL, + MATRIX_OS_SETUP_DESCRIPTION, + MATRIX_OS_SETUP_MOBILE_ACTION_LABEL, +} from "@t3tools/shared/matrixOsConnect"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -14,6 +19,7 @@ import { ConnectionSheetButton } from "./ConnectionSheetButton"; import { extractPairingUrlFromQrPayload } from "./pairing"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { buildPairingUrl, parsePairingUrl } from "./pairing"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; type ConnectionsNewRouteParams = { readonly mode?: string; @@ -34,9 +40,11 @@ export function ConnectionsNewRouteScreen({ const [hostInput, setHostInput] = useState(""); const [codeInput, setCodeInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [isOpeningMatrixOs, setIsOpeningMatrixOs] = useState(false); const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); const [cameraPermission, requestCameraPermission] = useCameraPermissions(); const [scannerLocked, setScannerLocked] = useState(false); + const matrixOsLaunchInFlightRef = useRef(false); const headerIconColor = useThemeColor("--color-icon"); @@ -133,6 +141,27 @@ export function ConnectionsNewRouteScreen({ } }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, navigation]); + const handleConnectMatrixOs = useCallback(async () => { + if (matrixOsLaunchInFlightRef.current) { + return; + } + + matrixOsLaunchInFlightRef.current = true; + setIsOpeningMatrixOs(true); + try { + const opened = await tryOpenExternalUrl(MATRIX_OS_CONNECT_URL, "matrix-os-connect"); + if (!opened) { + Alert.alert( + "Unable to open Matrix OS", + "Open app.matrix-os.com and connect T3 Code from its Terminal.", + ); + } + } finally { + matrixOsLaunchInFlightRef.current = false; + setIsOpeningMatrixOs(false); + } + }, []); + return ( )} + {!showScanner ? ( + + + Matrix OS + + {MATRIX_OS_SETUP_DESCRIPTION} + + + { + void handleConnectMatrixOs(); + }} + /> + + ) : null} diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts index 5a69cbdd43b..018db2fa0b3 100644 --- a/apps/mobile/src/lib/openExternalUrl.test.ts +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -2,6 +2,7 @@ import { Linking } from "react-native"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { tryOpenExternalUrl } from "./openExternalUrl"; +import { MATRIX_OS_CONNECT_URL } from "@t3tools/shared/matrixOsConnect"; vi.mock("react-native", () => ({ Linking: { openURL: vi.fn() }, @@ -56,3 +57,14 @@ describe("tryOpenExternalUrl", () => { expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel"); }); }); + +describe("Matrix OS handoff", () => { + it("opens the fixed Matrix OS setup URL", async () => { + openURL.mockResolvedValueOnce(undefined); + + await expect(tryOpenExternalUrl(MATRIX_OS_CONNECT_URL, "matrix-os-connect")).resolves.toBe( + true, + ); + expect(openURL).toHaveBeenCalledExactlyOnceWith(MATRIX_OS_CONNECT_URL); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index 10e6378bc00..0ceafab9191 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -1,7 +1,12 @@ import * as Schema from "effect/Schema"; import { Linking } from "react-native"; -const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); +const ExternalUrlTarget = Schema.Literals([ + "file-preview", + "markdown-link", + "pull-request", + "matrix-os-connect", +]); export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index eb056342140..950cfc4fc87 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -28,12 +28,13 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as ServerConfig from "../config.ts"; +import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; -import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; @@ -555,6 +556,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | } export const make = Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; @@ -613,6 +615,7 @@ export const make = Effect.gen(function* () { request, expectedThumbprint: session.proofKeyThumbprint, expectedAccessToken: dpopToken, + ...(serverConfig.pairingBaseUrl ? { pairingBaseUrl: serverConfig.pairingBaseUrl } : {}), }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), Effect.provideService(Crypto.Crypto, crypto), diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index fa75c407b0c..2f06f7a1ce9 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; import { SecretStorePersistError } from "./ServerSecretStore.ts"; -import { mapDpopReplayStoreError } from "./dpop.ts"; +import { mapDpopReplayStoreError, resolveDpopRequestUrl } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ @@ -35,3 +35,15 @@ describe("mapDpopReplayStoreError", () => { } }); }); + +describe("resolveDpopRequestUrl", () => { + it("uses the configured reverse-proxy base instead of forwarded headers", () => { + expect( + resolveDpopRequestUrl({ + localUrl: new URL("http://127.0.0.1:3773/oauth/token?code=one-time"), + originalUrl: "/oauth/token?code=one-time", + pairingBaseUrl: new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/"), + }), + ).toBe("https://app.matrix-os.com/vm/alice/api/integrations/t3/oauth/token?code=one-time"); + }); +}); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index f19984eb369..94a60642956 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -26,10 +26,20 @@ export const mapDpopReplayStoreError = ( cause: error, }); +export function resolveDpopRequestUrl(input: { + readonly localUrl: URL; + readonly originalUrl: string; + readonly pairingBaseUrl?: URL; +}): string { + if (!input.pairingBaseUrl) return input.localUrl.href; + return new URL(input.originalUrl.replace(/^\/+/, ""), input.pairingBaseUrl).href; +} + export const verifyRequestDpopProof = (input: { readonly request: HttpServerRequest.HttpServerRequest; readonly expectedThumbprint?: string; readonly expectedAccessToken?: string; + readonly pairingBaseUrl?: URL; }) => Effect.gen(function* () { const proof = input.request.headers.dpop; @@ -39,11 +49,16 @@ export const verifyRequestDpopProof = (input: { diagnostic: "Invalid DPoP request URL.", }); } + const requestUrl = resolveDpopRequestUrl({ + localUrl: url.value, + originalUrl: input.request.originalUrl, + ...(input.pairingBaseUrl ? { pairingBaseUrl: input.pairingBaseUrl } : {}), + }); const now = yield* DateTime.now; const result = verifyDpopProof({ proof, method: input.request.method, - url: url.value.href, + url: requestUrl, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde25..0977b9a3aa7 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -37,6 +37,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; +import * as ServerConfig from "../config.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -203,6 +204,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const serverConfig = yield* ServerConfig.ServerConfig; return handlers .handle( @@ -277,7 +279,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( return yield* failEnvironmentInvalidRequest("invalid_scope"); } const proofKeyThumbprint = args.headers.dpop - ? yield* verifyRequestDpopProof({ request }).pipe( + ? yield* verifyRequestDpopProof({ + request, + ...(serverConfig.pairingBaseUrl + ? { pairingBaseUrl: serverConfig.pairingBaseUrl } + : {}), + }).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => appendDpopChallengeHeader.pipe( Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index cc1821dd10d..950d7b7d492 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -16,7 +16,9 @@ import { import * as NetService from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; -import { resolveServerConfig } from "./config.ts"; +import { PairingBaseUrl, resolveServerConfig } from "./config.ts"; + +const decodePairingBaseUrl = Schema.decodeUnknownSync(PairingBaseUrl); const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); @@ -155,6 +157,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.some(true), tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.some(8443), + pairingBaseUrl: Option.some( + new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3"), + ), }, Option.some("Debug"), ).pipe( @@ -194,15 +199,26 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, tailscaleServeEnabled: true, tailscaleServePort: 8443, + pairingBaseUrl: new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/"), }); assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite")); }), ); + it("accepts only HTTP(S) pairing base URLs", () => { + expect(decodePairingBaseUrl("https://example.com/proxy")).toEqual( + new URL("https://example.com/proxy"), + ); + expect(() => decodePairingBaseUrl("ftp://example.com/proxy")).toThrow(/HTTP\(S\)/); + }); + it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () => Effect.gen(function* () { const { join } = yield* Path.Path; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 6814d251a37..3888e29941a 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -74,6 +74,29 @@ export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +export const PairingBaseUrl = Schema.URLFromString.pipe( + Schema.refine((url): url is URL => url.protocol === "http:" || url.protocol === "https:", { + message: "Expected an HTTP(S) URL", + }), +); + +export const normalizePairingBaseUrl = (value: URL): URL => { + const normalized = new URL(value); + if (!normalized.pathname.endsWith("/")) { + normalized.pathname = `${normalized.pathname}/`; + } + normalized.search = ""; + normalized.hash = ""; + return normalized; +}; + +export const pairingBaseUrlFlag = Flag.string("pairing-base-url").pipe( + Flag.withSchema(PairingBaseUrl), + Flag.withDescription( + "Public HTTP(S) base URL advertised in the headless pairing link when a trusted reverse proxy fronts this server.", + ), + Flag.optional, +); const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), @@ -139,6 +162,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + pairingBaseUrl: Config.schema(PairingBaseUrl, "T3CODE_PAIRING_BASE_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); export interface CliServerFlags { @@ -154,6 +181,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly pairingBaseUrl?: Option.Option; } export interface CliAuthLocationFlags { @@ -188,6 +216,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + pairingBaseUrl: pairingBaseUrlFlag, } as const; export const authLocationFlags = sharedServerLocationFlags; @@ -233,6 +262,7 @@ export const resolveServerConfig = ( logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + pairingBaseUrl: flags.pairingBaseUrl ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -338,6 +368,14 @@ export const resolveServerConfig = ( ), () => 443, ); + const pairingBaseUrlValue = Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.pairingBaseUrl, + Option.fromUndefinedOr(env.pairingBaseUrl), + ), + ); + const pairingBaseUrl = + pairingBaseUrlValue === undefined ? undefined : normalizePairingBaseUrl(pairingBaseUrlValue); const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -386,6 +424,7 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + ...(pairingBaseUrl ? { pairingBaseUrl } : {}), }; return config; @@ -409,6 +448,7 @@ export const resolveCliAuthConfig = ( logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + pairingBaseUrl: Option.none(), }, cliLogLevel, ); diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index c4f321a5a61..b39110240f9 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -173,6 +173,112 @@ describe("t3 pair", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("rejects a pairing URL that differs from the running server", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-pair-proxy-mismatch-test-"), + ); + const port = Number(new URL(origin).port); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { + host: "127.0.0.1", + devUrl: undefined, + pairingBaseUrl: new URL("https://example.com/expected/"), + }, + port, + }), + }); + + const error = yield* provideCliTestLayers( + runCli([ + "pair", + "--base-dir", + baseDir, + "--pairing-base-url", + "https://example.com/different/", + ]).pipe(Effect.flip), + ); + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "does not match the running server"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("advertises a trusted reverse-proxy base URL", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-proxy-test-")); + const port = Number(new URL(origin).port); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { + host: "127.0.0.1", + devUrl: undefined, + pairingBaseUrl: new URL("https://example.com/vm/alice/api/integrations/t3/"), + }, + port, + }), + }); + + const output = yield* captureStdout( + runCli([ + "pair", + "--base-dir", + baseDir, + "--pairing-base-url", + "https://example.com/vm/alice/api/integrations/t3/", + ]), + ); + + assert.include( + output, + "Pairing URL: https://app.t3.codes/pair?host=https%3A%2F%2Fexample.com%2Fvm%2Falice%2Fapi%2Fintegrations%2Ft3%2F#token=", + ); + assert.notInclude(output, "only reachable from this machine"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects Tailscale pairing for a server configured behind another proxy", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-pair-proxy-tailscale-test-"), + ); + const port = Number(new URL(origin).port); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { + host: "127.0.0.1", + devUrl: undefined, + pairingBaseUrl: new URL("https://example.com/proxy/"), + }, + port, + }), + }); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(Effect.flip), + ); + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "cannot use --tailscale"); + assert.include(rendered, "Restart the server without --pairing-base-url"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("pairs through the recorded dev web URL for dev servers", () => withDescriptorServer((origin) => Effect.gen(function* () { diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 38fa3be8bb5..8860276711c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -15,6 +15,7 @@ import { PortSchema, } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, @@ -53,7 +54,12 @@ import { renderTerminalQrCode, resolveHeadlessConnectionString, } from "../startupAccess.ts"; -import { baseDirFlag, DurationFromString } from "./config.ts"; +import { + baseDirFlag, + DurationFromString, + normalizePairingBaseUrl, + pairingBaseUrlFlag, +} from "./config.ts"; const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; const PAIR_PROBE_TIMEOUT = Duration.millis(2_500); @@ -83,6 +89,28 @@ export class NoRunningServerError extends Schema.TaggedErrorClass()( + "PairingBaseUrlMismatchError", + { + requestedBaseUrl: Schema.String, + runningBaseUrl: Schema.optional(Schema.String), + }, +) { + override get message(): string { + const running = this.runningBaseUrl ?? "no public pairing URL"; + return `The requested pairing base URL ${this.requestedBaseUrl} does not match the running server (${running}). Restart the server with the same --pairing-base-url before pairing.`; + } +} + +export class TailscalePairingConflictError extends Schema.TaggedErrorClass()( + "TailscalePairingConflictError", + { runningBaseUrl: Schema.String }, +) { + override get message(): string { + return `This server cannot use --tailscale while configured behind ${this.runningBaseUrl}. Restart the server without --pairing-base-url before pairing through Tailscale.`; + } +} + // Each tailscale failure gets its own class (same reasoning as // scripts/lib/dev-share.ts): distinct caller-visible message, distinct remedy. export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( @@ -316,6 +344,10 @@ const makePairServerConfig = Effect.fn(function* (input: { // an explicit home and therefore lands in `userdata`. The recorded devUrl is // what actually marks a dev server. const devUrl = state.devUrl !== undefined ? new URL(state.devUrl) : undefined; + const pairingBaseUrl = + state.pairingBaseUrl !== undefined + ? normalizePairingBaseUrl(new URL(state.pairingBaseUrl)) + : undefined; const derivedPaths = yield* ServerConfig.deriveServerPaths( baseDir, variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined, @@ -351,6 +383,7 @@ const makePairServerConfig = Effect.fn(function* (input: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + ...(pairingBaseUrl ? { pairingBaseUrl } : {}), }); }); @@ -485,6 +518,7 @@ export const pairCommand = Command.make("pair", { baseDir: baseDirFlag, ttl: ttlFlag, label: labelFlag, + pairingBaseUrl: pairingBaseUrlFlag, tailscale: tailscaleFlag, tailscaleServePort: tailscaleServePortFlag, }).pipe( @@ -502,13 +536,40 @@ export const pairCommand = Command.make("pair", { const notes: Array = []; let pairingBaseUrl: string; - if (flags.tailscale) { + let useHostedApp = false; + const explicitPairingBaseUrlValue = Option.getOrUndefined(flags.pairingBaseUrl); + const explicitPairingBaseUrl = + explicitPairingBaseUrlValue === undefined + ? undefined + : normalizePairingBaseUrl(explicitPairingBaseUrlValue).toString(); + const runningPairingBaseUrl = + target.state.pairingBaseUrl === undefined + ? undefined + : normalizePairingBaseUrl(new URL(target.state.pairingBaseUrl)).toString(); + if (explicitPairingBaseUrl !== undefined) { + if (explicitPairingBaseUrl !== runningPairingBaseUrl) { + return yield* new PairingBaseUrlMismatchError({ + requestedBaseUrl: explicitPairingBaseUrl, + ...(runningPairingBaseUrl ? { runningBaseUrl: runningPairingBaseUrl } : {}), + }); + } + pairingBaseUrl = explicitPairingBaseUrl; + useHostedApp = true; + } else if (flags.tailscale) { + if (runningPairingBaseUrl !== undefined) { + return yield* new TailscalePairingConflictError({ + runningBaseUrl: runningPairingBaseUrl, + }); + } const resolved = yield* resolveTailscalePairingBase({ target, servePort: flags.tailscaleServePort, }); pairingBaseUrl = resolved.baseUrl; notes.push(...resolved.notes); + } else if (runningPairingBaseUrl !== undefined) { + pairingBaseUrl = runningPairingBaseUrl; + useHostedApp = true; } else { pairingBaseUrl = resolveDirectPairingBaseUrl(target.state); if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) { @@ -525,7 +586,11 @@ export const pairCommand = Command.make("pair", { const config = yield* makePairServerConfig({ target, logLevel }); const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label }); - const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); + const pairingUrl = buildPairingUrl( + pairingBaseUrl, + issued.credential, + useHostedApp ? DEFAULT_HOSTED_APP_URL : undefined, + ); yield* Console.log( formatPairOutput({ diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e678264dde5..56b5cade70c 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -83,6 +83,7 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly pairingBaseUrl?: URL | undefined; } >()("t3/config/ServerConfig") { /** @deprecated Import and use `layerTest` from this module. */ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 74d3fd2d594..e4838e64c52 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1689,6 +1689,47 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("validates token exchange DPoP against the configured reverse-proxy URL", () => + Effect.gen(function* () { + const pairingBaseUrl = new URL("https://app.matrix-os.com/vm/alice/api/integrations/t3/"); + yield* buildAppUnderTest({ config: { pairingBaseUrl } }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const localTokenUrl = yield* getHttpServerUrl("/oauth/token"); + const now = yield* DateTime.now; + const tokenProof = makeDpopProof({ + method: "POST", + url: new URL("oauth/token", pairingBaseUrl).href, + iat: Math.floor(now.epochMilliseconds / 1_000), + jti: "reverse-proxy-token-exchange-proof", + }); + + const tokenResponse = yield* fetchEffect(localTokenUrl, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + dpop: tokenProof.proof, + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: credential.credential, + subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap", + requested_token_type: "urn:ietf:params:oauth:token-type:access_token", + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }).toString(), + }); + + assert.equal(tokenResponse.status, 200); + const token = yield* responseJsonEffect<{ readonly token_type: string }>(tokenResponse); + assert.equal(token.token_type, "DPoP"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects replayed DPoP proofs across token exchanges", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 4c2375b29a7..ca038ce19b8 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -34,6 +34,7 @@ describe("serverRuntimeState", () => { port: 4_971, origin: "http://127.0.0.1:4971", devUrl: "http://localhost:5733/", + pairingBaseUrl: "https://example.com/proxy/", startedAt: "2026-06-20T00:00:00.000Z", }; @@ -47,18 +48,24 @@ describe("serverRuntimeState", () => { it.effect("records the dev web URL when the server fronts a dev server", () => Effect.gen(function* () { const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: new URL("http://localhost:5733") }, + config: { + host: undefined, + devUrl: new URL("http://localhost:5733"), + pairingBaseUrl: new URL("https://example.com/proxy/"), + }, port: 13_773, }); assert.equal(state.devUrl, "http://localhost:5733/"); + assert.equal(state.pairingBaseUrl, "https://example.com/proxy/"); assert.equal(state.origin, "http://127.0.0.1:13773"); const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, + config: { host: undefined, devUrl: undefined, pairingBaseUrl: undefined }, port: 13_773, }); assert.isFalse("devUrl" in withoutDev); + assert.isFalse("pairingBaseUrl" in withoutDev); }), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index b32f3814547..5827fd6feec 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -17,6 +17,8 @@ export const PersistedServerRuntimeState = Schema.Struct({ // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). // Dev is single-origin: browsers must pair through this URL, not `origin`. devUrl: Schema.optional(Schema.String), + // Public reverse-proxy base used by the running server for DPoP URL binding. + pairingBaseUrl: Schema.optional(Schema.String), startedAt: Schema.String, }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -48,7 +50,8 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick & + Partial>; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ @@ -58,6 +61,9 @@ export const makePersistedServerRuntimeState = (input: { port: input.port, origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), + ...(input.config.pairingBaseUrl + ? { pairingBaseUrl: input.config.pairingBaseUrl.toString() } + : {}), startedAt: DateTime.formatIso(now), })); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f15..a80c284f061 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -58,6 +58,18 @@ it("builds a pairing URL that embeds the token in the hash", () => { ); }); +it("builds a hosted pairing URL from an advertised reverse-proxy base", () => { + expect( + buildPairingUrl( + "https://app.matrix-os.com/vm/alice/api/integrations/t3/", + "PAIRCODE", + "https://app.t3.codes", + ), + ).toBe( + "https://app.t3.codes/pair?host=https%3A%2F%2Fapp.matrix-os.com%2Fvm%2Falice%2Fapi%2Fintegrations%2Ft3%2F#token=PAIRCODE", + ); +}); + it("renders terminal QR codes as a multi-line unicode block grid", () => { const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7df131669ba..5902746266b 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,5 +1,6 @@ import * as NodeOS from "node:os"; +import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth"; import { QrCode } from "@t3tools/shared/qrCode"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -89,9 +90,20 @@ export const resolveListeningPort = (address: unknown, fallbackPort: number): nu return fallbackPort; }; -export const buildPairingUrl = (connectionString: string, token: string): string => { +export const buildPairingUrl = ( + connectionString: string, + token: string, + hostedAppUrl?: string, +): string => { + if (hostedAppUrl) { + const url = new URL("/pair", hostedAppUrl); + url.searchParams.set("host", connectionString); + url.hash = new URLSearchParams([["token", token]]).toString(); + return url.toString(); + } const url = new URL(connectionString); - url.pathname = "/pair"; + const basePath = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`; + url.pathname = `${basePath}pair`; url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", token]]).toString(); return url.toString(); @@ -138,11 +150,16 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), ); + const pairingBaseUrl = serverConfig.pairingBaseUrl?.toString() ?? connectionString; const issued = yield* serverAuth.issueStartupPairingCredential(); return { connectionString, token: issued.credential, - pairingUrl: buildPairingUrl(connectionString, issued.credential), + pairingUrl: buildPairingUrl( + pairingBaseUrl, + issued.credential, + serverConfig.pairingBaseUrl ? DEFAULT_HOSTED_APP_URL : undefined, + ), } satisfies HeadlessServeAccessInfo; }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 640412e4a6d..bd7887302d6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -43,6 +43,7 @@ import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { MatrixOsConnectRow } from "./MatrixOsConnectRow"; import { SettingsPageContainer, SettingsRow, @@ -3415,6 +3416,7 @@ export function ConnectionsSettings() { } > + {savedEnvironments.map((environment) => ( { + const api = readLocalApi(); + if (!api) { + toastManager.add({ type: "error", title: "Link opening is unavailable." }); + return; + } + + setIsOpening(true); + try { + await openMatrixOsConnect(api.shell); + } catch (error) { + console.error("Failed to open the Matrix OS setup link.", error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open Matrix OS", + description: "Open app.matrix-os.com and connect T3 Code from its Terminal.", + }), + ); + } finally { + setIsOpening(false); + } + }; + + return ( + void handleConnect()} + > + + {isOpening ? "Opening…" : MATRIX_OS_SETUP_ACTION_LABEL} + + } + /> + ); +} diff --git a/apps/web/src/components/settings/openMatrixOsConnect.test.ts b/apps/web/src/components/settings/openMatrixOsConnect.test.ts new file mode 100644 index 00000000000..73b82854799 --- /dev/null +++ b/apps/web/src/components/settings/openMatrixOsConnect.test.ts @@ -0,0 +1,14 @@ +import { MATRIX_OS_CONNECT_URL } from "@t3tools/shared/matrixOsConnect"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openMatrixOsConnect } from "./openMatrixOsConnect"; + +describe("openMatrixOsConnect", () => { + it("opens the fixed Matrix OS handoff in the external browser", async () => { + const openExternal = vi.fn(async () => undefined); + + await openMatrixOsConnect({ openExternal }); + + expect(openExternal).toHaveBeenCalledExactlyOnceWith(MATRIX_OS_CONNECT_URL); + }); +}); diff --git a/apps/web/src/components/settings/openMatrixOsConnect.ts b/apps/web/src/components/settings/openMatrixOsConnect.ts new file mode 100644 index 00000000000..622c0e3ae3e --- /dev/null +++ b/apps/web/src/components/settings/openMatrixOsConnect.ts @@ -0,0 +1,8 @@ +import type { LocalApi } from "@t3tools/contracts"; +import { MATRIX_OS_CONNECT_URL } from "@t3tools/shared/matrixOsConnect"; + +export async function openMatrixOsConnect( + shell: Pick, +): Promise { + await shell.openExternal(MATRIX_OS_CONNECT_URL); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 260256c1250..5e68b91ed66 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -77,6 +77,44 @@ describe("LocalApi", () => { expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); + it("rejects external links when the browser blocks the popup", async () => { + Object.defineProperty(testWindow(), "open", { + configurable: true, + value: vi.fn(() => null), + }); + const { createLocalApi } = await import("./localApi"); + + await expect( + createLocalApi().shell.openExternal("https://app.matrix-os.com/?launch=__terminal__"), + ).rejects.toThrow("Unable to open link."); + }); + + it("opens browser external links without exposing the opener", async () => { + const targetUrl = "https://app.matrix-os.com/?launch=__terminal__"; + const click = vi.fn(); + const anchor = { href: "", rel: "", click }; + const createElement = vi.fn(() => anchor); + const popup = { + opener: testWindow(), + document: { createElement }, + }; + const open = vi.fn(() => popup); + Object.defineProperty(testWindow(), "open", { + configurable: true, + value: open, + }); + const { createLocalApi } = await import("./localApi"); + + await expect(createLocalApi().shell.openExternal(targetUrl)).resolves.toBeUndefined(); + + expect(open).toHaveBeenCalledExactlyOnceWith("about:blank", "_blank"); + expect(popup.opener).toBeNull(); + expect(createElement).toHaveBeenCalledExactlyOnceWith("a"); + expect(anchor.href).toBe(targetUrl); + expect(anchor.rel).toBe("noopener noreferrer"); + expect(click).toHaveBeenCalledOnce(); + }); + it("delegates host capabilities and persistence to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index b42702c7a4a..e0771944a03 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -30,7 +30,19 @@ function createBrowserLocalApi(): LocalApi { return; } - window.open(url, "_blank", "noopener,noreferrer"); + // Browsers may return null for successful `noopener` launches, which is + // indistinguishable from a blocked popup. Open a blank tab first so we + // can detect blocking, sever its opener synchronously, then navigate + // through a noreferrer link created inside the new tab. + const opened = window.open("about:blank", "_blank"); + if (!opened) { + throw new Error("Unable to open link."); + } + opened.opener = null; + const link = opened.document.createElement("a"); + link.href = url; + link.rel = "noopener noreferrer"; + link.click(); }, }, contextMenu: { diff --git a/docs/internals/remote.md b/docs/internals/remote.md index afce95f725b..7464f5336c0 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -140,6 +140,16 @@ how the server got started or who manages the process. It works for desktop, mobile, and web with no client-side process management. Browser security rules are part of it: a hosted HTTPS client cannot connect to plain `ws://` or `http://` LAN backends. +A trusted reverse proxy may publish the server below a URL path prefix, for example +`https://host.example/vm/alice/t3/`. Pairing preserves that prefix and resolves the environment +descriptor, OAuth exchange, API calls, assets, and WebSocket endpoint beneath it. Start a headless +server with `--pairing-base-url` to advertise that public base while still binding the server to a +private or loopback interface. `t3 pair --pairing-base-url ...` mints another one-time link for an +already-running server. Those links open the hosted T3 client with the public backend base encoded +as the target, so the proxy does not need to publish T3's static web assets or `/pair` page. The +reverse proxy is responsible for forwarding the complete T3 protocol namespace and preserving T3 +authorization headers and WebSocket tickets. + ### Relay-tunneled access Managed T3 Connect relay tunnels use `RelayConnectionTarget` and are the answer when the host is @@ -189,6 +199,9 @@ it separate from access. - **Client-managed local publish.** A local server is published through the relay with `t3 connect link`, exposing a desktop-hosted environment to mobile without router or firewall changes. +- **Trusted reverse-proxy publish.** The operator starts T3 on a private interface and advertises a + public, possibly path-prefixed base URL with `--pairing-base-url`; the proxy owns reachability and + T3 continues to own pairing and session authentication. The same `ExecutionEnvironment` can be reached several of these ways. Only the launch and access paths differ. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 05418022d23..b8717db6fcf 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -84,6 +84,16 @@ For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A p Use this when you want to run the server without a GUI, for example on a remote machine over SSH. +If your remote machine runs Matrix OS, choose **Settings** → **Connections** → **Matrix OS** in +the desktop or web client, or **Add Environment** → **Open Matrix OS setup** on mobile. T3 Code +opens the Matrix OS Terminal with a pinned T3 CLI setup ready to run. Review and confirm the +command. Matrix OS either prints a fresh one-time pairing link for the running T3 server or starts +the server and prints its pairing link and QR code. Scan the QR code on mobile, or paste the full +pairing URL into the desktop or web client. No T3 account is required: Matrix OS exposes the local +server through a scoped reverse proxy while T3 Code continues to authenticate the pairing link, +device session, and WebSocket connection. Keep the Matrix Terminal session running to keep the +environment reachable. + Run the server with `t3 serve`. ```bash @@ -106,6 +116,16 @@ From there, connect from another device in either of these ways: Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. +If a trusted reverse proxy exposes the server at a different or path-prefixed public URL, keep T3 +bound to the private interface and advertise the public base explicitly. The printed link opens the +hosted T3 client and points it at that backend, so the proxy only needs to expose the T3 protocol: + +```bash +npx t3 serve --host 127.0.0.1 --pairing-base-url https://example.com/path/to/t3/ +``` + +Use the same `--pairing-base-url` with `t3 pair` to print a fresh link for that running server. + For hosted web pairing over Tailscale HTTPS, opt in to Tailscale Serve: ```bash diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts index 6e6ccc86052..de98dfaeef7 100644 --- a/packages/client-runtime/src/authorization/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -126,6 +126,34 @@ describe("remote environment authorization", () => { }), ); + it.effect("appends auth and websocket endpoints beneath a reverse-proxy base path", () => + Effect.gen(function* () { + const fetch = recordedFetch( + Response.json({ + ticket: "ws-ticket", + expiresAt: "2026-05-01T12:05:00.000Z", + }), + ); + + const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ + httpBaseUrl: "https://app.matrix-os.com/vm/alice/api/integrations/t3/", + wsBaseUrl: "wss://app.matrix-os.com/vm/alice/api/integrations/t3/", + bearerToken: "bearer-token", + }).pipe(provideRemoteHttp(fetch.fetchFn)); + + expectFetchCall(fetch.calls, 1, { + url: "https://app.matrix-os.com/vm/alice/api/integrations/t3/api/auth/websocket-ticket", + method: "POST", + headers: { + authorization: "Bearer bearer-token", + }, + }); + expect(socketUrl).toBe( + "wss://app.matrix-os.com/vm/alice/api/integrations/t3/ws?wsTicket=ws-ticket", + ); + }), + ); + it.effect("exchanges managed credentials and admits websocket requests with DPoP", () => Effect.gen(function* () { const fetch = recordedFetch( diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 69c157d0e50..f631d92b957 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -7,7 +7,7 @@ import { } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Effect from "effect/Effect"; -import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { environmentEndpointUrl, environmentWebSocketUrl } from "../environment/endpoint.ts"; import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient, @@ -182,10 +182,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), }); - const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } + const url = environmentWebSocketUrl(input.wsBaseUrl); url.searchParams.set("wsTicket", issued.ticket); return url.toString(); }); @@ -205,10 +202,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( dpopProof: input.dpopProof, ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), }); - const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } + const url = environmentWebSocketUrl(input.wsBaseUrl); url.searchParams.set("wsTicket", issued.ticket); return url.toString(); }); diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 9bee0dad6fb..d4951610aeb 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -118,6 +118,27 @@ describe("connection onboarding", () => { }), ); + it.effect("pairs through a reverse-proxy base path", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const registration = yield* preparePairingRegistration({ + pairingUrl: + "https://app.matrix-os.com/vm/alice/api/integrations/t3/pair#token=pairing-token", + }).pipe(Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls)))); + + expect(calls.map((call) => new URL(call.url).pathname)).toEqual([ + "/vm/alice/api/integrations/t3/.well-known/t3/environment", + "/vm/alice/api/integrations/t3/oauth/token", + ]); + expect(registration.profile.httpBaseUrl).toBe( + "https://app.matrix-os.com/vm/alice/api/integrations/t3/", + ); + expect(registration.profile.wsBaseUrl).toBe( + "wss://app.matrix-os.com/vm/alice/api/integrations/t3/", + ); + }), + ); + it.effect("does not consume a pairing credential when descriptor discovery fails", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index c219bde092c..b044698e434 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -30,6 +30,7 @@ import type { RelayConnectionTarget, SshConnectionTarget, } from "./model.ts"; +import { environmentWebSocketUrl } from "../environment/endpoint.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; @@ -47,11 +48,7 @@ const isSshProfile = Schema.is(SshConnectionProfile); const isBearerCredential = Schema.is(BearerConnectionCredential); function primarySocketUrl(target: PrimaryConnectionTarget): string { - const url = new URL(target.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } - return url.toString(); + return environmentWebSocketUrl(target.wsBaseUrl).toString(); } const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { diff --git a/packages/client-runtime/src/environment/endpoint.test.ts b/packages/client-runtime/src/environment/endpoint.test.ts index d26201dc4f7..ae13508f26c 100644 --- a/packages/client-runtime/src/environment/endpoint.test.ts +++ b/packages/client-runtime/src/environment/endpoint.test.ts @@ -4,6 +4,8 @@ import { classifyHostedHttpsCompatibility, createAdvertisedEndpoint, deriveWsBaseUrl, + environmentEndpointUrl, + environmentWebSocketUrl, normalizeHttpBaseUrl, } from "./endpoint.ts"; @@ -22,6 +24,24 @@ describe("advertised endpoint helpers", () => { expect(deriveWsBaseUrl("http://127.0.0.1:3773")).toBe("ws://127.0.0.1:3773/"); }); + it("resolves environment endpoints beneath a reverse-proxy base path", () => { + expect( + environmentEndpointUrl( + "https://app.matrix-os.com/vm/alice/api/integrations/t3/", + "/api/auth/session", + ), + ).toBe("https://app.matrix-os.com/vm/alice/api/integrations/t3/api/auth/session"); + expect( + environmentWebSocketUrl( + "wss://app.matrix-os.com/vm/alice/api/integrations/t3/?stale=1#fragment", + ).toString(), + ).toBe("wss://app.matrix-os.com/vm/alice/api/integrations/t3/ws"); + expect(environmentWebSocketUrl("wss://example.com/ws").toString()).toBe("wss://example.com/ws"); + expect(environmentWebSocketUrl("wss://example.com/proxy/ws/").toString()).toBe( + "wss://example.com/proxy/ws", + ); + }); + it("marks HTTP endpoints as blocked from hosted HTTPS apps", () => { expect(classifyHostedHttpsCompatibility("http://192.168.1.44:3773")).toBe( "mixed-content-blocked", diff --git a/packages/client-runtime/src/environment/endpoint.ts b/packages/client-runtime/src/environment/endpoint.ts index 4178259361e..05d6924231b 100644 --- a/packages/client-runtime/src/environment/endpoint.ts +++ b/packages/client-runtime/src/environment/endpoint.ts @@ -2,8 +2,18 @@ export * from "@t3tools/shared/advertisedEndpoint"; export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => { const url = new URL(httpBaseUrl); - url.pathname = pathname; + const basePath = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`; + url.pathname = `${basePath}${pathname.replace(/^\/+/, "")}`; url.search = ""; url.hash = ""; return url.toString(); }; + +export const environmentWebSocketUrl = (wsBaseUrl: string): URL => { + const url = new URL(wsBaseUrl); + const basePath = url.pathname.replace(/\/+$/, ""); + url.pathname = basePath === "/ws" || basePath.endsWith("/ws") ? basePath : `${basePath}/ws`; + url.search = ""; + url.hash = ""; + return url; +}; diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index 87495918598..75e810c44f2 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -88,7 +88,9 @@ export const remoteHttpClientLayer = ( const remoteApiBaseUrl = (httpBaseUrl: string): string => { const url = new URL(httpBaseUrl); - url.pathname = "/"; + if (!url.pathname.endsWith("/")) { + url.pathname = `${url.pathname}/`; + } url.search = ""; url.hash = ""; return url.toString(); diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 58add31d6bb..61566a408e2 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -8,6 +8,7 @@ import { createAssetEnvironmentAtoms, InvalidAssetCollectionKeyError, parseAssetCollectionKey, + resolveAssetUrl, } from "./assets.ts"; describe("asset collection keys", () => { @@ -32,6 +33,17 @@ describe("asset collection keys", () => { }); }); +describe("asset urls", () => { + it("keeps server-relative assets beneath a reverse-proxy base path", () => { + expect( + resolveAssetUrl( + "https://app.matrix-os.com/vm/alice/api/integrations/t3/", + "/api/assets/asset-1", + ), + ).toBe("https://app.matrix-os.com/vm/alice/api/integrations/t3/api/assets/asset-1"); + }); +}); + describe("createAssetEnvironmentAtoms", () => { it("keys asset URL queries by environment and resource", () => { const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime< diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e407f5d0028..466569f898f 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; @@ -37,6 +38,9 @@ export function parseAssetCollectionKey( export function resolveAssetUrl(httpBaseUrl: string, relativeUrl: string): string | null { try { + if (relativeUrl.startsWith("/")) { + return environmentEndpointUrl(httpBaseUrl, relativeUrl); + } return new URL(relativeUrl, httpBaseUrl).toString(); } catch { return null; diff --git a/packages/shared/package.json b/packages/shared/package.json index 8cdae3e5160..0834c24be79 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -206,6 +206,10 @@ "./devProxy": { "types": "./src/devProxy.ts", "import": "./src/devProxy.ts" + }, + "./matrixOsConnect": { + "types": "./src/matrixOsConnect.ts", + "import": "./src/matrixOsConnect.ts" } }, "scripts": { diff --git a/packages/shared/src/matrixOsConnect.test.ts b/packages/shared/src/matrixOsConnect.test.ts new file mode 100644 index 00000000000..0e1826847ae --- /dev/null +++ b/packages/shared/src/matrixOsConnect.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MATRIX_OS_CONNECT_URL, + MATRIX_OS_SETUP_ACTION_LABEL, + MATRIX_OS_SETUP_DESCRIPTION, + MATRIX_OS_SETUP_MOBILE_ACTION_LABEL, +} from "./matrixOsConnect.js"; + +describe("MATRIX_OS_CONNECT_URL", () => { + it("targets the canonical Matrix OS Terminal with the fixed T3 action", () => { + const url = new URL(MATRIX_OS_CONNECT_URL); + + expect(url.origin).toBe("https://app.matrix-os.com"); + expect(url.pathname).toBe("/"); + expect(url.searchParams.get("launch")).toBe("__terminal__"); + expect(url.searchParams.get("terminal_action")).toBe("t3-connect"); + expect(new Set(url.searchParams.keys())).toEqual(new Set(["launch", "terminal_action"])); + }); +}); + +describe("Matrix OS setup copy", () => { + it("describes an onboarding action instead of claiming connection status", () => { + expect(MATRIX_OS_SETUP_ACTION_LABEL).toBe("Set up"); + expect(MATRIX_OS_SETUP_MOBILE_ACTION_LABEL).toBe("Open Matrix OS setup"); + expect(MATRIX_OS_SETUP_DESCRIPTION).toContain("one-time pairing link"); + expect(MATRIX_OS_SETUP_DESCRIPTION).toContain("No T3 account required"); + }); +}); diff --git a/packages/shared/src/matrixOsConnect.ts b/packages/shared/src/matrixOsConnect.ts new file mode 100644 index 00000000000..b438e87258b --- /dev/null +++ b/packages/shared/src/matrixOsConnect.ts @@ -0,0 +1,7 @@ +export const MATRIX_OS_CONNECT_URL = + "https://app.matrix-os.com/?launch=__terminal__&terminal_action=t3-connect"; + +export const MATRIX_OS_SETUP_ACTION_LABEL = "Set up"; +export const MATRIX_OS_SETUP_MOBILE_ACTION_LABEL = "Open Matrix OS setup"; +export const MATRIX_OS_SETUP_DESCRIPTION = + "Start T3 Code on a Matrix OS computer, then scan or paste its one-time pairing link here. No T3 account required."; diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 5b7a9973652..01d27df8276 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -21,6 +21,32 @@ describe("remote", () => { }); }); + it("preserves a reverse-proxy base path while removing the pairing page", () => { + expect( + resolveRemotePairingTarget({ + pairingUrl: + "https://app.matrix-os.com/vm/alice/api/integrations/t3/pair#token=pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://app.matrix-os.com/vm/alice/api/integrations/t3/", + wsBaseUrl: "wss://app.matrix-os.com/vm/alice/api/integrations/t3/", + }); + }); + + it("preserves a reverse-proxy base path from a hosted pairing request", () => { + expect( + resolveRemotePairingTarget({ + pairingUrl: + "https://app.t3.codes/pair?host=https%3A%2F%2Fapp.matrix-os.com%2Fvm%2Falice%2Fapi%2Fintegrations%2Ft3%2F#token=pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://app.matrix-os.com/vm/alice/api/integrations/t3/", + wsBaseUrl: "wss://app.matrix-os.com/vm/alice/api/integrations/t3/", + }); + }); + it("accepts pairing urls that still use a query token", () => { expect( resolveRemotePairingTarget({ diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 1b5d1c586e2..573efa8668e 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -72,6 +72,24 @@ export type RemotePairingTargetError = typeof RemotePairingTargetError.Type; const hasSupportedRemoteBackendProtocol = (url: URL): boolean => SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); +const normalizeRemoteBasePath = (url: URL): void => { + const pathname = url.pathname.replace(/\/+$/, ""); + url.pathname = pathname.length > 0 ? `${pathname}/` : "/"; +}; + +const removePairingPageFromPath = (url: URL): void => { + const pathname = url.pathname.replace(/\/+$/, ""); + if (pathname === "/pair") { + url.pathname = "/"; + return; + } + if (pathname.endsWith("/pair")) { + url.pathname = `${pathname.slice(0, -"/pair".length)}/`; + return; + } + normalizeRemoteBasePath(url); +}; + const normalizeRemoteBaseUrl = ( rawValue: string, source: RemoteBackendUrlInvalidError["source"], @@ -97,7 +115,7 @@ const normalizeRemoteBaseUrl = ( protocol: url.protocol, }); } - url.pathname = "/"; + normalizeRemoteBasePath(url); url.search = ""; url.hash = ""; return url; @@ -110,7 +128,7 @@ const toHttpBaseUrl = (url: URL): string => { } else if (next.protocol === "wss:") { next.protocol = "https:"; } - next.pathname = "/"; + normalizeRemoteBasePath(next); next.search = ""; next.hash = ""; return next.toString(); @@ -123,7 +141,7 @@ const toWsBaseUrl = (url: URL): string => { } else if (next.protocol === "https:") { next.protocol = "wss:"; } - next.pathname = "/"; + normalizeRemoteBasePath(next); next.search = ""; next.hash = ""; return next.toString(); @@ -220,6 +238,7 @@ export const resolveRemotePairingTarget = (input: { if (!credential) { throw new RemotePairingTokenMissingError({ host: url.host }); } + removePairingPageFromPath(url); return { credential, httpBaseUrl: toHttpBaseUrl(url),