authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 20:18:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 20:18:17-07:00
log3b75e607eb909522a8f5562fb40e44efc72bb66c
treec94567b1561dd19dde5173ee8730ceadd5d9c4a6
parent564719165e9131ab2aa55e9c805b860a415690da
signature Signed by PGP key B5690EEEBB952194

feat(server): add reusable auth token for dev worktrees (#8606)


20 files changed, 1177 insertions(+), 42 deletions(-)

AGENTS.md+2-2
......@@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste
7979- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run.
8080- `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.
8181- 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.
82- 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.
83- 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).
82- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings.
83- To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential).
8484- Stop what you started, by the PID you tracked. See rule 1.
8585
8686## Test data
apps/server/src/auth/EnvironmentAuth.test.ts+217-1
......@@ -3,9 +3,13 @@ import { AuthAdministrativeScopes } from "@t3tools/contracts";
33import { expect, it } from "@effect/vitest";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as SqlClient from "effect/unstable/sql/SqlClient";
69
710import * as ServerConfig from "../config.ts";
811import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
12import * as PersistenceErrors from "../persistence/Errors.ts";
913import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
1014import * as PairingGrantStore from "./PairingGrantStore.ts";
1115import * as EnvironmentAuth from "./EnvironmentAuth.ts";
......@@ -15,6 +19,8 @@ import * as SessionStore from "./SessionStore.ts";
1519
1620/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
1721const TEST_SERVER_PORT = 13_773;
22const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError);
23const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError);
1824
1925const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
2026 Layer.effect(
......@@ -33,7 +39,7 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se
3339
3440const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
3541 EnvironmentAuth.layer.pipe(
36 Layer.provide(SqlitePersistenceMemory),
42 Layer.provideMerge(SqlitePersistenceMemory),
3743 Layer.provide(ServerSecretStore.layer),
3844 Layer.provide(ServerEnvironment.identityLayer),
3945 Layer.provide(makeServerConfigLayer(overrides)),
......@@ -72,6 +78,216 @@ const requestMetadata = {
7278};
7379
7480it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
81 it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () =>
82 Effect.gen(function* () {
83 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
84 const sessions = yield* SessionStore.SessionStore;
85 const token = "reusable-dev-auth-token-that-is-long-enough";
86 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
87 const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
88 const scopedExchange = yield* serverAuth.createBrowserSession(
89 pairing.credential,
90 requestMetadata,
91 );
92 const request = {
93 cookies: {
94 [sessions.cookieName]: scopedExchange.sessionToken,
95 [devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
96 },
97 headers: {},
98 } as unknown as Parameters<
99 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
100 >[0];
101
102 const authenticated = yield* serverAuth.authenticateHttpRequest(request);
103 expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);
104 expect(devExchange.expireNormalCookie).toBe(true);
105 expect(authenticated.scopes).toEqual(["orchestration:read"]);
106 }).pipe(
107 Effect.provide(
108 makeEnvironmentAuthLayer({
109 mode: "web",
110 devUrl: new URL("http://127.0.0.1:5173"),
111 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
112 }),
113 ),
114 ),
115 );
116
117 it.effect("does not fall back to the dev cookie after a normal cookie is rejected", () =>
118 Effect.gen(function* () {
119 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
120 const sessions = yield* SessionStore.SessionStore;
121 const token = "reusable-dev-auth-token-that-is-long-enough";
122 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
123 const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
124 const scopedExchange = yield* serverAuth.createBrowserSession(
125 pairing.credential,
126 requestMetadata,
127 );
128 const scoped = yield* sessions.verify(scopedExchange.sessionToken);
129 yield* sessions.revoke(scoped.sessionId);
130 const request = {
131 cookies: {
132 [sessions.cookieName]: scopedExchange.sessionToken,
133 [devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
134 },
135 headers: {},
136 } as unknown as Parameters<
137 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
138 >[0];
139
140 const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
141 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
142 }).pipe(
143 Effect.provide(
144 makeEnvironmentAuthLayer({
145 mode: "web",
146 devUrl: new URL("http://127.0.0.1:5173"),
147 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
148 }),
149 ),
150 ),
151 );
152
153 it.effect("does not use the dev cookie when Authorization is invalid or empty", () =>
154 Effect.gen(function* () {
155 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
156 const token = "reusable-dev-auth-token-that-is-long-enough";
157 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
158 for (const authorization of ["Bearer invalid", ""] as const) {
159 const request = {
160 cookies: { [devExchange.cookieName ?? "missing"]: devExchange.sessionToken },
161 headers: { authorization },
162 } as unknown as Parameters<
163 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
164 >[0];
165 const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
166 expect(EnvironmentAuth.isServerAuthCredentialError(error)).toBe(true);
167 }
168 }).pipe(
169 Effect.provide(
170 makeEnvironmentAuthLayer({
171 mode: "web",
172 devUrl: new URL("http://127.0.0.1:5173"),
173 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
174 }),
175 ),
176 ),
177 );
178
179 it.effect("exchanges the reusable dev token for a local scoped OAuth session", () =>
180 Effect.gen(function* () {
181 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
182 const sessions = yield* SessionStore.SessionStore;
183 const token = "reusable-dev-auth-token-that-is-long-enough";
184 const exchanged = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
185 token,
186 ["orchestration:read"],
187 requestMetadata,
188 );
189
190 expect(exchanged.access_token).not.toBe(token);
191 expect(exchanged.scope).toBe("orchestration:read");
192 const dpop = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
193 token,
194 ["orchestration:read"],
195 requestMetadata,
196 { proofKeyThumbprint: "test-proof-key" },
197 );
198 expect(dpop.access_token).not.toBe(token);
199 expect(dpop.access_token).not.toBe(exchanged.access_token);
200 expect(dpop.token_type).toBe("DPoP");
201 expect(dpop.scope).toBe("orchestration:read");
202
203 const secondBearer = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
204 token,
205 ["orchestration:read"],
206 requestMetadata,
207 );
208 const firstSession = yield* serverAuth.authenticateHttpRequest(
209 makeBearerRequest(exchanged.access_token),
210 );
211 const secondSession = yield* serverAuth.authenticateHttpRequest(
212 makeBearerRequest(secondBearer.access_token),
213 );
214 expect(firstSession.subject).toBe("reusable-dev-token-child");
215 expect(secondSession.subject).toBe("reusable-dev-token-child");
216 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
217 }).pipe(
218 Effect.provide(
219 makeEnvironmentAuthLayer({
220 mode: "web",
221 devUrl: new URL("http://127.0.0.1:5173"),
222 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
223 }),
224 ),
225 ),
226 );
227
228 it.effect("uses a one-time startup credential after local dev token revocation", () =>
229 Effect.gen(function* () {
230 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
231 const sessions = yield* SessionStore.SessionStore;
232 const token = "reusable-dev-auth-token-that-is-long-enough";
233 const initial = yield* serverAuth.issueStartupPairingCredential();
234 const seeded = yield* sessions.verify(token);
235
236 yield* sessions.revoke(seeded.sessionId);
237 const recovery = yield* serverAuth.issueStartupPairingCredential();
238
239 expect(initial.credential).toBe(token);
240 expect(recovery.credential).not.toBe(token);
241 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
242 expect(
243 (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,
244 ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes });
245 }).pipe(
246 Effect.provide(
247 makeEnvironmentAuthLayer({
248 mode: "web",
249 devUrl: new URL("http://127.0.0.1:5173"),
250 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
251 }),
252 ),
253 ),
254 );
255
256 it.effect("keeps the pairing issue error as the immediate recovery failure", () =>
257 Effect.gen(function* () {
258 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
259 const sessions = yield* SessionStore.SessionStore;
260 const sql = yield* SqlClient.SqlClient;
261 const token = "reusable-dev-auth-token-that-is-long-enough";
262 const seeded = yield* sessions.verify(token);
263
264 yield* sessions.revoke(seeded.sessionId);
265 yield* sql`
266 CREATE TRIGGER reject_startup_pairing_link
267 BEFORE INSERT ON auth_pairing_links
268 BEGIN
269 SELECT RAISE(ABORT, 'startup pairing insert rejected');
270 END
271 `;
272
273 const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential());
274
275 expect(error._tag).toBe("ServerAuthPairingLinkCreationError");
276 expect(isPairingCredentialIssueError(error.cause)).toBe(true);
277 if (isPairingCredentialIssueError(error.cause)) {
278 expect(isPersistenceSqlError(error.cause.cause)).toBe(true);
279 }
280 }).pipe(
281 Effect.provide(
282 makeEnvironmentAuthLayer({
283 mode: "web",
284 devUrl: new URL("http://127.0.0.1:5173"),
285 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
286 }),
287 ),
288 ),
289 );
290
75291 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
76292 Effect.sync(() => {
77293 const error = EnvironmentAuth.toBootstrapExchangeError(
apps/server/src/auth/EnvironmentAuth.ts+100-9
......@@ -31,10 +31,12 @@ import * as Schema from "effect/Schema";
3131import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3232
3333import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
34import * as ServerConfig from "../config.ts";
3435import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
3536import * as PairingGrantStore from "./PairingGrantStore.ts";
3637import * as ServerSecretStore from "./ServerSecretStore.ts";
3738import * as SessionStore from "./SessionStore.ts";
39import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
3840import { verifyRequestDpopProof } from "./dpop.ts";
3941import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
4042
......@@ -427,6 +429,8 @@ export class EnvironmentAuth extends Context.Service<
427429 {
428430 readonly response: AuthBrowserSessionResult;
429431 readonly sessionToken: string;
432 readonly cookieName?: string;
433 readonly expireNormalCookie?: boolean;
430434 },
431435 ServerAuthInvalidCredentialError | ServerAuthInternalError
432436 >;
......@@ -504,6 +508,8 @@ export class EnvironmentAuth extends Context.Service<
504508type BootstrapExchangeResult = {
505509 readonly response: AuthBrowserSessionResult;
506510 readonly sessionToken: string;
511 readonly cookieName?: string;
512 readonly expireNormalCookie?: boolean;
507513};
508514
509515const AUTHORIZATION_PREFIX = "Bearer ";
......@@ -599,6 +605,8 @@ export const make = Effect.gen(function* () {
599605 const secretStore = yield* ServerSecretStore.ServerSecretStore;
600606 const crypto = yield* Crypto.Crypto;
601607 const descriptor = yield* policy.getDescriptor();
608 const config = yield* ServerConfig.ServerConfig;
609 const devAuth = resolveReusableDevAuth(config);
602610
603611 const authenticateToken = (
604612 token: string,
......@@ -630,15 +638,22 @@ export const make = Effect.gen(function* () {
630638 const authenticateRequest = (
631639 request: HttpServerRequest.HttpServerRequest,
632640 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
633 const credential = selectRequestCredential(
641 const selectedCredential = selectRequestCredential(
634642 request,
635643 sessions.cookieName,
636644 sessions.legacyCookieName,
637645 );
646 const dpopToken = parseDpopToken(request);
647 const hasAuthorization = request.headers.authorization !== undefined;
648 const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined;
649 const credential =
650 selectedCredential ??
651 (!hasAuthorization && devCookieToken !== undefined
652 ? { token: devCookieToken, source: "dev-cookie" as const }
653 : undefined);
638654 if (!credential?.token) {
639655 return Effect.fail(new ServerAuthMissingCredentialError({}));
640656 }
641 const dpopToken = parseDpopToken(request);
642657 return authenticateToken(credential.token).pipe(
643658 Effect.flatMap((session) => {
644659 if (session.proofKeyThumbprint) {
......@@ -697,8 +712,32 @@ export const make = Effect.gen(function* () {
697712 const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = (
698713 credential,
699714 requestMetadata,
700 ) =>
701 bootstrapCredentials.consume(credential).pipe(
715 ) => {
716 if (devAuth?.matches(credential)) {
717 return sessions.verify(credential).pipe(
718 mapSessionVerificationErrors,
719 Effect.flatMap((session) =>
720 DateTime.now.pipe(
721 Effect.map(
722 (now) =>
723 ({
724 response: {
725 authenticated: true,
726 scopes: session.scopes,
727 sessionMethod: session.method,
728 expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })),
729 } satisfies AuthBrowserSessionResult,
730 sessionToken: credential,
731 cookieName: devAuth.cookieName,
732 expireNormalCookie: true,
733 }) satisfies BootstrapExchangeResult,
734 ),
735 ),
736 ),
737 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
738 );
739 }
740 return bootstrapCredentials.consume(credential).pipe(
702741 Effect.mapError(toBootstrapExchangeError),
703742 Effect.flatMap((grant) =>
704743 sessions
......@@ -729,11 +768,42 @@ export const make = Effect.gen(function* () {
729768 ),
730769 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
731770 );
771 };
772
773 type ResolvedBootstrapGrant = Pick<
774 PairingGrantStore.BootstrapGrant,
775 "scopes" | "subject" | "label"
776 > & {
777 readonly method: PairingGrantStore.BootstrapGrant["method"] | "reusable-dev-token";
778 };
779 const resolveBootstrapGrant = (
780 credential: string,
781 input?: { readonly proofKeyThumbprint?: string },
782 ): Effect.Effect<
783 ResolvedBootstrapGrant,
784 ServerAuthInvalidCredentialError | ServerAuthInternalError
785 > => {
786 if (!devAuth?.matches(credential)) {
787 return bootstrapCredentials
788 .consume(credential, input)
789 .pipe(Effect.mapError(toBootstrapExchangeError));
790 }
791 return sessions.verify(credential).pipe(
792 mapSessionVerificationErrors,
793 Effect.map(
794 (session) =>
795 ({
796 method: "reusable-dev-token",
797 scopes: session.scopes,
798 subject: "reusable-dev-token-child",
799 }) satisfies ResolvedBootstrapGrant,
800 ),
801 );
802 };
732803
733804 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
734805 (credential, requestedScopes, requestMetadata, input) =>
735 bootstrapCredentials.consume(credential, input).pipe(
736 Effect.mapError(toBootstrapExchangeError),
806 resolveBootstrapGrant(credential, input).pipe(
737807 Effect.flatMap((grant) =>
738808 Effect.gen(function* () {
739809 const grantedScopes = requestedScopes ?? grant.scopes;
......@@ -917,12 +987,33 @@ export const make = Effect.gen(function* () {
917987 }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential"));
918988
919989 const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] =
920 () =>
921 issuePairingCredentialForSubject({
990 () => {
991 const fallback = issuePairingCredentialForSubject({
922992 scopes: AuthAdministrativeScopes,
923993 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
924994 purpose: "startup",
925 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
995 });
996 if (!devAuth) {
997 return fallback.pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
998 }
999 return sessions.verify(devAuth.credential).pipe(
1000 Effect.map(
1001 (session) =>
1002 ({
1003 id: session.sessionId,
1004 credential: devAuth.credential,
1005 label: "Reusable dev token",
1006 expiresAt: DateTime.toUtc(session.expiresAt ?? REUSABLE_DEV_SESSION_EXPIRES_AT),
1007 }) satisfies AuthPairingCredentialResult,
1008 ),
1009 Effect.catch((cause) =>
1010 SessionStore.isSessionCredentialInvalidError(cause)
1011 ? fallback
1012 : Effect.fail(new ServerAuthPairingLinkCreationError({ cause })),
1013 ),
1014 Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"),
1015 );
1016 };
9261017
9271018 const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
9281019 listSessions().pipe(
apps/server/src/auth/ReusableDevAuth.ts created+31
......@@ -0,0 +1,31 @@
1import * as NodeCrypto from "node:crypto";
2import { AuthSessionId } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Redacted from "effect/Redacted";
5
6import type { ServerConfig } from "../config.ts";
7
8export const REUSABLE_DEV_SESSION_PREFIX = "dev-auth-";
9// The database schema requires an expiry for a configured token with no normal session TTL.
10export const REUSABLE_DEV_SESSION_EXPIRES_AT = DateTime.makeUnsafe("9999-12-31T23:59:59.999Z");
11
12export function resolveReusableDevAuth(
13 config: Pick<ServerConfig["Service"], "mode" | "devUrl" | "devAuthToken">,
14) {
15 if (config.mode !== "web" || config.devUrl === undefined || config.devAuthToken === undefined) {
16 return undefined;
17 }
18 const token = config.devAuthToken;
19 if (Redacted.value(token).length === 0) {
20 return undefined;
21 }
22 const hash = NodeCrypto.createHash("sha256").update(Redacted.value(token)).digest();
23 const tokenId = hash.toString("hex");
24 return {
25 credential: Redacted.value(token),
26 sessionId: AuthSessionId.make(`${REUSABLE_DEV_SESSION_PREFIX}${tokenId}`),
27 cookieName: `t3_dev_session_${tokenId}`,
28 matches: (credential: string) =>
29 NodeCrypto.timingSafeEqual(hash, NodeCrypto.createHash("sha256").update(credential).digest()),
30 };
31}
apps/server/src/auth/SessionStore.test.ts+152-1
......@@ -3,9 +3,11 @@ import { EnvironmentId } from "@t3tools/contracts";
33import { expect, it } from "@effect/vitest";
44import * as Duration from "effect/Duration";
55import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
67import * as Layer from "effect/Layer";
78import * as Option from "effect/Option";
89import * as Queue from "effect/Queue";
10import * as Redacted from "effect/Redacted";
911import * as Stream from "effect/Stream";
1012import * as TestClock from "effect/testing/TestClock";
1113import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -13,7 +15,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
1315import * as ServerConfig from "../config.ts";
1416import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1517import { PersistenceSqlError } from "../persistence/Errors.ts";
16import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
18import {
19 makeSqlitePersistenceLive,
20 SqlitePersistenceMemory,
21} from "../persistence/Layers/Sqlite.ts";
1722import * as AuthSessions from "../persistence/AuthSessions.ts";
1823import * as SessionStore from "./SessionStore.ts";
1924import * as ServerSecretStore from "./ServerSecretStore.ts";
......@@ -54,6 +59,32 @@ const relaySessionInput = {
5459 client: { label: "Relay desktop", deviceType: "desktop" },
5560} as const;
5661
62const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(function* (
63 baseDir: string,
64 token?: string,
65) {
66 const devUrl = new URL("http://127.0.0.1:5173");
67 const paths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
68 baseDirIsExplicit: true,
69 });
70 yield* ServerConfig.ensureServerDirectories(paths);
71 const persistence = makeSqlitePersistenceLive(paths.dbPath);
72 return SessionStore.layer.pipe(
73 Layer.provide(persistence),
74 Layer.provide(ServerSecretStore.layer),
75 Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make(baseDir))),
76 Layer.provide(
77 makeServerConfigLayer({
78 ...paths,
79 baseDir,
80 mode: "web",
81 devUrl,
82 ...(token === undefined ? {} : { devAuthToken: Redacted.make(token) }),
83 }),
84 ),
85 );
86});
87
5788const repositoryFailure = new PersistenceSqlError({
5889 operation: "AuthSessionRepository.getById:query",
5990 detail: "sqlite is unavailable",
......@@ -62,6 +93,7 @@ const repositoryFailure = new PersistenceSqlError({
6293const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, {
6394 create: () => Effect.void,
6495 createReplacingActive: () => Effect.succeed([]),
96 createIfAbsent: () => Effect.void,
6597 getById: () => Effect.fail(repositoryFailure),
6698 listActive: () => Effect.succeed([]),
6799 revoke: () => Effect.fail(repositoryFailure),
......@@ -103,6 +135,125 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
103135 }),
104136 );
105137
138 it.effect("keeps reusable dev auth local across disk-backed stores and restarts", () =>
139 Effect.gen(function* () {
140 const fs = yield* FileSystem.FileSystem;
141 const token = "reusable-dev-auth-token-that-is-long-enough";
142 const baseA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-a-" });
143 const baseB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-b-" });
144 const layerA = yield* makeDiskSessionStoreLayer(baseA, token);
145 const fromA = yield* Effect.gen(function* () {
146 const sessions = yield* SessionStore.SessionStore;
147 const dev = yield* sessions.verify(token);
148 const local = yield* sessions.issue({ subject: "environment-a" });
149 const ticket = yield* sessions.issueWebSocketToken(local.sessionId);
150 yield* sessions.revoke(dev.sessionId);
151 return { dev, local, ticket };
152 }).pipe(Effect.provide(layerA), Effect.scoped);
153
154 const layerB = yield* makeDiskSessionStoreLayer(baseB, token);
155 yield* Effect.gen(function* () {
156 const sessions = yield* SessionStore.SessionStore;
157 const dev = yield* sessions.verify(token);
158 expect(dev.sessionId).toBe(fromA.dev.sessionId);
159 expect((yield* Effect.flip(sessions.verify(fromA.local.token)))._tag).toBe(
160 "InvalidSessionTokenSignatureError",
161 );
162 expect((yield* Effect.flip(sessions.verifyWebSocketToken(fromA.ticket.token)))._tag).toBe(
163 "InvalidWebSocketTokenSignatureError",
164 );
165 }).pipe(Effect.provide(layerB), Effect.scoped);
166
167 const reopenedB = yield* makeDiskSessionStoreLayer(baseB, token);
168 yield* Effect.gen(function* () {
169 const sessions = yield* SessionStore.SessionStore;
170 const dev = yield* sessions.verify(token);
171 expect(dev.sessionId).toBe(fromA.dev.sessionId);
172 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
173 expect((yield* sessions.verifyWebSocketToken(ticket.token)).sessionId).toBe(dev.sessionId);
174 }).pipe(Effect.provide(reopenedB), Effect.scoped);
175
176 const reopenedA = yield* makeDiskSessionStoreLayer(baseA, token);
177 yield* Effect.gen(function* () {
178 const sessions = yield* SessionStore.SessionStore;
179 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
180 }).pipe(Effect.provide(reopenedA), Effect.scoped);
181 }),
182 );
183
184 it.effect("invalidates old dev credentials and tickets after rotation or removal", () =>
185 Effect.gen(function* () {
186 const fs = yield* FileSystem.FileSystem;
187 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-rotation-" });
188 const oldToken = "old-reusable-dev-auth-token-that-is-long-enough";
189 const newToken = "new-reusable-dev-auth-token-that-is-long-enough";
190 const initialLayer = yield* makeDiskSessionStoreLayer(baseDir, oldToken);
191 const old = yield* Effect.gen(function* () {
192 const sessions = yield* SessionStore.SessionStore;
193 const dev = yield* sessions.verify(oldToken);
194 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
195 return { dev, ticket };
196 }).pipe(Effect.provide(initialLayer), Effect.scoped);
197
198 const rotatedLayer = yield* makeDiskSessionStoreLayer(baseDir, newToken);
199 const rotated = yield* Effect.gen(function* () {
200 const sessions = yield* SessionStore.SessionStore;
201 expect((yield* Effect.flip(sessions.verify(oldToken)))._tag).toBe(
202 "MalformedSessionTokenError",
203 );
204 expect((yield* Effect.flip(sessions.verifyWebSocketToken(old.ticket.token)))._tag).toBe(
205 "UnknownWebSocketSessionError",
206 );
207 expect(
208 (yield* sessions.listActive()).some((row) => row.sessionId === old.dev.sessionId),
209 ).toBe(true);
210 const dev = yield* sessions.verify(newToken);
211 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
212 return { dev, ticket };
213 }).pipe(Effect.provide(rotatedLayer), Effect.scoped);
214
215 const removedLayer = yield* makeDiskSessionStoreLayer(baseDir);
216 yield* Effect.gen(function* () {
217 const sessions = yield* SessionStore.SessionStore;
218 expect((yield* Effect.flip(sessions.verify(newToken)))._tag).toBe(
219 "MalformedSessionTokenError",
220 );
221 expect((yield* Effect.flip(sessions.verifyWebSocketToken(rotated.ticket.token)))._tag).toBe(
222 "UnknownWebSocketSessionError",
223 );
224 expect(
225 (yield* sessions.listActive()).some((row) => row.sessionId === rotated.dev.sessionId),
226 ).toBe(true);
227 }).pipe(Effect.provide(removedLayer), Effect.scoped);
228 }),
229 );
230
231 it.effect("keeps the reusable token active after normal sessions expire", () =>
232 Effect.gen(function* () {
233 const sessions = yield* SessionStore.SessionStore;
234 const token = "reusable-dev-auth-token-that-is-long-enough";
235 const normal = yield* sessions.issue({ subject: "normal-session" });
236
237 yield* TestClock.adjust(Duration.days(31));
238
239 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
240 expect((yield* Effect.flip(sessions.verify(normal.token)))._tag).toBe(
241 "SessionTokenExpiredError",
242 );
243 }).pipe(
244 Effect.provide(
245 Layer.merge(
246 makeSessionStoreLayer({
247 mode: "web",
248 devUrl: new URL("http://127.0.0.1:5173"),
249 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
250 }),
251 TestClock.layer(),
252 ),
253 ),
254 ),
255 );
256
106257 it.effect("issues and verifies signed browser session tokens", () =>
107258 Effect.gen(function* () {
108259 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+70-1
......@@ -1,5 +1,6 @@
11import {
22 AuthSessionId,
3 AuthAdministrativeScopes,
34 AuthStandardClientScopes,
45 AuthEnvironmentScopes,
56 type AuthClientMetadata,
......@@ -24,6 +25,11 @@ import * as ServerConfig from "../config.ts";
2425import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
2526import * as AuthSessions from "../persistence/AuthSessions.ts";
2627import * as ServerSecretStore from "./ServerSecretStore.ts";
28import {
29 REUSABLE_DEV_SESSION_EXPIRES_AT,
30 REUSABLE_DEV_SESSION_PREFIX,
31 resolveReusableDevAuth,
32} from "./ReusableDevAuth.ts";
2733import {
2834 base64UrlDecodeUtf8,
2935 base64UrlEncode,
......@@ -416,7 +422,6 @@ export class SessionStore extends Context.Service<
416422const SIGNING_SECRET_NAME = "server-signing-key";
417423const DEFAULT_SESSION_TTL = Duration.days(30);
418424const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
419
420425const SessionClaims = Schema.Struct({
421426 v: Schema.Literal(1),
422427 kind: Schema.Literal("session"),
......@@ -492,6 +497,31 @@ export const make = Effect.gen(function* () {
492497 } as const;
493498 const cookieName = resolveSessionCookieName(cookieInput);
494499 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);
500 const devAuth = resolveReusableDevAuth(serverConfig);
501 if (devAuth) {
502 yield* authSessions
503 .createIfAbsent({
504 sessionId: devAuth.sessionId,
505 subject: "reusable-dev-token",
506 scopes: AuthAdministrativeScopes,
507 method: "browser-session-cookie",
508 client: {
509 label: "Reusable dev token",
510 ipAddress: null,
511 userAgent: null,
512 deviceType: "unknown",
513 os: null,
514 browser: null,
515 },
516 issuedAt: yield* DateTime.now,
517 expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT,
518 })
519 .pipe(
520 Effect.mapError(
521 (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }),
522 ),
523 );
524 }
495525
496526 const emitUpsert = (clientSession: AuthClientSession) =>
497527 PubSub.publish(changesPubSub, {
......@@ -717,6 +747,42 @@ export const make = Effect.gen(function* () {
717747
718748 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
719749 function* (token) {
750 if (devAuth?.matches(token)) {
751 const row = yield* authSessions
752 .getById({ sessionId: devAuth.sessionId })
753 .pipe(
754 Effect.mapError(
755 (cause) =>
756 new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }),
757 ),
758 );
759 if (Option.isNone(row)) {
760 return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId });
761 }
762 if (row.value.revokedAt !== null) {
763 return yield* new SessionTokenRevokedError({
764 sessionId: devAuth.sessionId,
765 revokedAt: row.value.revokedAt,
766 });
767 }
768 const observedAt = yield* DateTime.now;
769 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
770 return yield* new SessionTokenExpiredError({
771 sessionId: devAuth.sessionId,
772 expiresAt: row.value.expiresAt,
773 observedAt,
774 });
775 }
776 return {
777 sessionId: row.value.sessionId,
778 token,
779 method: row.value.method,
780 client: toClientMetadata(row.value.client),
781 expiresAt: row.value.expiresAt,
782 subject: row.value.subject,
783 scopes: row.value.scopes,
784 } satisfies VerifiedSession;
785 }
720786 const [encodedPayload, signature] = token.split(".");
721787 if (!encodedPayload || !signature) {
722788 return yield* new MalformedSessionTokenError({});
......@@ -829,6 +895,9 @@ export const make = Effect.gen(function* () {
829895 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
830896 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
831897 );
898 if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) {
899 return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
900 }
832901
833902 const observedAt = yield* DateTime.now;
834903 const expiresAt = DateTime.make(claims.exp);
apps/server/src/auth/http.test.ts created+137
......@@ -0,0 +1,137 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentHttpApi } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";
11import * as Etag from "effect/unstable/http/Etag";
12import * as HttpPlatform from "effect/unstable/http/HttpPlatform";
13import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
14import * as HttpApi from "effect/unstable/httpapi/HttpApi";
15import * as HttpRouter from "effect/unstable/http/HttpRouter";
16
17import * as ServerConfig from "../config.ts";
18import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
19import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
20import * as EnvironmentAuth from "./EnvironmentAuth.ts";
21import * as ServerSecretStore from "./ServerSecretStore.ts";
22import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./http.ts";
23
24const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough";
25class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}
26
27const configLayer = Layer.effect(
28 ServerConfig.ServerConfig,
29 Effect.gen(function* () {
30 const config = yield* ServerConfig.ServerConfig;
31 return {
32 ...config,
33 mode: "web",
34 devUrl: new URL("http://127.0.0.1:5173"),
35 devAuthToken: Redacted.make(DEV_TOKEN),
36 } satisfies ServerConfig.ServerConfig["Service"];
37 }),
38).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-http-test-" })));
39
40const environmentAuthLayer = EnvironmentAuth.layer.pipe(
41 Layer.provide(SqlitePersistenceMemory),
42 Layer.provide(ServerSecretStore.layer),
43 Layer.provide(ServerEnvironment.identityLayer),
44 Layer.provide(configLayer),
45);
46const routesLayer = HttpApiBuilder.layer(AuthTestApi).pipe(
47 Layer.provide(authHttpApiLayer),
48 Layer.provide(environmentAuthenticatedAuthLayer),
49 Layer.provideMerge(environmentAuthLayer),
50 Layer.provide(configLayer),
51 Layer.provideMerge(
52 HttpPlatform.layer.pipe(
53 Layer.provideMerge(NodeServices.layer),
54 Layer.provideMerge(Etag.layerWeak),
55 ),
56 ),
57 Layer.provide(NodeServices.layer),
58);
59
60const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
61const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, string>>) =>
62 new Request(`http://127.0.0.1${path}`, {
63 method: "POST",
64 headers: { "content-type": "application/json", ...headers },
65 body: encodeJson(body),
66 });
67
68it.effect("sets the selected browser session cookies through the HTTP route", () =>
69 Effect.gen(function* () {
70 const crypto = yield* Crypto.Crypto;
71 const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({
72 get: () => Effect.succeed(Option.none()),
73 set: () => Effect.void,
74 create: () => Effect.void,
75 getOrCreateRandom: () => Effect.die("Not used by these routes."),
76 remove: () => Effect.void,
77 });
78 const requestContext = Context.make(Crypto.Crypto, crypto).pipe(
79 Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore),
80 );
81 return yield* Effect.acquireUseRelease(
82 Effect.sync(
83 () =>
84 [
85 HttpRouter.toWebHandler(routesLayer, { disableLogger: true }),
86 HttpRouter.toWebHandler(routesLayer, { disableLogger: true }),
87 ] as const,
88 ),
89 ([environmentA, environmentB]) =>
90 Effect.tryPromise(async () => {
91 const devResponse = await environmentA.handler(
92 postJson("/api/auth/browser-session", { credential: DEV_TOKEN }),
93 requestContext,
94 );
95 expect(devResponse.status).toBe(200);
96 const devCookies = devResponse.headers.getSetCookie();
97 const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));
98 expect(devCookie).toContain("HttpOnly");
99 expect(devCookie).toContain(`=${DEV_TOKEN};`);
100 expect(devCookies).toContainEqual(
101 expect.stringMatching(/^t3_session_[^=]*=;.*Max-Age=0/),
102 );
103 const devCookieHeader = devCookie?.split(";", 1)[0] ?? "";
104 const environmentBSession = await environmentB.handler(
105 new Request("http://127.0.0.1/api/auth/session", {
106 headers: { cookie: devCookieHeader },
107 }),
108 requestContext,
109 );
110 expect(environmentBSession.status).toBe(200);
111 expect(await environmentBSession.json()).toMatchObject({ authenticated: true });
112
113 const pairingResponse = await environmentA.handler(
114 postJson(
115 "/api/auth/pairing-token",
116 { scopes: ["orchestration:read"] },
117 { cookie: devCookieHeader },
118 ),
119 requestContext,
120 );
121 expect(pairingResponse.status).toBe(200);
122 const pairing = (await pairingResponse.json()) as { credential: string };
123 const restrictedResponse = await environmentA.handler(
124 postJson("/api/auth/browser-session", { credential: pairing.credential }),
125 requestContext,
126 );
127 expect(restrictedResponse.status).toBe(200);
128 const restrictedCookies = restrictedResponse.headers.getSetCookie();
129 expect(restrictedCookies).toHaveLength(1);
130 expect(restrictedCookies[0]).toMatch(/^t3_session_/);
131 expect(restrictedCookies[0]).not.toContain("t3_dev_session_");
132 }),
133 ([environmentA, environmentB]) =>
134 Effect.promise(() => Promise.all([environmentA.dispose(), environmentB.dispose()])),
135 );
136 }).pipe(Effect.provide(NodeServices.layer)),
137);
apps/server/src/auth/http.ts+21-4
......@@ -273,10 +273,27 @@ export const authHttpApiLayer = HttpApiBuilder.group(
273273 args.payload.credential,
274274 deriveAuthClientMetadata({ request }),
275275 );
276 yield* appendSessionCookie(
277 sessions.cookieName,
278 result.sessionToken,
279 result.response.expiresAt,
276 const cookieName = result.cookieName ?? sessions.cookieName;
277 const selectedCookie = yield* Effect.fromResult(
278 Cookies.set(Cookies.empty, cookieName, result.sessionToken, {
279 expires: DateTime.toDate(result.response.expiresAt),
280 httpOnly: true,
281 path: "/",
282 sameSite: "lax",
283 }),
284 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));
285 const sessionCookies = result.expireNormalCookie
286 ? yield* Effect.fromResult(
287 Cookies.expireCookie(selectedCookie, sessions.cookieName, {
288 httpOnly: true,
289 path: "/",
290 sameSite: "lax",
291 }),
292 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")))
293 : selectedCookie;
294
295 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
296 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
280297 );
281298 yield* appendCredentialResponseHeaders;
282299 return result.response;
apps/server/src/cli/config.test.ts+89
......@@ -9,6 +9,7 @@ import * as FileSystem from "effect/FileSystem";
99import * as Layer from "effect/Layer";
1010import * as Option from "effect/Option";
1111import * as Path from "effect/Path";
12import * as Redacted from "effect/Redacted";
1213import * as Schema from "effect/Schema";
1314
1415import {
......@@ -24,6 +25,7 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
2425 deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });
2526
2627const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
28const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
2729
2830const makeDesktopBootstrap = (
2931 overrides: Partial<DesktopBackendBootstrapValue> = {},
......@@ -73,6 +75,93 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
7375 );
7476 });
7577
78 it.effect("enables a trimmed reusable auth token only for web dev mode", () =>
79 Effect.gen(function* () {
80 const baseDir = yield* FileSystem.FileSystem.pipe(
81 Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-" })),
82 );
83 const flags = {
84 mode: Option.some("web" as const),
85 port: Option.some(8788),
86 host: Option.none<string>(),
87 baseDir: Option.some(baseDir),
88 cwd: Option.none<string>(),
89 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
90 noBrowser: Option.none<boolean>(),
91 bootstrapFd: Option.none<number>(),
92 autoBootstrapProjectFromCwd: Option.none<boolean>(),
93 logWebSocketEvents: Option.none<boolean>(),
94 tailscaleServeEnabled: Option.none<boolean>(),
95 tailscaleServePort: Option.none<number>(),
96 };
97 const configLayer = ConfigProvider.layer(
98 ConfigProvider.fromEnv({
99 env: {
100 T3CODE_DEV_AUTH_TOKEN: " reusable-dev-auth-token-that-is-long-enough ",
101 },
102 }),
103 );
104 const web = yield* resolveServerConfig(flags, Option.none()).pipe(
105 Effect.provide(Layer.mergeAll(configLayer, NetService.layer)),
106 );
107 const desktop = yield* resolveServerConfig(
108 { ...flags, mode: Option.some("desktop" as const) },
109 Option.none(),
110 ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer)));
111
112 expect(web.devAuthToken).toBeDefined();
113 if (web.devAuthToken === undefined) {
114 return yield* Effect.die("Expected reusable dev auth token.");
115 }
116 expect(Redacted.value(web.devAuthToken)).toBe("reusable-dev-auth-token-that-is-long-enough");
117 expect(desktop.devAuthToken).toBeUndefined();
118 }),
119 );
120
121 it.effect("does not expose an invalid reusable auth token", () =>
122 Effect.gen(function* () {
123 const secret = "short-secret";
124 const baseDir = yield* FileSystem.FileSystem.pipe(
125 Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-invalid-" })),
126 );
127 const flags = {
128 mode: Option.some("web" as const),
129 port: Option.some(8788),
130 host: Option.none<string>(),
131 baseDir: Option.some(baseDir),
132 cwd: Option.none<string>(),
133 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
134 noBrowser: Option.none<boolean>(),
135 bootstrapFd: Option.none<number>(),
136 autoBootstrapProjectFromCwd: Option.none<boolean>(),
137 logWebSocketEvents: Option.none<boolean>(),
138 tailscaleServeEnabled: Option.none<boolean>(),
139 tailscaleServePort: Option.none<number>(),
140 };
141 const configLayer = ConfigProvider.layer(
142 ConfigProvider.fromEnv({ env: { T3CODE_DEV_AUTH_TOKEN: secret } }),
143 );
144 const error = yield* resolveServerConfig(flags, Option.none()).pipe(
145 Effect.provide(Layer.mergeAll(configLayer, NetService.layer)),
146 Effect.flip,
147 );
148 const desktop = yield* resolveServerConfig(
149 { ...flags, mode: Option.some("desktop" as const) },
150 Option.none(),
151 ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer)));
152 const staticWeb = yield* resolveServerConfig(
153 { ...flags, devUrl: Option.none() },
154 Option.none(),
155 ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer)));
156
157 expect(String(error)).not.toContain(secret);
158 const serialized = yield* encodeUnknownJson(error);
159 expect(serialized).not.toContain(secret);
160 expect(desktop.devAuthToken).toBeUndefined();
161 expect(staticWeb.devAuthToken).toBeUndefined();
162 }),
163 );
164
76165 it.effect("falls back to effect/config values when flags are omitted", () =>
77166 Effect.gen(function* () {
78167 const { join } = yield* Path.Path;
apps/server/src/cli/config.ts+24
......@@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem";
88import * as LogLevel from "effect/LogLevel";
99import * as Option from "effect/Option";
1010import * as Path from "effect/Path";
11import * as Redacted from "effect/Redacted";
1112import * as Schema from "effect/Schema";
1213import * as SchemaIssue from "effect/SchemaIssue";
1314import * as SchemaTransformation from "effect/SchemaTransformation";
......@@ -141,6 +142,26 @@ const EnvServerConfig = Config.all({
141142 ),
142143});
143144
145const DevAuthTokenConfig = Config.redacted("T3CODE_DEV_AUTH_TOKEN").pipe(
146 Config.map((token) => Redacted.make(Redacted.value(token).trim())),
147 Config.mapOrFail((token) =>
148 Redacted.value(token).length === 0 || Redacted.value(token).length >= 32
149 ? Effect.succeed(token)
150 : Effect.fail(
151 new Config.ConfigError(
152 new Schema.SchemaError(
153 new SchemaIssue.InvalidValue({
154 message: "T3CODE_DEV_AUTH_TOKEN must contain at least 32 characters.",
155 }),
156 ),
157 ),
158 ),
159 ),
160 Config.option,
161 Config.map(Option.filter((token) => Redacted.value(token).length > 0)),
162 Config.map(Option.getOrUndefined),
163);
164
144165export interface CliServerFlags {
145166 readonly mode: Option.Option<ServerConfig.RuntimeMode>;
146167 readonly port: Option.Option<number>;
......@@ -268,6 +289,8 @@ export const resolveServerConfig = (
268289 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),
269290 () => undefined,
270291 );
292 const devAuthToken =
293 mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined;
271294 const explicitBaseDir = resolveOptionPrecedence(
272295 normalizedFlags.baseDir,
273296 Option.fromUndefinedOr(env.t3Home),
......@@ -373,6 +396,7 @@ export const resolveServerConfig = (
373396 host,
374397 staticDir,
375398 devUrl,
399 ...(devAuthToken === undefined ? {} : { devAuthToken }),
376400 devAllowedOrigins: env.devAllowedOrigins,
377401 noBrowser,
378402 startupPresentation,
apps/server/src/config.ts+2
......@@ -13,6 +13,7 @@ import * as FileSystem from "effect/FileSystem";
1313import * as Layer from "effect/Layer";
1414import * as LogLevel from "effect/LogLevel";
1515import * as Path from "effect/Path";
16import type * as Redacted from "effect/Redacted";
1617import * as Schema from "effect/Schema";
1718
1819import { sweepStalePendingAttachments } from "./attachmentStore.ts";
......@@ -79,6 +80,7 @@ export class ServerConfig extends Context.Service<
7980 readonly baseDir: string;
8081 readonly staticDir: string | undefined;
8182 readonly devUrl: URL | undefined;
83 readonly devAuthToken?: Redacted.Redacted<string> | undefined;
8284 readonly devAllowedOrigins: ReadonlyArray<string>;
8385 readonly noBrowser: boolean;
8486 readonly startupPresentation: StartupPresentation;
apps/server/src/persistence/AuthSessions.ts+24-5
......@@ -107,6 +107,9 @@ export class AuthSessionRepository extends Context.Service<
107107 readonly createReplacingActive: (
108108 input: CreateReplacingActiveAuthSessionInput,
109109 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
110 readonly createIfAbsent: (
111 input: CreateAuthSessionInput,
112 ) => Effect.Effect<void, AuthSessionRepositoryError>;
110113 readonly getById: (
111114 input: GetAuthSessionByIdInput,
112115 ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>;
......@@ -204,10 +207,11 @@ function toPersistenceSqlOrDecodeError(
204207export const make = Effect.gen(function* () {
205208 const sql = yield* SqlClient.SqlClient;
206209
207 const createSessionRow = SqlSchema.void({
208 Request: CreateAuthSessionInput,
209 execute: (input) =>
210 sql`
210 const insertSessionRow = (ignoreExisting: boolean) =>
211 SqlSchema.void({
212 Request: CreateAuthSessionInput,
213 execute: (input) =>
214 sql`
211215 INSERT INTO auth_sessions (
212216 session_id,
213217 subject,
......@@ -238,8 +242,11 @@ export const make = Effect.gen(function* () {
238242 ${input.expiresAt},
239243 NULL
240244 )
245 ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``}
241246 `,
242 });
247 });
248 const createSessionRow = insertSessionRow(false);
249 const createSessionRowIfAbsent = insertSessionRow(true);
243250
244251 const getSessionRowById = SqlSchema.findOneOption({
245252 Request: GetAuthSessionByIdInput,
......@@ -393,6 +400,17 @@ export const make = Effect.gen(function* () {
393400 ),
394401 );
395402
403 const createIfAbsent: AuthSessionRepository["Service"]["createIfAbsent"] = (input) =>
404 createSessionRowIfAbsent(input).pipe(
405 Effect.mapError(
406 toPersistenceSqlOrDecodeError(
407 "AuthSessionRepository.createIfAbsent:query",
408 "AuthSessionRepository.createIfAbsent:encodeRequest",
409 { sessionId: input.sessionId },
410 ),
411 ),
412 );
413
396414 const getById: AuthSessionRepository["Service"]["getById"] = (input) =>
397415 getSessionRowById(input).pipe(
398416 Effect.mapError(
......@@ -493,6 +511,7 @@ export const make = Effect.gen(function* () {
493511 return {
494512 create,
495513 createReplacingActive,
514 createIfAbsent,
496515 getById,
497516 listActive,
498517 revoke,
apps/web/src/authBootstrap.test.ts+139
......@@ -506,6 +506,145 @@ describe("resolveInitialServerAuthGateState", () => {
506506 expect(testApi.calls.session).toBe(1);
507507 });
508508
509 it("exchanges a URL token when the browser already has a session", async () => {
510 const testApi = await installAuthApi({
511 session: () => authenticatedSession(LOOPBACK_AUTH),
512 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
513 });
514 const testWindow = installTestBrowser("http://localhost/#token=reusable-token");
515 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
516
517 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
518 status: "authenticated",
519 });
520
521 expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]);
522 expect(testWindow.location.hash).toBe("");
523 });
524
525 it("exchanges an explicit pair link after caching an authenticated state", async () => {
526 const testApi = await installAuthApi({
527 session: () => authenticatedSession(LOOPBACK_AUTH),
528 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
529 });
530 const testWindow = installTestBrowser("http://localhost/");
531 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
532
533 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
534 status: "authenticated",
535 });
536 testWindow.location = new URL("http://localhost/pair#token=reusable-token");
537
538 await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]);
539
540 expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]);
541 expect(testApi.calls.session).toBe(3);
542 });
543
544 it("makes later callers wait for a URL token that arrives during bootstrap", async () => {
545 let releaseExchange!: () => void;
546 const exchangeRelease = new Promise<void>((resolve) => {
547 releaseExchange = resolve;
548 });
549 let markExchangeStarted!: () => void;
550 const exchangeStarted = new Promise<void>((resolve) => {
551 markExchangeStarted = resolve;
552 });
553 const nextSession = sequence(
554 authenticatedSession(LOOPBACK_AUTH),
555 authenticatedSession(LOOPBACK_AUTH),
556 );
557 const testApi = await installAuthApi({
558 session: nextSession,
559 browserSession: () => {
560 markExchangeStarted();
561 return Effect.promise(() => exchangeRelease).pipe(
562 Effect.andThen(
563 Effect.fail(
564 new EnvironmentAuthInvalidError({
565 code: "auth_invalid",
566 reason: "invalid_credential",
567 traceId: "trace-rejected-queued-credential",
568 }),
569 ),
570 ),
571 );
572 },
573 });
574 const testWindow = installTestBrowser("http://localhost/");
575 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
576
577 const initialBootstrap = resolveInitialServerAuthGateState();
578 testWindow.location = new URL("http://localhost/pair#token=reusable-token");
579 const explicitPairing = resolveInitialServerAuthGateState();
580 const laterCaller = resolveInitialServerAuthGateState();
581 let laterCallerSettled = false;
582 void laterCaller.then(() => {
583 laterCallerSettled = true;
584 });
585
586 try {
587 await expect(initialBootstrap).resolves.toEqual({ status: "authenticated" });
588 await exchangeStarted;
589 expect(laterCallerSettled).toBe(false);
590 } finally {
591 releaseExchange();
592 }
593
594 const rejectedState = {
595 status: "requires-auth",
596 auth: LOOPBACK_AUTH,
597 errorMessage: "Invalid pairing token. Check the token and try again.",
598 } as const;
599 await expect(explicitPairing).resolves.toEqual(rejectedState);
600 await expect(laterCaller).resolves.toEqual(rejectedState);
601 expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]);
602 expect(testApi.calls.session).toBe(2);
603 });
604
605 it("does not exchange a token during an ordinary authenticated load", async () => {
606 const testApi = await installAuthApi({
607 session: () => authenticatedSession(LOOPBACK_AUTH),
608 browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
609 });
610 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
611
612 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
613 status: "authenticated",
614 });
615
616 expect(testApi.calls.browserSession).toEqual([]);
617 });
618
619 it("reports a rejected URL token without caching false success", async () => {
620 const cause = new EnvironmentAuthInvalidError({
621 code: "auth_invalid",
622 reason: "invalid_credential",
623 traceId: "trace-invalid-url-credential",
624 });
625 const nextSession = sequence(
626 authenticatedSession(LOOPBACK_AUTH),
627 unauthenticatedSession(LOOPBACK_AUTH),
628 );
629 const testApi = await installAuthApi({
630 session: nextSession,
631 browserSession: () => Effect.fail(cause),
632 });
633 installTestBrowser("http://localhost/#token=rejected-token");
634 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
635
636 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
637 status: "requires-auth",
638 auth: LOOPBACK_AUTH,
639 errorMessage: "Invalid pairing token. Check the token and try again.",
640 });
641 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
642 status: "requires-auth",
643 auth: LOOPBACK_AUTH,
644 });
645 expect(testApi.calls.browserSession).toEqual([{ credential: "rejected-token" }]);
646 });
647
509648 it("creates a pairing credential from the authenticated auth endpoint", async () => {
510649 const testApi = await installAuthApi({
511650 pairingCredential: (payload) =>
apps/web/src/environments/primary/auth.ts+23-10
......@@ -307,13 +307,13 @@ function isTransientBootstrapError(error: unknown): boolean {
307307 return error instanceof DOMException && error.name === "AbortError";
308308}
309309
310async function bootstrapServerAuth(): Promise<ServerAuthGateState> {
311 const bootstrapCredential = getDesktopBootstrapCredential();
310async function bootstrapServerAuth(urlCredential: string | null): Promise<ServerAuthGateState> {
312311 const currentSession = await fetchSessionState();
313 if (currentSession.authenticated) {
312 if (currentSession.authenticated && !urlCredential) {
314313 return { status: "authenticated" };
315314 }
316315
316 const bootstrapCredential = urlCredential ?? getDesktopBootstrapCredential();
317317 if (!bootstrapCredential) {
318318 return {
319319 status: "requires-auth",
......@@ -428,19 +428,32 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
428428}
429429
430430export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {
431 if (resolvedAuthenticatedGateState?.status === "authenticated") {
432 return resolvedAuthenticatedGateState;
433 }
431 const urlCredential = takePairingTokenFromUrl();
432 const previousPromise = bootstrapPromise;
433 if (urlCredential) {
434 resolvedAuthenticatedGateState = null;
435 } else {
436 if (previousPromise) {
437 return previousPromise;
438 }
434439
435 if (bootstrapPromise) {
436 return bootstrapPromise;
440 if (resolvedAuthenticatedGateState?.status === "authenticated") {
441 return resolvedAuthenticatedGateState;
442 }
437443 }
438444
439 const nextPromise = bootstrapServerAuth();
445 const nextPromise = previousPromise
446 ? previousPromise
447 .catch(() => undefined)
448 .then(() => {
449 resolvedAuthenticatedGateState = null;
450 return bootstrapServerAuth(urlCredential);
451 })
452 : bootstrapServerAuth(urlCredential);
440453 bootstrapPromise = nextPromise;
441454 return nextPromise
442455 .then((result) => {
443 if (result.status === "authenticated") {
456 if (bootstrapPromise === nextPromise && result.status === "authenticated") {
444457 resolvedAuthenticatedGateState = result;
445458 }
446459 return result;
docs/internals/environment-auth.md+19
......@@ -37,6 +37,25 @@ transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed
3737replacement must leave the old credential usable. Pairing and browser sessions
3838do not follow this replacement rule.
3939
40### Reusable dev credential
41
42Web development environments can accept one `T3CODE_DEV_AUTH_TOKEN` across
43worktrees and ports on one hostname. The token and startup URLs that contain it
44grant administrative access. Desktop and non-development servers ignore it. See
45the [development runbook](../operations/development.md#reusable-dev-credential)
46for setup.
47
48Each environment hashes the value and seeds its own database record at startup.
49Environments do not share SQLite data, signing keys, environment IDs, session
50records, pairing grants, or revocation state. Local revocation persists after
51restart and does not affect another worktree. Removing or rotating the value
52and restarting invalidates the old credential and its WebSocket tickets.
53
54Normal credentials keep precedence. A rejected normal credential never falls
55back to the reusable credential. OAuth exchanges create ordinary local bearer
56or DPoP children with normal expiry and revocation. The reusable cookie expires
57after 30 days.
58
4059## The environment is the filesystem boundary
4160
4261Projects are organizational boundaries, not filesystem sandboxes.
docs/operations/development.md+42-1
......@@ -10,7 +10,7 @@ vp i
1010vp run dev
1111```
1212
13Open the one-time pairing URL printed by the dev runner. The bare origin does not authenticate
13Open the pairing URL printed by the dev runner. The bare origin does not authenticate
1414a new browser.
1515
1616Prefer a container? See [Dev container](../internals/devcontainer.md) for VS Code and Codespaces setup.
......@@ -56,6 +56,47 @@ when changing this setup:
5656The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and
5757[Tailwind plugin](../../apps/web/vite/tailwind.ts).
5858
59#### Reusable dev credential
60
61Use this only on a hostname where you trust every service. Browsers send cookies to all ports
62on that hostname. Any service you visit there can receive the reusable admin credential,
63including services unrelated to T3 Code. If you run untrusted services on that hostname, keep
64normal per-environment pairing instead.
65
66To use one browser profile across web dev worktrees on the same hostname, generate one fixed
67value once:
68
69```sh
70openssl rand -hex 32
71```
72
73Put that value in the main checkout's gitignored `.env`:
74
75```dotenv
76T3CODE_DEV_AUTH_TOKEN=<the value generated above>
77```
78
79The `t3.json` Setup Worktree commands on Unix and Windows link that file to each worktree's
80`.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process
81environment values override `.env`, so no per-worktree export is needed after setup.
82
83For a manual worktree or launcher without that link, export the same fixed value instead:
84
85```sh
86export T3CODE_DEV_AUTH_TOKEN="<the value generated above>"
87```
88
89Do not generate a new value at startup. Start or restart `vp run dev --share` after configuration,
90then open its printed startup pairing URL once per browser profile on that hostname. Later web dev
91servers on the same hostname accept the shared cookie across ports. The cookie expires after 30
92days. Reload an old tab if its URL now serves a replacement environment.
93
94The token and startup pairing URLs are reusable administrative secrets. Never put them in a
95commit, pull request, or public output. Every server still seeds its own auth database record at
96startup and keeps its own SQLite data, signing key, and revocation state. Desktop and non-dev
97servers ignore the value. See [environment authentication](../internals/environment-auth.md#reusable-dev-credential)
98for the security model.
99
59100## Checks
60101
61102Run checks for the files and packages you changed:
packages/client-runtime/src/rpc/session.test.ts+31
......@@ -1057,6 +1057,37 @@ describe("RpcSessionFactory", () => {
10571057 ),
10581058 );
10591059
1060 it.effect("rejects a server config for a different environment", () =>
1061 Effect.gen(function* () {
1062 const { factory, sockets } = yield* makeFactory();
1063 const session = yield* factory.connect(PREPARED);
1064 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));
1065 const configFiber = yield* session
1066 .subscribeServerConfig({})
1067 .pipe(Stream.runHead, Effect.flip, Effect.forkChild);
1068 const customConfigFiber = yield* session
1069 .subscribeServerConfig({ environmentThemes: true })
1070 .pipe(Stream.runHead, Effect.flip, Effect.forkChild);
1071 const socket = yield* awaitSocket(sockets);
1072 socket.open();
1073 yield* completeInitialConfig(socket, {
1074 ...ENCODED_SERVER_CONFIG,
1075 environment: {
1076 ...ENCODED_SERVER_CONFIG.environment,
1077 environmentId: "environment-2",
1078 },
1079 });
1080
1081 const error = yield* Fiber.join(readyFiber);
1082 expect(error).toMatchObject({
1083 reason: "configuration",
1084 message: "Connected environment environment-2 does not match environment-1.",
1085 });
1086 expect((yield* Fiber.join(configFiber))._tag).toBe("RpcClientError");
1087 expect((yield* Fiber.join(customConfigFiber))._tag).toBe("RpcClientError");
1088 }),
1089 );
1090
10601091 it.effect("tolerates two missed pong windows before closing the session", () =>
10611092 Effect.gen(function* () {
10621093 const { factory, sockets } = yield* makeFactory();
packages/client-runtime/src/rpc/session.ts+32-8
......@@ -40,6 +40,7 @@ import {
4040 type ServerConfigProjection,
4141 withoutEnvironmentThemes,
4242} from "../state/serverConfigProjection.ts";
43import { environmentMismatchError } from "../connection/errors.ts";
4344
4445const SOCKET_OPEN_TIMEOUT = "15 seconds";
4546
......@@ -289,14 +290,20 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
289290 Effect.mapError(mapRpcError),
290291 Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)),
291292 ),
292 ).pipe(Effect.withSpan("environment.initialSync"));
293 ).pipe(
294 Effect.flatMap((config) =>
295 config.environment.environmentId === connection.environmentId
296 ? Effect.succeed(config)
297 : environmentMismatchError({
298 expected: connection.environmentId,
299 actual: config.environment.environmentId,
300 }),
301 ),
302 Effect.withSpan("environment.initialSync"),
303 );
293304 const serverConfigEvents = Stream.unwrap(
294305 Effect.gen(function* () {
295306 const subscription = yield* PubSub.subscribe(serverConfigUpdates);
296 yield* Effect.raceFirst(
297 Deferred.await(initialConfigDeferred).pipe(Effect.asVoid),
298 Deferred.await(serverConfigExit),
299 );
300307 const snapshot = yield* Ref.get(serverConfigState);
301308 if (Option.isNone(snapshot)) {
302309 return Stream.empty;
......@@ -336,10 +343,27 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
336343 );
337344 }),
338345 );
346 const validatedInitialConfig = initialConfig.pipe(
347 Effect.mapError(
348 (cause) =>
349 new RpcClientError.RpcClientError({
350 reason: new RpcClientError.RpcClientDefect({
351 message: `${connection.label} config subscription failed.`,
352 cause,
353 }),
354 }),
355 ),
356 );
339357 const subscribeServerConfig = (input: ServerConfigSubscriptionInput) =>
340 Equal.equals(input, serverConfigInput)
341 ? serverConfigEvents
342 : protocolClient[WS_METHODS.subscribeServerConfig](input);
358 Stream.unwrap(
359 validatedInitialConfig.pipe(
360 Effect.as(
361 Equal.equals(input, serverConfigInput)
362 ? serverConfigEvents
363 : protocolClient[WS_METHODS.subscribeServerConfig](input),
364 ),
365 ),
366 );
343367 const probe = initialConfig.pipe(
344368 Effect.flatMap((config) =>
345369 (config.environment.capabilities.connectionProbe === true
scripts/dev-runner.test.ts+21
......@@ -152,6 +152,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
152152 });
153153
154154 describe("createDevRunnerEnv", () => {
155 it.effect("forwards the reusable auth token to web dev and removes it for desktop", () =>
156 Effect.gen(function* () {
157 const input = {
158 baseEnv: { T3CODE_DEV_AUTH_TOKEN: "reusable-dev-auth-token-that-is-long-enough" },
159 serverOffset: 0,
160 webOffset: 0,
161 t3Home: undefined,
162 browser: undefined,
163 autoBootstrapProjectFromCwd: undefined,
164 logWebSocketEvents: undefined,
165 host: undefined,
166 port: undefined,
167 devUrl: undefined,
168 } as const;
169 const web = yield* createDevRunnerEnv({ ...input, mode: "dev" });
170 const desktop = yield* createDevRunnerEnv({ ...input, mode: "dev:desktop" });
171
172 assert.equal(web.T3CODE_DEV_AUTH_TOKEN, input.baseEnv.T3CODE_DEV_AUTH_TOKEN);
173 assert.equal(desktop.T3CODE_DEV_AUTH_TOKEN, undefined);
174 }),
175 );
155176 it.effect("leaves the shared home implicit and disables browser auto-open", () =>
156177 Effect.gen(function* () {
157178 const env = yield* createDevRunnerEnv({
scripts/dev-runner.ts+1
......@@ -388,6 +388,7 @@ export function createDevRunnerEnv({
388388 delete output.T3CODE_MODE;
389389 delete output.T3CODE_NO_BROWSER;
390390 delete output.T3CODE_HOST;
391 delete output.T3CODE_DEV_AUTH_TOKEN;
391392 }
392393
393394 if (!isDesktopMode && host !== undefined) {