Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2654583
Keep worktree dev state isolated
t3dotgg Jul 26, 2026
e52d104
Distinguish worktrees from submodules, cover precedence
t3dotgg Jul 26, 2026
6404fc1
Match worktrees by their gitdir tail, ignore blank --home-dir
t3dotgg Jul 26, 2026
8684ca4
Make browser dev shareable over Tailscale
t3dotgg Jul 26, 2026
dbad3bc
Decline --share for dev:desktop
t3dotgg Jul 26, 2026
ce4664d
Classify tailscale stderr instead of quoting it; probe the bind host
t3dotgg Jul 26, 2026
2869a9e
Signal single-origin dev positively so .env cannot revive backend URLs
t3dotgg Jul 26, 2026
37e1c79
Apply the bind host to the server port only
t3dotgg Jul 26, 2026
b50efd5
Address review: recursive secret check, pinned test port, bun docs
t3dotgg Jul 26, 2026
2db512a
Split DevShareError into three classes and keep the cause
t3dotgg Jul 26, 2026
029dfc3
Merge remote-tracking branch 'origin/main' into agent/dev-server-sharing
t3dotgg Jul 26, 2026
4333a66
Fix dev sharing across hosted and restarted servers
t3dotgg Jul 26, 2026
f48ee4a
Update hosted cookie test expectation
t3dotgg Jul 26, 2026
8ba59f6
Claim dev share ownership after serving
t3dotgg Jul 26, 2026
3b4765b
Make dev share acquisition transactional
t3dotgg Jul 26, 2026
5f48002
Handoff dev share leases between clear and serve
t3dotgg Jul 26, 2026
0a504fa
Restore dev sharing when lease handoff fails
t3dotgg Jul 26, 2026
2071c98
Keep desktop session cookies port-scoped
t3dotgg Jul 27, 2026
63b2613
Drop an inherited HOST in browser dev modes
t3dotgg Jul 27, 2026
1c8e540
Remove the dev-share lease protocol
t3dotgg Jul 27, 2026
faf6467
Print the exact stop command at dev-runner startup
t3dotgg Jul 27, 2026
08d9260
Use HostProcessPlatform for the stop-command platform check
t3dotgg Jul 27, 2026
9055f7c
Revert the printed stop command
t3dotgg Jul 27, 2026
83783d0
Reject a specific non-loopback --host in browser dev; restore vp run …
t3dotgg Jul 27, 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
6 changes: 5 additions & 1 deletion .agents/skills/test-t3-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
2. Choose a base directory that belongs only to the current worktree or test:
- Use the repository's ignored `.t3` directory for reusable worktree-local state.
- Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.
3. Start the full web stack with `vp run dev`. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory.
3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory.
4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.

Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership.

The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server.

Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line.

Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.

The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.

## Preserve the environment while iterating
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
## Dev Servers

- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins.
- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet.
- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.
- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them.

## Package Roles

Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import * as ServerConfig from "../config.ts";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import { resolveSessionCookieName } from "./utils.ts";

import * as ServerSecretStore from "./ServerSecretStore.ts";

/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
const TEST_SERVER_PORT = 13_773;

const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
Layer.effect(
ServerConfig.ServerConfig,
Expand All @@ -19,6 +23,12 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se
return {
...config,
...overrides,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Last, so the port cannot be overridden out from under
// makeCookieRequest — which builds the cookie name from this constant.
// An override that changed it would leave the server reading
// t3_session_<other> while every request still sent t3_session_13773,
// and the tests would fail for a reason unrelated to what they assert.
port: TEST_SERVER_PORT,
} satisfies ServerConfig.ServerConfig["Service"];
}),
).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
Expand All @@ -35,7 +45,11 @@ const makeCookieRequest = (
): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
({
cookies: {
t3_session: sessionToken,
// Derived, not hardcoded: the name is port-scoped so concurrent servers
// on one hostname don't share a cookie. Mode and devUrl mirror
// ServerConfig.layerTest, so this resolves to whatever the server reads.
[resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]:
sessionToken,
},
headers: {},
}) as unknown as Parameters<
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service<
readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
readonly subject?: string;
readonly proofKeyThumbprint?: string;
readonly purpose?: "startup";
}) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
readonly issuePairingCredential: (
input?: AuthCreatePairingCredentialInput,
Expand Down Expand Up @@ -746,11 +747,13 @@ export const make = Effect.gen(function* () {
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
readonly subject: string;
readonly label?: string;
readonly purpose?: "startup";
}) =>
createPairingLink({
scopes: input.scopes,
subject: input.subject,
...(input.label ? { label: input.label } : {}),
...(input.purpose ? { purpose: input.purpose } : {}),
}).pipe(
Effect.map(
(issued) =>
Expand All @@ -774,6 +777,7 @@ export const make = Effect.gen(function* () {
...(input?.ttl ? { ttl: input.ttl } : {}),
...(input?.label ? { label: input.label } : {}),
...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
...(input?.purpose ? { purpose: input.purpose } : {}),
});
return {
id: issued.id,
Expand Down Expand Up @@ -872,6 +876,7 @@ export const make = Effect.gen(function* () {
issuePairingCredentialForSubject({
scopes: AuthAdministrativeScopes,
subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
purpose: "startup",
}).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));

const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
Expand Down
37 changes: 37 additions & 0 deletions apps/server/src/auth/EnvironmentAuthPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {

expect(descriptor.policy).toBe("desktop-managed-local");
expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
// Packaged desktop has no devUrl, but still needs the port scope: it
// scans upward from 3773 for a free port and binds 127.0.0.1, so a second
// instance shares this one's hostname on a different port.
expect(descriptor.sessionCookieName).toBe("t3_session_3773");
}).pipe(
Effect.provide(
Expand All @@ -45,6 +48,22 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
),
);

it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () =>
Effect.gen(function* () {
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
const descriptor = yield* policy.getDescriptor();

expect(descriptor.sessionCookieName).toBe("t3_session_3774");
}).pipe(
Effect.provide(
makeEnvironmentAuthPolicyLayer({
mode: "desktop",
port: 3774,
}),
),
),
);

it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
Effect.gen(function* () {
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
Expand Down Expand Up @@ -75,6 +94,24 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
makeEnvironmentAuthPolicyLayer({
mode: "web",
host: "127.0.0.1",
port: 13773,
}),
),
),
);

it.effect("scopes web session cookies by port only in development", () =>
Effect.gen(function* () {
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
const descriptor = yield* policy.getDescriptor();

expect(descriptor.sessionCookieName).toBe("t3_session_13773");
}).pipe(
Effect.provide(
makeEnvironmentAuthPolicyLayer({
mode: "web",
port: 13773,
devUrl: new URL("http://127.0.0.1:5733"),
}),
),
),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/EnvironmentAuthPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const make = Effect.gen(function* () {
sessionCookieName: resolveSessionCookieName({
mode: config.mode,
port: config.port,
devUrl: config.devUrl,
}),
};

Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service<
readonly subject?: string;
readonly label?: string;
readonly proofKeyThumbprint?: string;
/**
* "startup" marks the credential the server mints for itself at boot,
* which gets the long dev TTL when a dev URL is configured.
*/
readonly purpose?: "startup";
}) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
readonly listActive: () => Effect.Effect<
ReadonlyArray<AuthPairingLink>,
Expand Down Expand Up @@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
// window can still recover by re-bootstrapping rather than locking
// the user out of the backend.
const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24);
// A dev server's startup token is read off a log by whoever (or whatever) is
// driving the session, often minutes later — after a `node --watch` restart, a
// detour into another task, or a hand-off to the person actually doing the
// testing. Five minutes turns that into a restart-the-server loop for no
// security benefit: the token only unlocks a local dev backend, and its holder
// could read the log anyway. Same reasoning (and duration) as the desktop
// bootstrap grant above. Only applies when a dev URL is configured; user-issued
// pairing links and real servers keep the 5-minute default.
const DEV_STARTUP_TTL_HOURS = Duration.hours(24);
const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const PAIRING_TOKEN_LENGTH = 12;
const PAIRING_TOKEN_REJECTION_LIMIT =
Expand Down Expand Up @@ -371,7 +385,10 @@ export const make = Effect.gen(function* () {
),
);
const credential = yield* generatePairingToken;
const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup";
const ttl =
input?.ttl ??
(isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES);
const now = yield* DateTime.now;
const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
const issued: IssuedBootstrapCredential = {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ export const make = Effect.gen(function* () {
const cookieName = resolveSessionCookieName({
mode: serverConfig.mode,
port: serverConfig.port,
devUrl: serverConfig.devUrl,
});

const emitUpsert = (clientSession: AuthClientSession) =>
Expand Down
24 changes: 19 additions & 5 deletions apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,29 @@ import * as Result from "effect/Result";

const SESSION_COOKIE_NAME = "t3_session";

/**
* Cookies are scoped by host but *not* by port, so any two servers that can be
* live on one hostname at once need separate names — otherwise the second
* clobbers the first's session and both sides see "Invalid session token
* signature" until someone clears cookies by hand.
*
* Two populations qualify, for the same reason but from different causes:
*
* - **Dev servers** (`devUrl` set), which run several at a time across worktrees.
* - **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.
*
* Hosted deployments keep the stable production name: their public port can
* change between releases, and scoping it would log every user out.
*/
export function resolveSessionCookieName(input: {
readonly mode: "web" | "desktop";
readonly port: number;
readonly devUrl: URL | undefined;
}): string {
if (input.mode !== "desktop") {
return SESSION_COOKIE_NAME;
}

return `${SESSION_COOKIE_NAME}_${input.port}`;
return input.devUrl === undefined && input.mode !== "desktop"
? SESSION_COOKIE_NAME
: `${SESSION_COOKIE_NAME}_${input.port}`;
}

export function base64UrlEncode(input: string | Uint8Array): string {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
...derivedPaths,
staticDir: undefined,
devUrl: undefined,
devAllowedOrigins: [],
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
otlpMetricsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
devAllowedOrigins: [],
} as const;

const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) {
Expand Down Expand Up @@ -95,6 +96,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
T3CODE_HOST: "0.0.0.0",
T3CODE_HOME: baseDir,
VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
T3CODE_DEV_ALLOWED_ORIGINS:
"https://host.example.ts.net, https://phone.example.ts.net ",
T3CODE_NO_BROWSER: "true",
T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
T3CODE_LOG_WS_EVENTS: "true",
Expand All @@ -117,6 +120,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
host: "0.0.0.0",
staticDir: undefined,
devUrl: new URL("http://127.0.0.1:5173"),
devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"],
noBrowser: true,
startupPresentation: "browser",
desktopBootstrapToken: undefined,
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ 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)),
devAllowedOrigins: Config.string("T3CODE_DEV_ALLOWED_ORIGINS").pipe(
Config.withDefault(""),
Config.map((value) =>
value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
),
),
noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
Config.option,
Config.map(Option.getOrUndefined),
Expand Down Expand Up @@ -363,6 +372,7 @@ export const resolveServerConfig = (
host,
staticDir,
devUrl,
devAllowedOrigins: env.devAllowedOrigins,
noBrowser,
startupPresentation,
desktopBootstrapToken,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service<
readonly baseDir: string;
readonly staticDir: string | undefined;
readonly devUrl: URL | undefined;
readonly devAllowedOrigins: ReadonlyArray<string>;
readonly noBrowser: boolean;
readonly startupPresentation: StartupPresentation;
readonly desktopBootstrapToken: string | undefined;
Expand Down Expand Up @@ -187,6 +188,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
desktopBootstrapToken: undefined,
staticDir: undefined,
devUrl,
devAllowedOrigins: [],
noBrowser: false,
startupPresentation: "browser",
});
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
desktopBootstrapToken: undefined,
staticDir: undefined,
devUrl: undefined,
devAllowedOrigins: [],
noBrowser: false,
startupPresentation: "browser",
} satisfies ServerConfig.ServerConfig["Service"];
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AuthOrchestrationReadScope,
EnvironmentHttpApi,
} from "@t3tools/contracts";
import { isDevProxiedPath } from "@t3tools/shared/devProxy";
import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -48,9 +49,17 @@ export const browserApiCorsLayer = Layer.unwrap(
const devOrigin = config.devUrl?.origin;
// Dev uses credentialed requests from Vite or the Electron custom origin, so both must be
// explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.
//
// T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second
// origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies
// through Vite and is same-origin (no preflight at all), so this is a
// safety net for the desktop renderer and any direct-to-backend caller.
return HttpRouter.cors({
...(devOrigin
? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true }
? {
allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins],
credentials: true,
}
: {}),
allowedMethods: browserApiCorsAllowedMethods,
allowedHeaders: browserApiCorsAllowedHeaders,
Expand Down Expand Up @@ -216,6 +225,10 @@ export const staticAndDevRouteLayer = HttpRouter.add(
}

const config = yield* ServerConfig.ServerConfig;
if (config.devUrl && isDevProxiedPath(url.value.pathname)) {
return HttpServerResponse.text("Not Found", { status: 404 });
}

if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
status: 302,
Expand Down
Loading
Loading