Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e6a520e
feat(dev): simplify sharing through existing t3 connect environments
maria-rcks Sep 4, 2026
33da629
fix(dev): clarify connect setup and structured failures
maria-rcks Sep 4, 2026
091ec0d
fix(dev): release stale connect tunnels after private restarts
maria-rcks Sep 4, 2026
2056b82
feat(preview): forward remote browsers through existing connect tunnels
maria-rcks Sep 4, 2026
6656aa1
fix(preview): preserve forwarded URL credentials and origins
maria-rcks Sep 4, 2026
0b2bacf
fix(preview): keep forwarded targets on their requested loopback port
maria-rcks Sep 4, 2026
3868c86
fix(connect): address independent preview review findings
maria-rcks Sep 5, 2026
ac2703a
chore: resolve main conflicts in preview host classification
maria-rcks Sep 5, 2026
c535d05
chore: sync main and preserve connect remote access guidance
maria-rcks Sep 5, 2026
32363e8
fix(preview): preserve existing forwards when capacity is reached
maria-rcks Sep 5, 2026
d51d1e8
fix(preview): retry recovery and discard stale navigations
maria-rcks Sep 5, 2026
be5d6f4
fix(preview): reuse connect request credential renewal
maria-rcks Sep 5, 2026
cf6f760
fix(preview): cap slow recovery retries
maria-rcks Sep 5, 2026
8882519
test(connect): compose credential test dependencies
maria-rcks Sep 5, 2026
b9fb79f
fix: preserve PR 9817 behavior on current main
maria-rcks Oct 6, 2026
191e125
Merge commit '17c0878941ab8ab19108fb639c5138d92a89e919' into HEAD
maria-rcks Oct 6, 2026
0947ee0
test(connect): use parameterized credential precedence cases
maria-rcks Oct 6, 2026
5a898ed
fix(connect): report startup failures and stop mismatched retries
maria-rcks Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ const EnvServerConfig = Config.all({
host: Config.String("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
t3Home: Config.String("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
devUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
connectDevShare: Config.String("T3CODE_DEV_SHARE").pipe(
Config.withDefault(""),
Config.map((value) => value === "connect"),
),
connectAuthorizationHome: Config.String("T3CODE_CONNECT_AUTHORIZATION_HOME").pipe(
Config.option,
Config.map(Option.getOrUndefined),
),
devAllowedOrigins: Config.String("T3CODE_DEV_ALLOWED_ORIGINS").pipe(
Config.withDefault(""),
Config.map((value) =>
Expand Down Expand Up @@ -464,6 +472,10 @@ export const resolveServerConfig = (
devUrl,
...(devAuthToken === undefined ? {} : { devAuthToken }),
devAllowedOrigins: env.devAllowedOrigins,
...(env.connectDevShare ? { connectDevShare: true } : {}),
...(env.connectAuthorizationHome
? { connectAuthorizationHome: env.connectAuthorizationHome }
: {}),
noBrowser,
startupPresentation,
desktopBootstrapToken,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
const layerRuntime = Layer.mergeAll(
ServerSecretStore.layer,
CliTokenManager.layer.pipe(
CliTokenManager.layerWithSharedAuthorization.pipe(
Layer.provide(ServerSecretStore.layer),
Layer.provide(ExternalLauncher.layer),
),
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/cli/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as TestConsole from "effect/testing/TestConsole";
import { Command } from "effect/cli";

import { cli } from "../binCli.ts";
import { CLOUD_ENDPOINT_HTTP_ORIGIN } from "../cloud/config.ts";
import {
SERVICE_LAUNCHER_CONTEXT_ENV,
SERVICE_LAUNCHER_PROTOCOL,
Expand Down Expand Up @@ -144,6 +145,50 @@ const withDescriptorServer = <A, E, R>(run: (origin: string) => Effect.Effect<A,
);

describe("t3 pair", () => {
it.effect("refuses missing, invalid and unreachable connect endpoints before minting", () =>
withDescriptorServer((origin) =>
Effect.gen(function* () {
const baseDir = yield* Effect.acquireRelease(
Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-connect-"))),
(path) => Effect.sync(() => NodeFS.rmSync(path, { recursive: true, force: true })),
);
const stateDir = NodePath.join(baseDir, "userdata");
yield* persistServerRuntimeState({
path: NodePath.join(stateDir, "server-runtime.json"),
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: Number(new URL(origin).port),
}),
});
const secretsDir = NodePath.join(stateDir, "secrets");
NodeFS.mkdirSync(secretsDir, { recursive: true });
for (const [endpoint, expectedMessage] of [
[undefined, "No saved Connect endpoint"],
["not a url", "saved Connect endpoint is invalid"],
[origin, "saved Connect endpoint is invalid"],
["https://user:password@example.com", "saved Connect endpoint is invalid"],
["https://example.com/path", "saved Connect endpoint is invalid"],
["https://127.0.0.1:1", "not reaching this environment"],
] as const) {
if (endpoint !== undefined) {
NodeFS.writeFileSync(
NodePath.join(secretsDir, `${CLOUD_ENDPOINT_HTTP_ORIGIN}.bin`),
endpoint,
);
}
const error = yield* provideCliTestLayers(
runCli(["pair", "--base-dir", baseDir, "--connect"]).pipe(Effect.flip),
);
const rendered = String(
typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
);
assert.include(rendered, expectedMessage);
assert.isFalse(NodeFS.existsSync(NodePath.join(stateDir, "state.sqlite")));
}
}),
).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("mints a token and prints a QR pairing URL for a live server", () =>
withDescriptorServer((origin) =>
Effect.gen(function* () {
Expand Down
68 changes: 64 additions & 4 deletions apps/server/src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* public environment descriptor. Inside a linked git worktree the worktree's
* own `.t3` is checked first (matching dev-runner precedence); otherwise the
* shared T3 home. `--tailscale` publishes the server over Tailscale Serve
* HTTPS and pairs through the tailnet URL instead.
* HTTPS and pairs through the tailnet URL instead. `--connect` uses the
* environment's existing managed Connect endpoint after checking it is live.
*/
import {
AuthStandardClientScopes,
Expand Down Expand Up @@ -36,6 +37,8 @@ import { Command, Flag, GlobalFlag } from "effect/cli";
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";

import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { CLOUD_ENDPOINT_HTTP_ORIGIN } from "../cloud/config.ts";
import * as ServerConfig from "../config.ts";
import { resolveBaseDir } from "../os-jank.ts";
import {
Expand All @@ -55,6 +58,7 @@ import { baseDirFlag, DurationFromString } from "./config.ts";

const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
const decodeConnectOrigin = Schema.decodeUnknownOption(Schema.URLFromString);
// Tailscale provisions an HTTPS certificate on the first request to a fresh
// serve mapping, which can take a few seconds.
const TAILSCALE_PROBE_ATTEMPTS = 5;
Expand All @@ -81,6 +85,23 @@ export class NoRunningServerError extends Schema.TaggedError<NoRunningServerErro
}
}

export class ConnectPairingError extends Schema.TaggedError<ConnectPairingError>()(
"ConnectPairingError",
{ reason: Schema.Literals(["missing", "invalid", "unreachable", "conflict"]) },
) {
override get message(): string {
return {
missing:
"No saved Connect endpoint for this environment. Run `t3 connect link`, or restart its `vp run dev --share` server.",
invalid:
"The saved Connect endpoint is invalid. Restart this environment to refresh its link.",
unreachable:
"The saved Connect endpoint is not reaching this environment. Restart its Connect share and retry once the public endpoint is ready.",
conflict: "Choose either --connect or --tailscale.",
}[this.reason];
}
}

// 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.TaggedError<TailscaleUnavailableError>()(
Expand Down Expand Up @@ -128,7 +149,7 @@ export class ServePortOccupiedError extends Schema.TaggedError<ServePortOccupied
}
}

/** The URL a browser or phone should pair through, absent Tailscale. */
/** The URL a browser or phone should pair through without a sharing flag. */
export const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string =>
state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port);

Expand Down Expand Up @@ -227,6 +248,34 @@ const probeEnvironmentDescriptor = (
return { _tag: "descriptor", descriptor } as const;
}).pipe(Effect.catch((outcome) => Effect.succeed(outcome)));

const resolveConnectPairingBaseUrl = Effect.fn("pair.resolveConnectPairingBaseUrl")(function* (
environmentId: ExecutionEnvironmentDescriptor["environmentId"],
) {
const secrets = yield* ServerSecretStore.ServerSecretStore;
const stored = yield* secrets.get(CLOUD_ENDPOINT_HTTP_ORIGIN);
if (Option.isNone(stored)) {
return yield* new ConnectPairingError({ reason: "missing" });
}
const parsed = decodeConnectOrigin(new TextDecoder().decode(stored.value));
if (
Option.isNone(parsed) ||
parsed.value.protocol !== "https:" ||
parsed.value.username !== "" ||
parsed.value.password !== "" ||
parsed.value.pathname !== "/" ||
parsed.value.search !== "" ||
parsed.value.hash !== ""
) {
return yield* new ConnectPairingError({ reason: "invalid" });
}
const baseUrl = parsed.value.origin;
const probe = yield* probeEnvironmentDescriptor(baseUrl);
if (probe._tag !== "descriptor" || probe.descriptor.environmentId !== environmentId) {
return yield* new ConnectPairingError({ reason: "unreachable" });
}
return baseUrl;
});

interface DiscoveredPairTarget {
readonly baseDir: string;
readonly variant: PairStateVariant;
Expand Down Expand Up @@ -477,6 +526,10 @@ export const pairCommand = Command.make("pair", {
baseDir: baseDirFlag,
ttl: ttlFlag,
label: labelFlag,
connect: Flag.Boolean("connect").pipe(
Flag.withDescription("Pair through this environment's existing T3 Connect endpoint."),
Flag.withDefault(false),
),
tailscale: tailscaleFlag,
tailscaleServePort: tailscaleServePortFlag,
}).pipe(
Expand All @@ -485,16 +538,24 @@ export const pairCommand = Command.make("pair", {
),
Command.withHandler((flags) =>
Effect.gen(function* () {
if (flags.connect && flags.tailscale) {
return yield* new ConnectPairingError({ reason: "conflict" });
}
const cliLogLevel = yield* GlobalFlag.LogLevel;
// Default to Warn so storage/migration chatter cannot bury the QR code;
// an explicit --log-level still wins.
const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const);

const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir));
const config = yield* makePairServerConfig({ target, logLevel });

const notes: Array<string> = [];
let pairingBaseUrl: string;
if (flags.tailscale) {
if (flags.connect) {
pairingBaseUrl = yield* resolveConnectPairingBaseUrl(target.descriptor.environmentId).pipe(
Effect.provide(ServerSecretStore.layer.pipe(Layer.provide(ServerConfig.layer(config)))),
);
} else if (flags.tailscale) {
const resolved = yield* resolveTailscalePairingBase({
target,
servePort: flags.tailscaleServePort,
Expand All @@ -515,7 +576,6 @@ 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);

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/cloud/CliState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Option from "effect/Option";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import {
CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
CLOUD_ENDPOINT_HTTP_ORIGIN,
CLOUD_ENDPOINT_RUNTIME_CONFIG,
CLOUD_LINKED_USER_ID,
CLOUD_MINT_PUBLIC_KEY,
Expand Down Expand Up @@ -54,6 +55,7 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f
);
} else {
yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET);
yield* secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN);
}
});

Expand All @@ -68,6 +70,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () {
secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
secrets.remove(CLOUD_MINT_PUBLIC_KEY),
secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN),
secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN),
secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET),
],
Expand Down
108 changes: 108 additions & 0 deletions apps/server/src/cloud/CliTokenManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@ import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Base64Url from "effect/encoding/Base64Url";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as Terminal from "effect/Terminal";
import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientResponse from "effect/http/HttpClientResponse";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as ExternalLauncher from "../process/externalLauncher.ts";
import * as CliTokenManager from "./CliTokenManager.ts";

// pk_test_<base64 of "clerk.example.test$">
Expand Down Expand Up @@ -323,3 +328,106 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) =>
}),
);
});

it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it) => {
it.effect.each([
{
name: "prefers the stored home over the dev-share home",
stored: true,
devShare: true,
configured: true,
expected: "stored",
},
{
name: "uses the stored home outside dev sharing",
stored: true,
devShare: false,
configured: true,
expected: "stored",
},
{
name: "uses the configured home for dev sharing",
stored: false,
devShare: true,
configured: true,
expected: "configured",
},
{
name: "ignores the configured home outside dev sharing",
stored: false,
devShare: false,
configured: true,
expected: "local",
},
{
name: "uses local credentials without an authorization home",
stored: false,
devShare: true,
configured: false,
expected: "local",
},
])("$name", (scenario) =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-priority-" });
const storedHome = path.join(directory, "stored");
const configuredHome = path.join(directory, "configured");
const localConfig = ServerConfig.make({
...config,
secretsDir: path.join(directory, "local"),
connectDevShare: scenario.devShare,
...(scenario.configured ? { connectAuthorizationHome: configuredHome } : {}),
});
for (const [identity, secretsDir] of [
["stored", path.join(storedHome, "userdata", "secrets")],
["configured", path.join(configuredHome, "userdata", "secrets")],
["local", localConfig.secretsDir],
] as const) {
yield* Effect.gen(function* () {
const manager = yield* CliTokenManager.CloudCliTokenManager;
yield* manager.store({
accessToken: identity,
refreshToken: identity,
expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
});
}).pipe(
Effect.provide(
CliTokenManager.layer.pipe(
Layer.provide(ServerSecretStore.layer),
Layer.provide(ServerConfig.layer({ ...localConfig, secretsDir })),
),
),
);
}
yield* Effect.gen(function* () {
const secrets = yield* ServerSecretStore.ServerSecretStore;
if (scenario.stored) {
yield* secrets.set(
CliTokenManager.CLOUD_CLI_AUTHORIZATION_HOME_SECRET,
new TextEncoder().encode(storedHome),
);
}
const token = yield* Effect.gen(function* () {
const manager = yield* CliTokenManager.CloudCliTokenManager;
return yield* manager.getExisting;
}).pipe(Effect.provide(CliTokenManager.layerWithSharedAuthorization));
assert.isTrue(Option.isSome(token));
assert.equal(Option.getOrThrow(token).accessToken, scenario.expected);
}).pipe(
Effect.provide(
ServerSecretStore.layer.pipe(Layer.provideMerge(ServerConfig.layer(localConfig))),
),
);
}).pipe(
Effect.provide(
Layer.mergeAll(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-cli-auth-config-" }),
ExternalLauncher.layer,
layerDeviceFlow({ requests: [], tokenReplies: [] }),
),
),
),
);
});
Loading
Loading