Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ The most common defect in this repo is a change that works on the path you teste

- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run.
- `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins.
- Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift.
- Ports derive from the worktree path and are stable across restarts, and the host is `<worktree>.localhost` so every dev server keeps its own cookie jar; read the real origin from the `[dev-runner]` line since occupied ports shift.
- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself.
- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management).
- Stop what you started, by the PID you tracked. See rule 1.
Expand Down
62 changes: 7 additions & 55 deletions apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,8 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import { identity } from "effect/Function";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
import * as Cookies from "effect/unstable/http/Cookies";
import * as HttpEffect from "effect/unstable/http/HttpEffect";
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
Expand All @@ -39,12 +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 {
decodeDevelopmentSessionCookieName,
deriveAuthClientMetadata,
planStaleDevelopmentSessionCookieSweep,
} from "./utils.ts";
import { deriveAuthClientMetadata } from "./utils.ts";
import { verifyRequestDpopProof } from "./dpop.ts";

const CREDENTIAL_RESPONSE_HEADERS = {
Expand Down Expand Up @@ -210,8 +203,6 @@ export const authHttpApiLayer = HttpApiBuilder.group(
Effect.fnUntraced(function* (handlers) {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;

return handlers
.handle(
Expand All @@ -237,52 +228,13 @@ export const authHttpApiLayer = HttpApiBuilder.group(
args.payload.credential,
deriveAuthClientMetadata({ request }),
);
const requestCookieNames = Object.keys(request.cookies);
const cookieNamesToExpire =
serverConfig.devUrl === undefined
? []
: yield* Effect.gen(function* () {
const stateDirs = new Set(
requestCookieNames.flatMap((name) => {
const decoded = decodeDevelopmentSessionCookieName(name);
return decoded !== null && "stateDir" in decoded ? [decoded.stateDir] : [];
}),
);
const stateDirExistence = new Map(
yield* Effect.all(
Array.from(stateDirs, (stateDir) =>
fileSystem.exists(stateDir).pipe(
Effect.orElseSucceed(() => true),
Effect.map((exists) => [stateDir, exists] as const),
),
),
{ concurrency: "unbounded" },
),
);
return planStaleDevelopmentSessionCookieSweep({
ownCookieName: sessions.cookieName,
ownPort: serverConfig.port,
requestCookieNames,
stateDirExists: (stateDir) => stateDirExistence.get(stateDir) ?? true,
});
});
const sessionCookies = yield* Effect.fromResult(
cookieNamesToExpire.reduce(
(cookies, name) =>
Result.flatMap(cookies, (current) =>
Cookies.expireCookie(current, name, {
httpOnly: true,
path: "/",
sameSite: "lax",
}),
),
Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {
expires: DateTime.toDate(result.response.expiresAt),
httpOnly: true,
path: "/",
sameSite: "lax",
}),
),
Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {
expires: DateTime.toDate(result.response.expiresAt),
httpOnly: true,
path: "/",
sameSite: "lax",
}),
).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));

yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Expand Down
75 changes: 7 additions & 68 deletions apps/server/src/auth/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { describe, expect, it } from "vite-plus/test";

import {
base64UrlEncode,
decodeDevelopmentSessionCookieName,
deriveAuthClientMetadata,
isRemoteReachableHost,
planStaleDevelopmentSessionCookieSweep,
resolveSessionCookieName,
} from "./utils.ts";

Expand Down Expand Up @@ -61,34 +58,24 @@ describe("deriveAuthClientMetadata", () => {
});

describe("session cookie isolation", () => {
it("isolates loopback web servers by port and encoded server state", () => {
const firstStateDir = "/tmp/t3-agent-one";
const secondStateDir = "/tmp/t3-agent-two";
it("isolates loopback web servers by port and server state", () => {
const first = resolveSessionCookieName({
mode: "web",
port: 5775,
host: "127.0.0.1",
instanceKey: firstStateDir,
instanceKey: "/tmp/t3-agent-one",
development: true,
});
const second = resolveSessionCookieName({
mode: "web",
port: 5775,
host: "127.0.0.1",
instanceKey: secondStateDir,
instanceKey: "/tmp/t3-agent-two",
development: true,
});

expect(first).toBe(`t3_session_5775_${base64UrlEncode(firstStateDir)}`);
expect(second).toBe(`t3_session_5775_${base64UrlEncode(secondStateDir)}`);
expect(decodeDevelopmentSessionCookieName(first)).toEqual({
port: 5775,
stateDir: firstStateDir,
});
expect(decodeDevelopmentSessionCookieName(second)).toEqual({
port: 5775,
stateDir: secondStateDir,
});
expect(first).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
expect(second).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
expect(first).not.toBe(second);
});

Expand Down Expand Up @@ -126,16 +113,15 @@ describe("session cookie isolation", () => {
});

it("isolates development servers even when they bind a wildcard host", () => {
const stateDir = "/tmp/t3-wildcard-dev";
expect(
resolveSessionCookieName({
mode: "web",
port: 5775,
host: "0.0.0.0",
instanceKey: stateDir,
instanceKey: "/tmp/t3-wildcard-dev",
development: true,
}),
).toBe(`t3_session_5775_${base64UrlEncode(stateDir)}`);
).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
});

it("classifies loopback aliases separately from remotely reachable hosts", () => {
Expand All @@ -147,50 +133,3 @@ describe("session cookie isolation", () => {
expect(isRemoteReachableHost("192.168.1.50")).toBe(true);
});
});

describe("development session cookie decoding", () => {
it("classifies encoded state directories, legacy hashes, and unrelated names", () => {
const stateDir = "/tmp/t3-agent-state";

expect(
decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode(stateDir)}`),
).toEqual({ port: 5775, stateDir });
expect(decodeDevelopmentSessionCookieName("t3_session_5775_0123456789ab")).toEqual({
port: 5775,
legacyHash: "0123456789ab",
});
expect(decodeDevelopmentSessionCookieName("t3_session")).toBeNull();
expect(decodeDevelopmentSessionCookieName("t3_session_5775")).toBeNull();
expect(
decodeDevelopmentSessionCookieName(`t3_session_5775_${base64UrlEncode("relative/state")}`),
).toBeNull();
expect(decodeDevelopmentSessionCookieName("other_5775_0123456789ab")).toBeNull();
});
});

describe("stale development session cookie sweep", () => {
it("expires only dead encoded siblings and same-port legacy cookies", () => {
const ownCookieName = `t3_session_5775_${base64UrlEncode("/tmp/own")}`;
const liveSibling = `t3_session_5776_${base64UrlEncode("/tmp/live")}`;
const staleSibling = `t3_session_5777_${base64UrlEncode("/tmp/stale")}`;
const samePortLegacy = "t3_session_5775_0123456789ab";
const otherPortLegacy = "t3_session_5778_abcdef012345";

expect(
planStaleDevelopmentSessionCookieSweep({
ownCookieName,
ownPort: 5775,
requestCookieNames: [
ownCookieName,
liveSibling,
staleSibling,
samePortLegacy,
otherPortLegacy,
"t3_session",
"t3_session_3773",
],
stateDirExists: (stateDir) => stateDir === "/tmp/live",
}),
).toEqual([staleSibling, samePortLegacy]);
});
});
73 changes: 8 additions & 65 deletions apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ const SESSION_COOKIE_NAME = "t3_session";
* - **Desktop**, which scans upward from 3773 for a free port and binds
* 127.0.0.1, so a second instance lands on a different port and the same host.
*
* Dev names encode the state directory so a server can recognize and expire
* cookies belonging to worktrees whose state directories no longer exist.
*
* Hosted deployments keep the stable production name: their public port can
* change between releases, and scoping it would log every user out.
*/
Expand All @@ -43,68 +40,14 @@ export function resolveSessionCookieName(input: {
return SESSION_COOKIE_NAME;
}

return `${SESSION_COOKIE_NAME}_${input.port}_${base64UrlEncode(input.instanceKey)}`;
}

export type DevelopmentSessionCookieName =
| { readonly port: number; readonly stateDir: string }
| { readonly port: number; readonly legacyHash: string };

export function decodeDevelopmentSessionCookieName(
name: string,
): DevelopmentSessionCookieName | null {
const match = /^t3_session_(\d+)_([A-Za-z0-9_-]+)$/.exec(name);
const port = Number(match?.[1]);
const suffix = match?.[2];
if (!Number.isSafeInteger(port) || suffix === undefined) {
return null;
}

if (/^[0-9a-f]{12}$/.test(suffix)) {
return { port, legacyHash: suffix };
}

const decoded = Encoding.decodeBase64UrlString(suffix);
if (Result.isFailure(decoded) || !isAbsolutePathLike(decoded.success)) {
return null;
}
return { port, stateDir: decoded.success };
}

// POSIX root, Windows drive, or UNC. Format only: the sweep decides existence.
function isAbsolutePathLike(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
}

/**
* Plans cookie expiry from already-observed directory existence. Encoding the
* path creates a localhost-only path-existence oracle for pages that craft
* cookie names and observe expiry. This is accepted for development servers,
* where such a page already runs code on the same machine.
*/
export function planStaleDevelopmentSessionCookieSweep(input: {
readonly ownCookieName: string;
readonly ownPort: number;
readonly requestCookieNames: Iterable<string>;
readonly stateDirExists: (stateDir: string) => boolean;
}): ReadonlyArray<string> {
const namesToExpire: Array<string> = [];
for (const name of input.requestCookieNames) {
if (name === input.ownCookieName) {
continue;
}
const decoded = decodeDevelopmentSessionCookieName(name);
if (decoded === null) {
continue;
}
if (
("legacyHash" in decoded && decoded.port === input.ownPort) ||
("stateDir" in decoded && !input.stateDirExists(decoded.stateDir))
) {
namesToExpire.push(name);
}
}
return namesToExpire;
// Cookies are scoped by host, not port. Loopback development servers need an
// instance-specific name or parallel agents overwrite each other's session,
// and a server that later reuses the port receives a token signed elsewhere.
const instanceHash = NodeCrypto.createHash("sha256")
.update(input.instanceKey)
.digest("hex")
.slice(0, 12);
return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
}

export function isRemoteReachableHost(host: string | undefined): boolean {
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/cli/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ describe("pair tailscale local target", () => {
expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual(
{ localPort: 5_733 },
);
expect(
resolveTailscaleLocalTarget({
...baseState,
devUrl: "http://feature-worktree.localhost:5733/",
}),
).toEqual({ localPort: 5_733 });
// A dev server on a non-loopback interface must be proxied at that
// interface; tailscale serve defaults to 127.0.0.1 otherwise.
expect(
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ describe("http dev routing", () => {
expect(isLoopbackHostname("localhost")).toBe(true);
expect(isLoopbackHostname("::1")).toBe(true);
expect(isLoopbackHostname("[::1]")).toBe(true);
expect(isLoopbackHostname("feature-worktree.localhost")).toBe(true);
expect(isLoopbackHostname("FEATURE-WORKTREE.LOCALHOST")).toBe(true);
});

it("does not treat LAN addresses as local", () => {
expect(isLoopbackHostname("192.168.86.35")).toBe(false);
expect(isLoopbackHostname("10.0.0.24")).toBe(false);
expect(isLoopbackHostname("example.local")).toBe(false);
expect(isLoopbackHostname("localhost.example.com")).toBe(false);
});

it("preserves path and query when redirecting to the dev server", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function isLoopbackHostname(hostname: string): boolean {
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, "$1");
return LOOPBACK_HOSTNAMES.has(normalizedHostname);
return LOOPBACK_HOSTNAMES.has(normalizedHostname) || normalizedHostname.endsWith(".localhost");
}

export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
Expand Down
Loading
Loading