authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-01 06:07:42+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 20:07:42-07:00
logc78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc
tree715487357eb5d743673173cefa2bfb062e0ead70
parent9ecfc07a8b3acadd1612665b6e24425f625480de
signature Signed by PGP key B5690EEEBB952194

fix(server): isolate remote web session cookies (#8085)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

18 files changed, 484 insertions(+), 87 deletions(-)

apps/server/src/auth/EnvironmentAuth.test.ts+16
...@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";...@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
66
7import * as ServerConfig from "../config.ts";7import * as ServerConfig from "../config.ts";
8import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
8import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";9import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
9import * as PairingGrantStore from "./PairingGrantStore.ts";10import * as PairingGrantStore from "./PairingGrantStore.ts";
10import * as EnvironmentAuth from "./EnvironmentAuth.ts";11import * as EnvironmentAuth from "./EnvironmentAuth.ts";
...@@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[...@@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[
34 EnvironmentAuth.layer.pipe(35 EnvironmentAuth.layer.pipe(
35 Layer.provide(SqlitePersistenceMemory),36 Layer.provide(SqlitePersistenceMemory),
36 Layer.provide(ServerSecretStore.layer),37 Layer.provide(ServerSecretStore.layer),
38 Layer.provide(ServerEnvironment.identityLayer),
37 Layer.provide(makeServerConfigLayer(overrides)),39 Layer.provide(makeServerConfigLayer(overrides)),
38 );40 );
3941
...@@ -109,6 +111,20 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -109,6 +111,20 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
109 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),111 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
110 );112 );
111113
114 it.effect("prefers a bearer token over a stale legacy cookie", () =>
115 Effect.gen(function* () {
116 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
117 const sessions = yield* SessionStore.SessionStore;
118 const bearer = yield* serverAuth.issueSession();
119 const verified = yield* serverAuth.authenticateHttpRequest({
120 cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" },
121 headers: { authorization: `Bearer ${bearer.token}` },
122 } as never);
123
124 expect(verified.sessionId).toBe(bearer.sessionId);
125 }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))),
126 );
127
112 it.effect("does not exchange ordinary pairing grants for administrative access tokens", () =>128 it.effect("does not exchange ordinary pairing grants for administrative access tokens", () =>
113 Effect.gen(function* () {129 Effect.gen(function* () {
114 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;130 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
apps/server/src/auth/EnvironmentAuth.ts+42-8
...@@ -30,6 +30,7 @@ import * as Option from "effect/Option";...@@ -30,6 +30,7 @@ import * as Option from "effect/Option";
30import * as Schema from "effect/Schema";30import * as Schema from "effect/Schema";
31import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";31import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3232
33import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
33import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";34import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
34import * as PairingGrantStore from "./PairingGrantStore.ts";35import * as PairingGrantStore from "./PairingGrantStore.ts";
35import * as ServerSecretStore from "./ServerSecretStore.ts";36import * as ServerSecretStore from "./ServerSecretStore.ts";
...@@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string |...@@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string |
562 return token.length > 0 ? token : null;563 return token.length > 0 ? token : null;
563}564}
564565
566export function selectRequestCredential(
567 request: HttpServerRequest.HttpServerRequest,
568 cookieName: string,
569 legacyCookieName: string | undefined,
570) {
571 const cookieToken = request.cookies[cookieName];
572 if (cookieToken !== undefined) {
573 return { token: cookieToken, source: "cookie" } as const;
574 }
575
576 const bearerToken = parseBearerToken(request);
577 if (bearerToken !== null) {
578 return { token: bearerToken, source: "bearer" } as const;
579 }
580
581 const dpopToken = parseDpopToken(request);
582 if (dpopToken !== null) {
583 return { token: dpopToken, source: "dpop" } as const;
584 }
585
586 const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined;
587 if (legacyToken !== undefined) {
588 return { token: legacyToken, source: "legacy-cookie" } as const;
589 }
590
591 return undefined;
592}
593
565export const make = Effect.gen(function* () {594export const make = Effect.gen(function* () {
566 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;595 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
567 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;596 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
...@@ -600,17 +629,19 @@ export const make = Effect.gen(function* () {...@@ -600,17 +629,19 @@ export const make = Effect.gen(function* () {
600 const authenticateRequest = (629 const authenticateRequest = (
601 request: HttpServerRequest.HttpServerRequest,630 request: HttpServerRequest.HttpServerRequest,
602 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {631 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
603 const cookieToken = request.cookies[sessions.cookieName];632 const credential = selectRequestCredential(
604 const bearerToken = parseBearerToken(request);633 request,
605 const dpopToken = parseDpopToken(request);634 sessions.cookieName,
606 const credential = cookieToken ?? bearerToken ?? dpopToken;635 sessions.legacyCookieName,
607 if (!credential) {636 );
637 if (!credential?.token) {
608 return Effect.fail(new ServerAuthMissingCredentialError({}));638 return Effect.fail(new ServerAuthMissingCredentialError({}));
609 }639 }
610 return authenticateToken(credential).pipe(640 const dpopToken = parseDpopToken(request);
641 return authenticateToken(credential.token).pipe(
611 Effect.flatMap((session) => {642 Effect.flatMap((session) => {
612 if (session.proofKeyThumbprint) {643 if (session.proofKeyThumbprint) {
613 if (!dpopToken || dpopToken !== credential) {644 if (!dpopToken || dpopToken !== credential.token) {
614 return Effect.fail(645 return Effect.fail(
615 new ServerAuthInvalidCredentialError({646 new ServerAuthInvalidCredentialError({
616 diagnostic: "DPoP-bound access token requires DPoP authorization.",647 diagnostic: "DPoP-bound access token requires DPoP authorization.",
...@@ -1003,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe(...@@ -1003,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe(
10031034
1004export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer);1035export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer);
10051036
1006export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer));1037export const runtimeLayer = layer.pipe(
1038 Layer.provideMerge(storageLayer),
1039 Layer.provideMerge(ServerEnvironment.identityLayer),
1040);
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+2
...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
55
6import * as ServerConfig from "../config.ts";6import * as ServerConfig from "../config.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
7import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";8import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
8import * as EnvironmentAuth from "./EnvironmentAuth.ts";9import * as EnvironmentAuth from "./EnvironmentAuth.ts";
9import * as ServerSecretStore from "./ServerSecretStore.ts";10import * as ServerSecretStore from "./ServerSecretStore.ts";
...@@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = (...@@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = (
35 EnvironmentAuth.layer.pipe(36 EnvironmentAuth.layer.pipe(
36 Layer.provideMerge(ServerSecretStore.layer),37 Layer.provideMerge(ServerSecretStore.layer),
37 Layer.provideMerge(SqlitePersistenceMemory),38 Layer.provideMerge(SqlitePersistenceMemory),
39 Layer.provide(ServerEnvironment.identityLayer),
38 Layer.provide(makeServerConfigLayer(overrides)),40 Layer.provide(makeServerConfigLayer(overrides)),
39 );41 );
4042
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+4-2
...@@ -4,12 +4,14 @@ import * as Effect from "effect/Effect";...@@ -4,12 +4,14 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
55
6import * as ServerConfig from "../config.ts";6import * as ServerConfig from "../config.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
7import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";8import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
89
9const makeEnvironmentAuthPolicyLayer = (10const makeEnvironmentAuthPolicyLayer = (
10 overrides?: Partial<ServerConfig.ServerConfig["Service"]>,11 overrides?: Partial<ServerConfig.ServerConfig["Service"]>,
11) =>12) =>
12 EnvironmentAuthPolicy.layer.pipe(13 EnvironmentAuthPolicy.layer.pipe(
14 Layer.provide(ServerEnvironment.identityLayer),
13 Layer.provide(15 Layer.provide(
14 Layer.effect(16 Layer.effect(
15 ServerConfig.ServerConfig,17 ServerConfig.ServerConfig,
...@@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {...@@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
107109
108 expect(descriptor.policy).toBe("remote-reachable");110 expect(descriptor.policy).toBe("remote-reachable");
109 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);111 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
110 expect(descriptor.sessionCookieName).toBe("t3_session");112 expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/);
111 }).pipe(113 }).pipe(
112 Effect.provide(114 Effect.provide(
113 makeEnvironmentAuthPolicyLayer({115 makeEnvironmentAuthPolicyLayer({
...@@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {...@@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
143 const descriptor = yield* policy.getDescriptor();145 const descriptor = yield* policy.getDescriptor();
144146
145 expect(descriptor.policy).toBe("remote-reachable");147 expect(descriptor.policy).toBe("remote-reachable");
146 expect(descriptor.sessionCookieName).toBe("t3_session");148 expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/);
147 }).pipe(149 }).pipe(
148 Effect.provide(150 Effect.provide(
149 makeEnvironmentAuthPolicyLayer({151 makeEnvironmentAuthPolicyLayer({
apps/server/src/auth/EnvironmentAuthPolicy.ts+3
...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";...@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
55
6import * as ServerConfig from "../config.ts";6import * as ServerConfig from "../config.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
7import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts";8import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts";
89
9export class EnvironmentAuthPolicy extends Context.Service<10export class EnvironmentAuthPolicy extends Context.Service<
...@@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service<...@@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service<
1516
16export const make = Effect.gen(function* () {17export const make = Effect.gen(function* () {
17 const config = yield* ServerConfig.ServerConfig;18 const config = yield* ServerConfig.ServerConfig;
19 const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity;
18 const isRemoteReachable = isRemoteReachableHost(config.host);20 const isRemoteReachable = isRemoteReachableHost(config.host);
1921
20 const policy =22 const policy =
...@@ -42,6 +44,7 @@ export const make = Effect.gen(function* () {...@@ -42,6 +44,7 @@ export const make = Effect.gen(function* () {
42 port: config.port,44 port: config.port,
43 host: config.host,45 host: config.host,
44 instanceKey: config.stateDir,46 instanceKey: config.stateDir,
47 environmentId: yield* serverEnvironment.getEnvironmentId,
45 development: config.devUrl !== undefined,48 development: config.devUrl !== undefined,
46 }),49 }),
47 };50 };
apps/server/src/auth/SessionStore.test.ts+33-4
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentId } from "@t3tools/contracts";
2import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
...@@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock";...@@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock";
7import * as SqlClient from "effect/unstable/sql/SqlClient";8import * as SqlClient from "effect/unstable/sql/SqlClient";
89
9import * as ServerConfig from "../config.ts";10import * as ServerConfig from "../config.ts";
11import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
10import { PersistenceSqlError } from "../persistence/Errors.ts";12import { PersistenceSqlError } from "../persistence/Errors.ts";
11import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";13import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
12import * as AuthSessions from "../persistence/AuthSessions.ts";14import * as AuthSessions from "../persistence/AuthSessions.ts";
13import * as SessionStore from "./SessionStore.ts";15import * as SessionStore from "./SessionStore.ts";
14import * as ServerSecretStore from "./ServerSecretStore.ts";16import * as ServerSecretStore from "./ServerSecretStore.ts";
1517
16const makeServerConfigLayer = (18const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
17 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,
18) =>
19 Layer.effect(19 Layer.effect(
20 ServerConfig.ServerConfig,20 ServerConfig.ServerConfig,
21 Effect.gen(function* () {21 Effect.gen(function* () {
...@@ -27,12 +27,19 @@ const makeServerConfigLayer = (...@@ -27,12 +27,19 @@ const makeServerConfigLayer = (
27 }),27 }),
28 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));28 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
2929
30const makeServerEnvironmentLayer = (environmentId: EnvironmentId) =>
31 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
32 getEnvironmentId: Effect.succeed(environmentId),
33 });
34
30const makeSessionStoreLayer = (35const makeSessionStoreLayer = (
31 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,36 overrides?: Partial<ServerConfig.ServerConfig["Service"]>,
37 environmentId = EnvironmentId.make("test-environment"),
32) =>38) =>
33 SessionStore.layer.pipe(39 SessionStore.layer.pipe(
34 Layer.provide(SqlitePersistenceMemory),40 Layer.provide(SqlitePersistenceMemory),
35 Layer.provide(ServerSecretStore.layer),41 Layer.provide(ServerSecretStore.layer),
42 Layer.provide(makeServerEnvironmentLayer(environmentId)),
36 Layer.provide(makeServerConfigLayer(overrides)),43 Layer.provide(makeServerConfigLayer(overrides)),
37 );44 );
3845
...@@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect(...@@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect(
58 Layer.provide(failingSessionLookupRepositoryLayer),65 Layer.provide(failingSessionLookupRepositoryLayer),
59 Layer.provide(ServerSecretStore.layer),66 Layer.provide(ServerSecretStore.layer),
60 Layer.provide(SqlitePersistenceMemory),67 Layer.provide(SqlitePersistenceMemory),
68 Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))),
61 Layer.provide(makeServerConfigLayer()),69 Layer.provide(makeServerConfigLayer()),
62);70);
6371
64it.layer(NodeServices.layer)("SessionStore.layer", (it) => {72it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
73 it.effect("keys remote cookies by environment identity instead of state directory", () =>
74 Effect.gen(function* () {
75 const cookieName = (stateDir: string, environmentId: EnvironmentId) =>
76 Effect.gen(function* () {
77 const sessions = yield* SessionStore.SessionStore;
78 return sessions.cookieName;
79 }).pipe(
80 Effect.provide(
81 makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId),
82 ),
83 );
84
85 const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one"));
86 const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one"));
87 const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two"));
88
89 expect(moved).toBe(original);
90 expect(other).not.toBe(original);
91 }),
92 );
93
65 it.effect("issues and verifies signed browser session tokens", () =>94 it.effect("issues and verifies signed browser session tokens", () =>
66 Effect.gen(function* () {95 Effect.gen(function* () {
67 const sessions = yield* SessionStore.SessionStore;96 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+10-2
...@@ -21,11 +21,13 @@ import * as Stream from "effect/Stream";...@@ -21,11 +21,13 @@ import * as Stream from "effect/Stream";
21import * as Option from "effect/Option";21import * as Option from "effect/Option";
2222
23import * as ServerConfig from "../config.ts";23import * as ServerConfig from "../config.ts";
24import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
24import * as AuthSessions from "../persistence/AuthSessions.ts";25import * as AuthSessions from "../persistence/AuthSessions.ts";
25import * as ServerSecretStore from "./ServerSecretStore.ts";26import * as ServerSecretStore from "./ServerSecretStore.ts";
26import {27import {
27 base64UrlDecodeUtf8,28 base64UrlDecodeUtf8,
28 base64UrlEncode,29 base64UrlEncode,
30 resolveLegacySessionCookieName,
29 resolveSessionCookieName,31 resolveSessionCookieName,
30 signPayload,32 signPayload,
31 timingSafeEqualBase64Url,33 timingSafeEqualBase64Url,
...@@ -360,6 +362,7 @@ export class SessionStore extends Context.Service<...@@ -360,6 +362,7 @@ export class SessionStore extends Context.Service<
360 SessionStore,362 SessionStore,
361 {363 {
362 readonly cookieName: string;364 readonly cookieName: string;
365 readonly legacyCookieName: string | undefined;
363 readonly issue: (input?: {366 readonly issue: (input?: {
364 readonly ttl?: Duration.Duration;367 readonly ttl?: Duration.Duration;
365 readonly subject?: string;368 readonly subject?: string;
...@@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli...@@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli
470export const make = Effect.gen(function* () {473export const make = Effect.gen(function* () {
471 const crypto = yield* Crypto.Crypto;474 const crypto = yield* Crypto.Crypto;
472 const serverConfig = yield* ServerConfig.ServerConfig;475 const serverConfig = yield* ServerConfig.ServerConfig;
476 const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity;
473 const secretStore = yield* ServerSecretStore.ServerSecretStore;477 const secretStore = yield* ServerSecretStore.ServerSecretStore;
474 const authSessions = yield* AuthSessions.AuthSessionRepository;478 const authSessions = yield* AuthSessions.AuthSessionRepository;
475 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);479 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
476 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());480 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
477 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();481 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
478 const cookieName = resolveSessionCookieName({482 const cookieInput = {
479 mode: serverConfig.mode,483 mode: serverConfig.mode,
480 port: serverConfig.port,484 port: serverConfig.port,
481 host: serverConfig.host,485 host: serverConfig.host,
482 instanceKey: serverConfig.stateDir,486 instanceKey: serverConfig.stateDir,
487 environmentId: yield* serverEnvironment.getEnvironmentId,
483 development: serverConfig.devUrl !== undefined,488 development: serverConfig.devUrl !== undefined,
484 });489 } as const;
490 const cookieName = resolveSessionCookieName(cookieInput);
491 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);
485492
486 const emitUpsert = (clientSession: AuthClientSession) =>493 const emitUpsert = (clientSession: AuthClientSession) =>
487 PubSub.publish(changesPubSub, {494 PubSub.publish(changesPubSub, {
...@@ -930,6 +937,7 @@ export const make = Effect.gen(function* () {...@@ -930,6 +937,7 @@ export const make = Effect.gen(function* () {
930937
931 return SessionStore.of({938 return SessionStore.of({
932 cookieName,939 cookieName,
940 legacyCookieName,
933 issue,941 issue,
934 verify,942 verify,
935 issueWebSocketToken,943 issueWebSocketToken,
apps/server/src/auth/http.ts+37-12
...@@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason,...@@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason,
171 });171 });
172}172}
173173
174const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) =>
175 Effect.fromResult(
176 Cookies.set(Cookies.empty, cookieName, token, {
177 expires: DateTime.toDate(expiresAt),
178 httpOnly: true,
179 path: "/",
180 sameSite: "lax",
181 }),
182 ).pipe(
183 Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")),
184 Effect.flatMap((cookies) =>
185 HttpEffect.appendPreResponseHandler((_request, response) =>
186 Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)),
187 ),
188 ),
189 );
190
174export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* (191export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* (
175 scope: AuthEnvironmentScope,192 scope: AuthEnvironmentScope,
176) {193) {
...@@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group(...@@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group(
224 function* (args) {241 function* (args) {
225 yield* annotateEnvironmentRequest(args.endpoint.name);242 yield* annotateEnvironmentRequest(args.endpoint.name);
226 const request = yield* HttpServerRequest.HttpServerRequest;243 const request = yield* HttpServerRequest.HttpServerRequest;
227 return yield* serverAuth.getSessionState(request);244 const result = yield* serverAuth.getSessionState(request);
245 const credential = EnvironmentAuth.selectRequestCredential(
246 request,
247 sessions.cookieName,
248 sessions.legacyCookieName,
249 );
250 if (
251 credential?.source === "legacy-cookie" &&
252 result.authenticated &&
253 result.sessionMethod === "browser-session-cookie" &&
254 result.expiresAt
255 ) {
256 yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt);
257 yield* appendCredentialResponseHeaders;
258 }
259 return result;
228 },260 },
229 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>261 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
230 failEnvironmentInternal("internal_error", error),262 failEnvironmentInternal("internal_error", error),
...@@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(...@@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
241 args.payload.credential,273 args.payload.credential,
242 deriveAuthClientMetadata({ request }),274 deriveAuthClientMetadata({ request }),
243 );275 );
244 const sessionCookies = yield* Effect.fromResult(276 yield* appendSessionCookie(
245 Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {277 sessions.cookieName,
246 expires: DateTime.toDate(result.response.expiresAt),278 result.sessionToken,
247 httpOnly: true,279 result.response.expiresAt,
248 path: "/",
249 sameSite: "lax",
250 }),
251 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));
252
253 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
254 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
255 );280 );
256 yield* appendCredentialResponseHeaders;281 yield* appendCredentialResponseHeaders;
257 return result.response;282 return result.response;
apps/server/src/auth/utils.test.ts+46-19
...@@ -64,6 +64,7 @@ describe("session cookie isolation", () => {...@@ -64,6 +64,7 @@ describe("session cookie isolation", () => {
64 port: 5775,64 port: 5775,
65 host: "127.0.0.1",65 host: "127.0.0.1",
66 instanceKey: "/tmp/t3-agent-one",66 instanceKey: "/tmp/t3-agent-one",
67 environmentId: "environment-one",
67 development: true,68 development: true,
68 });69 });
69 const second = resolveSessionCookieName({70 const second = resolveSessionCookieName({
...@@ -71,6 +72,7 @@ describe("session cookie isolation", () => {...@@ -71,6 +72,7 @@ describe("session cookie isolation", () => {
71 port: 5775,72 port: 5775,
72 host: "127.0.0.1",73 host: "127.0.0.1",
73 instanceKey: "/tmp/t3-agent-two",74 instanceKey: "/tmp/t3-agent-two",
75 environmentId: "environment-two",
74 development: true,76 development: true,
75 });77 });
7678
...@@ -79,25 +81,48 @@ describe("session cookie isolation", () => {...@@ -79,25 +81,48 @@ describe("session cookie isolation", () => {
79 expect(first).not.toBe(second);81 expect(first).not.toBe(second);
80 });82 });
8183
82 it("keeps the hosted web cookie stable across server instances", () => {84 it("isolates remote web servers by server state", () => {
83 expect(85 const first = resolveSessionCookieName({
84 resolveSessionCookieName({86 mode: "web",
85 mode: "web",87 port: 3773,
86 port: 8080,88 host: "192.168.1.50",
87 host: "0.0.0.0",89 instanceKey: "/srv/t3-one",
88 instanceKey: "/srv/release-a",90 environmentId: "environment-one",
89 development: false,91 development: false,
90 }),92 });
91 ).toBe("t3_session");93 const second = resolveSessionCookieName({
92 expect(94 mode: "web",
93 resolveSessionCookieName({95 port: 5775,
94 mode: "web",96 host: "192.168.1.50",
95 port: 9090,97 instanceKey: "/srv/t3-two",
96 host: "app.example.com",98 environmentId: "environment-two",
97 instanceKey: "/srv/release-b",99 development: false,
98 development: false,100 });
99 }),101
100 ).toBe("t3_session");102 expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/);
103 expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/);
104 expect(first).not.toBe(second);
105 });
106
107 it("keeps a remote web server cookie stable across port changes", () => {
108 const first = resolveSessionCookieName({
109 mode: "web",
110 port: 8080,
111 host: "0.0.0.0",
112 instanceKey: "/srv/t3",
113 environmentId: "environment-one",
114 development: false,
115 });
116 const second = resolveSessionCookieName({
117 mode: "web",
118 port: 9090,
119 host: "app.example.com",
120 instanceKey: "/srv/t3",
121 environmentId: "environment-one",
122 development: false,
123 });
124
125 expect(first).toBe(second);
101 });126 });
102127
103 it("retains desktop port scoping", () => {128 it("retains desktop port scoping", () => {
...@@ -107,6 +132,7 @@ describe("session cookie isolation", () => {...@@ -107,6 +132,7 @@ describe("session cookie isolation", () => {
107 port: 3773,132 port: 3773,
108 host: "127.0.0.1",133 host: "127.0.0.1",
109 instanceKey: "/tmp/desktop",134 instanceKey: "/tmp/desktop",
135 environmentId: "environment-one",
110 development: true,136 development: true,
111 }),137 }),
112 ).toBe("t3_session_3773");138 ).toBe("t3_session_3773");
...@@ -119,6 +145,7 @@ describe("session cookie isolation", () => {...@@ -119,6 +145,7 @@ describe("session cookie isolation", () => {
119 port: 5775,145 port: 5775,
120 host: "0.0.0.0",146 host: "0.0.0.0",
121 instanceKey: "/tmp/t3-wildcard-dev",147 instanceKey: "/tmp/t3-wildcard-dev",
148 environmentId: "environment-one",
122 development: true,149 development: true,
123 }),150 }),
124 ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);151 ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/);
apps/server/src/auth/utils.ts+24-11
...@@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session";...@@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session";
16 * clobbers the first's session and both sides see "Invalid session token16 * clobbers the first's session and both sides see "Invalid session token
17 * signature" until someone clears cookies by hand.17 * signature" until someone clears cookies by hand.
18 *18 *
19 * Two populations qualify, for the same reason but from different causes:19 * Remote web servers use their persisted environment identity and omit the
20 * port, so the name survives state-directory moves and public port changes.
20 *21 *
21 * - **Dev servers** (`devUrl` set), which run several at a time across worktrees.22 * Desktop scans upward from 3773 for a free port and binds
22 * - **Desktop**, which scans upward from 3773 for a free port and binds
23 * 127.0.0.1, so a second instance lands on a different port and the same host.23 * 127.0.0.1, so a second instance lands on a different port and the same host.
24 *
25 * Hosted deployments keep the stable production name: their public port can
26 * change between releases, and scoping it would log every user out.
27 */24 */
28export function resolveSessionCookieName(input: {25export function resolveSessionCookieName(input: {
29 readonly mode: "web" | "desktop";26 readonly mode: "web" | "desktop";
30 readonly port: number;27 readonly port: number;
31 readonly host: string | undefined;28 readonly host: string | undefined;
32 readonly instanceKey: string;29 readonly instanceKey: string;
30 readonly environmentId: string;
33 readonly development: boolean;31 readonly development: boolean;
34}): string {32}): string {
35 if (input.mode === "desktop") {33 if (input.mode === "desktop") {
36 return `${SESSION_COOKIE_NAME}_${input.port}`;34 return `${SESSION_COOKIE_NAME}_${input.port}`;
37 }35 }
3836
37 const instanceHash = NodeCrypto.createHash("sha256")
38 .update(
39 !input.development && isRemoteReachableHost(input.host)
40 ? input.environmentId
41 : input.instanceKey,
42 )
43 .digest("hex")
44 .slice(0, 12);
45
39 if (!input.development && isRemoteReachableHost(input.host)) {46 if (!input.development && isRemoteReachableHost(input.host)) {
40 return SESSION_COOKIE_NAME;47 return `${SESSION_COOKIE_NAME}_${instanceHash}`;
41 }48 }
4249
43 // Cookies are scoped by host, not port. Loopback development servers need an50 // Cookies are scoped by host, not port. Loopback development servers need an
44 // instance-specific name or parallel agents overwrite each other's session,51 // instance-specific name or parallel agents overwrite each other's session,
45 // and a server that later reuses the port receives a token signed elsewhere.52 // and a server that later reuses the port receives a token signed elsewhere.
46 const instanceHash = NodeCrypto.createHash("sha256")
47 .update(input.instanceKey)
48 .digest("hex")
49 .slice(0, 12);
50 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;53 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
51}54}
5255
56export function resolveLegacySessionCookieName(input: {
57 readonly mode: "web" | "desktop";
58 readonly host: string | undefined;
59 readonly development: boolean;
60}): string | undefined {
61 return input.mode === "web" && !input.development && isRemoteReachableHost(input.host)
62 ? SESSION_COOKIE_NAME
63 : undefined;
64}
65
53export function isRemoteReachableHost(host: string | undefined): boolean {66export function isRemoteReachableHost(host: string | undefined): boolean {
54 if (host === "0.0.0.0" || host === "::" || host === "[::]") {67 if (host === "0.0.0.0" || host === "::" || host === "[::]") {
55 return true;68 return true;
apps/server/src/bin.test.ts+27-2
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 ThreadId,13 ThreadId,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import * as NetService from "@t3tools/shared/Net";15import * as NetService from "@t3tools/shared/Net";
16import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
16import { assert, it } from "@effect/vitest";17import { assert, it } from "@effect/vitest";
17import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
18import * as DateTime from "effect/DateTime";19import * as DateTime from "effect/DateTime";
...@@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole";...@@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole";
26import { Command } from "effect/unstable/cli";27import { Command } from "effect/unstable/cli";
2728
28import { cli, makeCli } from "./bin.ts";29import { cli, makeCli } from "./bin.ts";
30import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
31import {
32 SERVICE_LAUNCHER_CONTEXT_ENV,
33 SERVICE_LAUNCHER_PROTOCOL,
34} from "./cloud/serviceProtocol.ts";
29import * as ServerConfig from "./config.ts";35import * as ServerConfig from "./config.ts";
36import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
30import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";37import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
31import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";38import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
32import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";39import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
...@@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";...@@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
42import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";49import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
43import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";50import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
4451
52import packageJson from "../package.json" with { type: "json" };
53
45const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);54const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
55const DisconnectedLauncherChildLayer = Layer.mergeAll(
56 Layer.succeed(HostProcessEnvironment, {
57 ...process.env,
58 [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({
59 protocol: SERVICE_LAUNCHER_PROTOCOL,
60 childVersion: packageJson.version,
61 }),
62 }),
63 Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, {
64 connected: false,
65 send: () => false,
66 on: () => undefined,
67 off: () => undefined,
68 }),
69);
46class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}70class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
4771
48const connectCli = makeCli({ cloudEnabled: true });72const connectCli = makeCli({ cloudEnabled: true });
...@@ -127,6 +151,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef...@@ -127,6 +151,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
127 Layer.provideMerge(151 Layer.provideMerge(
128 EnvironmentAuth.layer.pipe(152 EnvironmentAuth.layer.pipe(
129 Layer.provideMerge(SqlitePersistenceLayerLive),153 Layer.provideMerge(SqlitePersistenceLayerLive),
154 Layer.provide(ServerEnvironment.identityLayer),
130 Layer.provide(ServerSecretStore.layer),155 Layer.provide(ServerSecretStore.layer),
131 ),156 ),
132 ),157 ),
...@@ -237,7 +262,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -237,7 +262,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
237 assert.equal(status.linked, false);262 assert.equal(status.linked, false);
238 assert.equal(status.cloudUserId, null);263 assert.equal(status.cloudUserId, null);
239 assert.equal(status.relayUrl, null);264 assert.equal(status.relayUrl, null);
240 }),265 }).pipe(Effect.provide(DisconnectedLauncherChildLayer)),
241 );266 );
242267
243 it.effect("reports actionable human-readable headless connect state", () =>268 it.effect("reports actionable human-readable headless connect state", () =>
...@@ -408,7 +433,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {...@@ -408,7 +433,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
408 "relay:write",433 "relay:write",
409 ]);434 ]);
410 assert.equal("token" in (listed[0] ?? {}), false);435 assert.equal("token" in (listed[0] ?? {}), false);
411 }),436 }).pipe(Effect.provide(DisconnectedLauncherChildLayer)),
412 );437 );
413438
414 it.effect("rejects invalid ttl values before running auth commands", () =>439 it.effect("rejects invalid ttl values before running auth commands", () =>
apps/server/src/cli/connect.ts+2-3
...@@ -337,7 +337,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f...@@ -337,7 +337,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f
337 return { status: "not-authenticated" } satisfies RelayUnlinkResult;337 return { status: "not-authenticated" } satisfies RelayUnlinkResult;
338 }338 }
339339
340 const environment = yield* ServerEnvironment.ServerEnvironment;340 const environment = yield* ServerEnvironment.ServerEnvironmentIdentity;
341 const environmentId = yield* environment.getEnvironmentId;341 const environmentId = yield* environment.getEnvironmentId;
342 const relayUrl = yield* relayUrlConfig;342 const relayUrl = yield* relayUrlConfig;
343 const httpClient = yield* HttpClient.HttpClient;343 const httpClient = yield* HttpClient.HttpClient;
...@@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E...@@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
432 | HttpClient.HttpClient432 | HttpClient.HttpClient
433 | Prompt.Environment433 | Prompt.Environment
434 | ServerConfig.ServerConfig434 | ServerConfig.ServerConfig
435 | ServerEnvironment.ServerEnvironment435 | ServerEnvironment.ServerEnvironmentIdentity
436 >,436 >,
437 options?: {437 options?: {
438 readonly quietLogs?: boolean;438 readonly quietLogs?: boolean;
...@@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E...@@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
449 ),449 ),
450 RelayClient.layerCloudflared({ baseDir: config.baseDir }),450 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
451 EnvironmentAuth.runtimeLayer,451 EnvironmentAuth.runtimeLayer,
452 ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
453 bootServiceLayer(config),452 bootServiceLayer(config),
454 headlessRelayClientTracingLayer,453 headlessRelayClientTracingLayer,
455 ).pipe(454 ).pipe(
apps/server/src/cli/pair.test.ts+24-1
...@@ -6,6 +6,7 @@ import * as NodePath from "node:path";...@@ -6,6 +6,7 @@ import * as NodePath from "node:path";
66
7import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as NetService from "@t3tools/shared/Net";8import * as NetService from "@t3tools/shared/Net";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
9import { assert, describe, expect, it } from "@effect/vitest";10import { assert, describe, expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
...@@ -13,6 +14,11 @@ import * as TestConsole from "effect/testing/TestConsole";...@@ -13,6 +14,11 @@ import * as TestConsole from "effect/testing/TestConsole";
13import { Command } from "effect/unstable/cli";14import { Command } from "effect/unstable/cli";
1415
15import { cli } from "../bin.ts";16import { cli } from "../bin.ts";
17import {
18 SERVICE_LAUNCHER_CONTEXT_ENV,
19 SERVICE_LAUNCHER_PROTOCOL,
20} from "../cloud/serviceProtocol.ts";
21import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts";
16import {22import {
17 makePersistedServerRuntimeState,23 makePersistedServerRuntimeState,
18 persistServerRuntimeState,24 persistServerRuntimeState,
...@@ -24,6 +30,8 @@ import {...@@ -24,6 +30,8 @@ import {
24 resolveTailscaleLocalTarget,30 resolveTailscaleLocalTarget,
25} from "./pair.ts";31} from "./pair.ts";
2632
33import packageJson from "../../package.json" with { type: "json" };
34
27const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);35const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
2836
29const baseState = {37const baseState = {
...@@ -170,7 +178,22 @@ describe("t3 pair", () => {...@@ -170,7 +178,22 @@ describe("t3 pair", () => {
170 assert.equal(credentials.length, 1);178 assert.equal(credentials.length, 1);
171 assert.equal(credentials[0]?.label, "t3 pair");179 assert.equal(credentials[0]?.label, "t3 pair");
172 }),180 }),
173 ).pipe(Effect.provide(NodeServices.layer)),181 ).pipe(
182 Effect.provide(NodeServices.layer),
183 Effect.provideService(HostProcessEnvironment, {
184 ...process.env,
185 [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({
186 protocol: SERVICE_LAUNCHER_PROTOCOL,
187 childVersion: packageJson.version,
188 }),
189 }),
190 Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, {
191 connected: false,
192 send: () => false,
193 on: () => undefined,
194 off: () => undefined,
195 }),
196 ),
174 );197 );
175198
176 it.effect("pairs through the recorded dev web URL for dev servers", () =>199 it.effect("pairs through the recorded dev web URL for dev servers", () =>
apps/server/src/environment/ServerEnvironment.test.ts+76-1
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import * as Crypto from "effect/Crypto";
4import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
...@@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {...@@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
71});73});
7274
73it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {75it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
76 it.effect.each([
77 { name: "missing", content: undefined },
78 { name: "empty", content: "" },
79 { name: "whitespace-only", content: " \t\n" },
80 ])("concurrent initializers recover a $name environment id file", ({ content }) =>
81 Effect.gen(function* () {
82 const fileSystem = yield* FileSystem.FileSystem;
83 const crypto = yield* Crypto.Crypto;
84 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
85 prefix: "t3-server-environment-concurrent-test-",
86 });
87 const serverConfig = yield* makeServerConfig(baseDir);
88 yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true });
89 if (content !== undefined) {
90 yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content);
91 }
92 const bothGenerated = yield* Deferred.make<void>();
93 const bothReadEmpty = yield* Deferred.make<void>();
94 const firstInitialized = yield* Deferred.make<void>();
95 let remaining = 2;
96 let emptyReads = 0;
97 const readIdentity = Effect.gen(function* () {
98 const identity = yield* ServerEnvironment.ServerEnvironmentIdentity;
99 return yield* identity.getEnvironmentId;
100 }).pipe(
101 Effect.tap(() => Deferred.succeed(firstInitialized, undefined)),
102 Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)),
103 Effect.provideService(ServerConfig.ServerConfig, serverConfig),
104 Effect.provideService(FileSystem.FileSystem, {
105 ...fileSystem,
106 readFileString: (path) =>
107 fileSystem.readFileString(path).pipe(
108 Effect.tap(
109 Effect.fn(function* (value) {
110 if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) {
111 return;
112 }
113 // Both observe the empty file, but one repairs it after the other has finished.
114 if (++emptyReads === 2) {
115 yield* Deferred.succeed(bothReadEmpty, undefined);
116 yield* Deferred.await(firstInitialized);
117 } else {
118 yield* Deferred.await(bothReadEmpty);
119 }
120 }),
121 ),
122 ),
123 }),
124 Effect.provideService(Crypto.Crypto, {
125 ...crypto,
126 randomUUIDv4: Effect.gen(function* () {
127 const id = yield* crypto.randomUUIDv4;
128 if (--remaining === 0) {
129 yield* Deferred.succeed(bothGenerated, undefined);
130 }
131 yield* Deferred.await(bothGenerated);
132 return id;
133 }),
134 }),
135 );
136
137 const [first, second] = yield* Effect.all([readIdentity, readIdentity], {
138 concurrency: "unbounded",
139 });
140 const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath);
141
142 expect(first).toBe(second);
143 expect(persisted.trim()).toBe(first);
144 }),
145 );
146
74 it.effect("persists the environment id across service restarts", () =>147 it.effect("persists the environment id across service restarts", () =>
75 Effect.gen(function* () {148 Effect.gen(function* () {
76 const fileSystem = yield* FileSystem.FileSystem;149 const fileSystem = yield* FileSystem.FileSystem;
...@@ -153,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -153,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
153 });226 });
154 const serverConfig = yield* makeServerConfig(baseDir);227 const serverConfig = yield* makeServerConfig(baseDir);
155 const environmentIdPath = serverConfig.environmentIdPath;228 const environmentIdPath = serverConfig.environmentIdPath;
229 const tempPath = `${environmentIdPath}.tmp`;
156 const methodByOperation = {230 const methodByOperation = {
157 check: "exists",231 check: "exists",
158 read: "readFileString",232 read: "readFileString",
...@@ -172,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -172,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
172 exists: () =>246 exists: () =>
173 operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"),247 operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"),
174 readFileString: () => Effect.fail(cause),248 readFileString: () => Effect.fail(cause),
249 makeTempFileScoped: () => Effect.succeed(tempPath),
175 writeFileString: (path) => {250 writeFileString: (path) => {
176 writeAttempts.push(path);251 writeAttempts.push(path);
177 return Effect.fail(cause);252 return Effect.fail(cause);
...@@ -201,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {...@@ -201,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
201 expect(error.message).toBe(276 expect(error.message).toBe(
202 `Server environment ID ${operation} failed at '${environmentIdPath}'.`,277 `Server environment ID ${operation} failed at '${environmentIdPath}'.`,
203 );278 );
204 expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []);279 expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []);
205 }280 }
206 }),281 }),
207 );282 );
apps/server/src/environment/ServerEnvironment.ts+81-21
...@@ -24,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";...@@ -24,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
24export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()(24export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()(
25 "ServerEnvironmentIdPersistenceError",25 "ServerEnvironmentIdPersistenceError",
26 {26 {
27 operation: Schema.Literals(["check", "read", "write"]),27 operation: Schema.Literals(["check", "read", "write", "initialize"]),
28 environmentIdPath: Schema.String,28 environmentIdPath: Schema.String,
29 cause: Schema.Defect(),29 cause: Schema.optional(Schema.Defect()),
30 },30 },
31) {31) {
32 override get message(): string {32 override get message(): string {
33 if (this.operation === "initialize") {
34 return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`;
35 }
33 return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`;36 return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`;
34 }37 }
35}38}
...@@ -42,6 +45,13 @@ export class ServerEnvironment extends Context.Service<...@@ -42,6 +45,13 @@ export class ServerEnvironment extends Context.Service<
42 }45 }
43>()("t3/environment/ServerEnvironment") {}46>()("t3/environment/ServerEnvironment") {}
4447
48export class ServerEnvironmentIdentity extends Context.Service<
49 ServerEnvironmentIdentity,
50 {
51 readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
52 }
53>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {}
54
45function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] {55function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] {
46 switch (platform) {56 switch (platform) {
47 case "darwin":57 case "darwin":
...@@ -68,14 +78,10 @@ function platformArch(...@@ -68,14 +78,10 @@ function platformArch(
68 }78 }
69}79}
7080
71export const make = Effect.gen(function* () {81const makeIdentity = Effect.gen(function* () {
72 const fileSystem = yield* FileSystem.FileSystem;82 const fileSystem = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
74 const serverConfig = yield* ServerConfig.ServerConfig;83 const serverConfig = yield* ServerConfig.ServerConfig;
75 const secrets = yield* ServerSecretStore.ServerSecretStore;
76 const crypto = yield* Crypto.Crypto;84 const crypto = yield* Crypto.Crypto;
77 const hostPlatform = yield* HostProcessPlatform;
78 const hostArchitecture = yield* HostProcessArchitecture;
7985
80 const readPersistedEnvironmentId = Effect.gen(function* () {86 const readPersistedEnvironmentId = Effect.gen(function* () {
81 const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe(87 const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe(
...@@ -107,17 +113,42 @@ export const make = Effect.gen(function* () {...@@ -107,17 +113,42 @@ export const make = Effect.gen(function* () {
107 return raw.length > 0 ? raw : null;113 return raw.length > 0 ? raw : null;
108 });114 });
109115
110 const persistEnvironmentId = (value: string) =>116 const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")(
111 fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe(117 function* (value: string, mode: "create" | "recover") {
112 Effect.mapError(118 const destinationPath =
113 (cause) =>119 mode === "recover"
114 new ServerEnvironmentIdPersistenceError({120 ? `${serverConfig.environmentIdPath}.recovery`
115 operation: "write",121 : serverConfig.environmentIdPath;
116 environmentIdPath: serverConfig.environmentIdPath,122 const tempPath = yield* fileSystem.makeTempFileScoped({
117 cause,123 directory: serverConfig.stateDir,
118 }),124 prefix: ".environment-id-",
119 ),125 });
120 );126 yield* fileSystem.writeFileString(tempPath, `${value}\n`);
127 // Publish the completed file without replacing an ID created by another process.
128 yield* fileSystem
129 .link(tempPath, destinationPath)
130 .pipe(
131 Effect.catch((cause) =>
132 cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause),
133 ),
134 );
135 if (mode === "recover") {
136 // Keep the recovery ID so delayed initializers also publish the same winner.
137 yield* fileSystem.remove(tempPath);
138 yield* fileSystem.copyFile(destinationPath, tempPath);
139 yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath);
140 }
141 },
142 Effect.scoped,
143 Effect.mapError(
144 (cause) =>
145 new ServerEnvironmentIdPersistenceError({
146 operation: "write",
147 environmentIdPath: serverConfig.environmentIdPath,
148 cause,
149 }),
150 ),
151 );
121152
122 const environmentIdRaw = yield* Effect.gen(function* () {153 const environmentIdRaw = yield* Effect.gen(function* () {
123 const persisted = yield* readPersistedEnvironmentId;154 const persisted = yield* readPersistedEnvironmentId;
...@@ -126,11 +157,35 @@ export const make = Effect.gen(function* () {...@@ -126,11 +157,35 @@ export const make = Effect.gen(function* () {
126 }157 }
127158
128 const generated = yield* crypto.randomUUIDv4;159 const generated = yield* crypto.randomUUIDv4;
129 yield* persistEnvironmentId(generated);160 yield* persistEnvironmentId(generated, "create");
130 return generated;161 let winner = yield* readPersistedEnvironmentId;
162 if (winner === null) {
163 yield* persistEnvironmentId(generated, "recover");
164 winner = yield* readPersistedEnvironmentId;
165 }
166 if (winner === null) {
167 return yield* new ServerEnvironmentIdPersistenceError({
168 operation: "initialize",
169 environmentIdPath: serverConfig.environmentIdPath,
170 });
171 }
172 return winner;
131 });173 });
132174
133 const environmentId = EnvironmentId.make(environmentIdRaw);175 const environmentId = EnvironmentId.make(environmentIdRaw);
176 return ServerEnvironmentIdentity.of({
177 getEnvironmentId: Effect.succeed(environmentId),
178 });
179});
180
181export const make = Effect.gen(function* () {
182 const path = yield* Path.Path;
183 const serverConfig = yield* ServerConfig.ServerConfig;
184 const secrets = yield* ServerSecretStore.ServerSecretStore;
185 const identity = yield* ServerEnvironmentIdentity;
186 const hostPlatform = yield* HostProcessPlatform;
187 const hostArchitecture = yield* HostProcessArchitecture;
188 const environmentId = yield* identity.getEnvironmentId;
134 const cwdBaseName = path.basename(serverConfig.cwd).trim();189 const cwdBaseName = path.basename(serverConfig.cwd).trim();
135 const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });190 const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
136 const launcher = yield* resolveServiceLauncherMode();191 const launcher = yield* resolveServiceLauncherMode();
...@@ -179,10 +234,15 @@ export const make = Effect.gen(function* () {...@@ -179,10 +234,15 @@ export const make = Effect.gen(function* () {
179 });234 });
180});235});
181236
237export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity);
238
182/**239/**
183 * ServerEnvironment is acquired from persisted filesystem and host-process240 * ServerEnvironment is acquired from persisted filesystem and host-process
184 * state. It intentionally has no fallback Layer.succeed value: callers must241 * state. It intentionally has no fallback Layer.succeed value: callers must
185 * provide the external platform services, a ServerConfig, and the242 * provide the external platform services, a ServerConfig, and the
186 * ServerSecretStore backing the descriptor's publishing capability.243 * ServerSecretStore backing the descriptor's publishing capability.
187 */244 */
188export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer));245export const layer = Layer.effect(ServerEnvironment, make).pipe(
246 Layer.provideMerge(identityLayer),
247 Layer.provide(ProcessRunner.layer),
248);
apps/server/src/server.test.ts+47
...@@ -291,6 +291,11 @@ const makeAuthTestLayer = () =>...@@ -291,6 +291,11 @@ const makeAuthTestLayer = () =>
291 EnvironmentAuth.layer.pipe(291 EnvironmentAuth.layer.pipe(
292 Layer.provide(SqlitePersistenceMemory),292 Layer.provide(SqlitePersistenceMemory),
293 Layer.provide(ServerSecretStore.layer),293 Layer.provide(ServerSecretStore.layer),
294 Layer.provide(
295 Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({
296 getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),
297 }),
298 ),
294 );299 );
295300
296const makeBrowserOtlpPayload = (spanName: string) =>301const makeBrowserOtlpPayload = (spanName: string) =>
...@@ -1674,6 +1679,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1674,6 +1679,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1674 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1679 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1675 );1680 );
16761681
1682 it.effect("migrates a valid legacy remote-web session cookie", () =>
1683 Effect.gen(function* () {
1684 yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } });
1685
1686 const { cookie } = yield* bootstrapBrowserSession();
1687 const currentCookie = cookie?.split(";")[0] ?? "";
1688 const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session=");
1689 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1690 const response = yield* fetchEffect(sessionUrl, {
1691 headers: { cookie: legacyCookie },
1692 });
1693 const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response);
1694
1695 assert.equal(body.authenticated, true);
1696 assert.equal(response.headers["set-cookie"], cookie);
1697 assert.equal(response.headers["cache-control"], "no-store");
1698 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1699 );
1700
1701 it.effect.each(["cookie", "bearer"])(
1702 "does not migrate a stale legacy cookie when %s auth succeeds",
1703 (source) =>
1704 Effect.gen(function* () {
1705 yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } });
1706
1707 const { cookie } = yield* bootstrapBrowserSession();
1708 const sessionCookie = cookie?.split(";")[0] ?? "";
1709 const sessionToken = extractSessionTokenFromSetCookie(cookie ?? "");
1710 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1711 const response = yield* fetchEffect(sessionUrl, {
1712 headers:
1713 source === "cookie"
1714 ? { cookie: `${sessionCookie}; t3_session=stale` }
1715 : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" },
1716 });
1717 const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response);
1718
1719 assert.equal(body.authenticated, true);
1720 assert.isUndefined(response.headers["set-cookie"]);
1721 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1722 );
1723
1677 it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>1724 it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>
1678 Effect.gen(function* () {1725 Effect.gen(function* () {
1679 yield* buildAppUnderTest();1726 yield* buildAppUnderTest();
apps/server/src/server.ts+6-1
...@@ -362,8 +362,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(...@@ -362,8 +362,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
362 Layer.provide(T3ProjectFileLoader.layer),362 Layer.provide(T3ProjectFileLoader.layer),
363);363);
364364
365const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe(
366 Layer.provide(ServerSecretStore.layer),
367);
368
365const AuthLayerLive = EnvironmentAuth.layer.pipe(369const AuthLayerLive = EnvironmentAuth.layer.pipe(
366 Layer.provideMerge(PersistenceLayerLive),370 Layer.provideMerge(PersistenceLayerLive),
371 Layer.provide(ServerEnvironmentLayerLive),
367 Layer.provide(ServerSecretStore.layer),372 Layer.provide(ServerSecretStore.layer),
368);373);
369374
...@@ -418,7 +423,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -418,7 +423,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
418 Layer.provideMerge(WorkspaceLayerLive),423 Layer.provideMerge(WorkspaceLayerLive),
419 Layer.provideMerge(ProjectFaviconResolverLayerLive),424 Layer.provideMerge(ProjectFaviconResolverLayerLive),
420 Layer.provideMerge(RepositoryIdentityResolver.layer),425 Layer.provideMerge(RepositoryIdentityResolver.layer),
421 Layer.provideMerge(ServerEnvironment.layer),426 Layer.provideMerge(ServerEnvironmentLayerLive),
422 Layer.provideMerge(AuthLayerLive),427 Layer.provideMerge(AuthLayerLive),
423 Layer.provideMerge(ServerSecretStore.layer),428 Layer.provideMerge(ServerSecretStore.layer),
424 Layer.provideMerge(429 Layer.provideMerge(
docs/internals/remote.md+4
...@@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `<state...@@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `<state
41and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile,41and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile,
42and web all reason about the same concept.42and web all reason about the same concept.
4343
44Initialization publishes a complete ID atomically. Empty or whitespace-only ID files are repaired
45using a retained `<stateDir>/environment-id.recovery` file so concurrent and delayed repairs choose
46the same ID. Existing nonempty ID files remain authoritative.
47
44### Known environments and connection targets48### Known environments and connection targets
4549
46A saved client-side entry for an environment the client knows how to reach. It is not50A saved client-side entry for an environment the client knows how to reach. It is not