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";
55import * as Layer from "effect/Layer";
66
77import * as ServerConfig from "../config.ts";
8import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
89import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
910import * as PairingGrantStore from "./PairingGrantStore.ts";
1011import * as EnvironmentAuth from "./EnvironmentAuth.ts";
......@@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[
3435 EnvironmentAuth.layer.pipe(
3536 Layer.provide(SqlitePersistenceMemory),
3637 Layer.provide(ServerSecretStore.layer),
38 Layer.provide(ServerEnvironment.identityLayer),
3739 Layer.provide(makeServerConfigLayer(overrides)),
3840 );
3941
......@@ -109,6 +111,20 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
109111 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
110112 );
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
112128 it.effect("does not exchange ordinary pairing grants for administrative access tokens", () =>
113129 Effect.gen(function* () {
114130 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
apps/server/src/auth/EnvironmentAuth.ts+42-8
......@@ -30,6 +30,7 @@ import * as Option from "effect/Option";
3030import * as Schema from "effect/Schema";
3131import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3232
33import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
3334import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
3435import * as PairingGrantStore from "./PairingGrantStore.ts";
3536import * as ServerSecretStore from "./ServerSecretStore.ts";
......@@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string |
562563 return token.length > 0 ? token : null;
563564}
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
565594export const make = Effect.gen(function* () {
566595 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
567596 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
......@@ -600,17 +629,19 @@ export const make = Effect.gen(function* () {
600629 const authenticateRequest = (
601630 request: HttpServerRequest.HttpServerRequest,
602631 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
603 const cookieToken = request.cookies[sessions.cookieName];
604 const bearerToken = parseBearerToken(request);
605 const dpopToken = parseDpopToken(request);
606 const credential = cookieToken ?? bearerToken ?? dpopToken;
607 if (!credential) {
632 const credential = selectRequestCredential(
633 request,
634 sessions.cookieName,
635 sessions.legacyCookieName,
636 );
637 if (!credential?.token) {
608638 return Effect.fail(new ServerAuthMissingCredentialError({}));
609639 }
610 return authenticateToken(credential).pipe(
640 const dpopToken = parseDpopToken(request);
641 return authenticateToken(credential.token).pipe(
611642 Effect.flatMap((session) => {
612643 if (session.proofKeyThumbprint) {
613 if (!dpopToken || dpopToken !== credential) {
644 if (!dpopToken || dpopToken !== credential.token) {
614645 return Effect.fail(
615646 new ServerAuthInvalidCredentialError({
616647 diagnostic: "DPoP-bound access token requires DPoP authorization.",
......@@ -1003,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe(
10031034
10041035export 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";
44import * as Layer from "effect/Layer";
55
66import * as ServerConfig from "../config.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
78import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
89import * as EnvironmentAuth from "./EnvironmentAuth.ts";
910import * as ServerSecretStore from "./ServerSecretStore.ts";
......@@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = (
3536 EnvironmentAuth.layer.pipe(
3637 Layer.provideMerge(ServerSecretStore.layer),
3738 Layer.provideMerge(SqlitePersistenceMemory),
39 Layer.provide(ServerEnvironment.identityLayer),
3840 Layer.provide(makeServerConfigLayer(overrides)),
3941 );
4042
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+4-2
......@@ -4,12 +4,14 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55
66import * as ServerConfig from "../config.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
78import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
89
910const makeEnvironmentAuthPolicyLayer = (
1011 overrides?: Partial<ServerConfig.ServerConfig["Service"]>,
1112) =>
1213 EnvironmentAuthPolicy.layer.pipe(
14 Layer.provide(ServerEnvironment.identityLayer),
1315 Layer.provide(
1416 Layer.effect(
1517 ServerConfig.ServerConfig,
......@@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
107109
108110 expect(descriptor.policy).toBe("remote-reachable");
109111 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}$/);
111113 }).pipe(
112114 Effect.provide(
113115 makeEnvironmentAuthPolicyLayer({
......@@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
143145 const descriptor = yield* policy.getDescriptor();
144146
145147 expect(descriptor.policy).toBe("remote-reachable");
146 expect(descriptor.sessionCookieName).toBe("t3_session");
148 expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/);
147149 }).pipe(
148150 Effect.provide(
149151 makeEnvironmentAuthPolicyLayer({
apps/server/src/auth/EnvironmentAuthPolicy.ts+3
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55
66import * as ServerConfig from "../config.ts";
7import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
78import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts";
89
910export class EnvironmentAuthPolicy extends Context.Service<
......@@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service<
1516
1617export const make = Effect.gen(function* () {
1718 const config = yield* ServerConfig.ServerConfig;
19 const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity;
1820 const isRemoteReachable = isRemoteReachableHost(config.host);
1921
2022 const policy =
......@@ -42,6 +44,7 @@ export const make = Effect.gen(function* () {
4244 port: config.port,
4345 host: config.host,
4446 instanceKey: config.stateDir,
47 environmentId: yield* serverEnvironment.getEnvironmentId,
4548 development: config.devUrl !== undefined,
4649 }),
4750 };
apps/server/src/auth/SessionStore.test.ts+33-4
......@@ -1,4 +1,5 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentId } from "@t3tools/contracts";
23import { expect, it } from "@effect/vitest";
34import * as Duration from "effect/Duration";
45import * as Effect from "effect/Effect";
......@@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock";
78import * as SqlClient from "effect/unstable/sql/SqlClient";
89
910import * as ServerConfig from "../config.ts";
11import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
1012import { PersistenceSqlError } from "../persistence/Errors.ts";
1113import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
1214import * as AuthSessions from "../persistence/AuthSessions.ts";
1315import * as SessionStore from "./SessionStore.ts";
1416import * as ServerSecretStore from "./ServerSecretStore.ts";
1517
16const makeServerConfigLayer = (
17 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,
18) =>
18const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
1919 Layer.effect(
2020 ServerConfig.ServerConfig,
2121 Effect.gen(function* () {
......@@ -27,12 +27,19 @@ const makeServerConfigLayer = (
2727 }),
2828 ).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
3035const makeSessionStoreLayer = (
31 overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>,
36 overrides?: Partial<ServerConfig.ServerConfig["Service"]>,
37 environmentId = EnvironmentId.make("test-environment"),
3238) =>
3339 SessionStore.layer.pipe(
3440 Layer.provide(SqlitePersistenceMemory),
3541 Layer.provide(ServerSecretStore.layer),
42 Layer.provide(makeServerEnvironmentLayer(environmentId)),
3643 Layer.provide(makeServerConfigLayer(overrides)),
3744 );
3845
......@@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect(
5865 Layer.provide(failingSessionLookupRepositoryLayer),
5966 Layer.provide(ServerSecretStore.layer),
6067 Layer.provide(SqlitePersistenceMemory),
68 Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))),
6169 Layer.provide(makeServerConfigLayer()),
6270);
6371
6472it.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
6594 it.effect("issues and verifies signed browser session tokens", () =>
6695 Effect.gen(function* () {
6796 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+10-2
......@@ -21,11 +21,13 @@ import * as Stream from "effect/Stream";
2121import * as Option from "effect/Option";
2222
2323import * as ServerConfig from "../config.ts";
24import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
2425import * as AuthSessions from "../persistence/AuthSessions.ts";
2526import * as ServerSecretStore from "./ServerSecretStore.ts";
2627import {
2728 base64UrlDecodeUtf8,
2829 base64UrlEncode,
30 resolveLegacySessionCookieName,
2931 resolveSessionCookieName,
3032 signPayload,
3133 timingSafeEqualBase64Url,
......@@ -360,6 +362,7 @@ export class SessionStore extends Context.Service<
360362 SessionStore,
361363 {
362364 readonly cookieName: string;
365 readonly legacyCookieName: string | undefined;
363366 readonly issue: (input?: {
364367 readonly ttl?: Duration.Duration;
365368 readonly subject?: string;
......@@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli
470473export const make = Effect.gen(function* () {
471474 const crypto = yield* Crypto.Crypto;
472475 const serverConfig = yield* ServerConfig.ServerConfig;
476 const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity;
473477 const secretStore = yield* ServerSecretStore.ServerSecretStore;
474478 const authSessions = yield* AuthSessions.AuthSessionRepository;
475479 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
476480 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
477481 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
478 const cookieName = resolveSessionCookieName({
482 const cookieInput = {
479483 mode: serverConfig.mode,
480484 port: serverConfig.port,
481485 host: serverConfig.host,
482486 instanceKey: serverConfig.stateDir,
487 environmentId: yield* serverEnvironment.getEnvironmentId,
483488 development: serverConfig.devUrl !== undefined,
484 });
489 } as const;
490 const cookieName = resolveSessionCookieName(cookieInput);
491 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);
485492
486493 const emitUpsert = (clientSession: AuthClientSession) =>
487494 PubSub.publish(changesPubSub, {
......@@ -930,6 +937,7 @@ export const make = Effect.gen(function* () {
930937
931938 return SessionStore.of({
932939 cookieName,
940 legacyCookieName,
933941 issue,
934942 verify,
935943 issueWebSocketToken,
apps/server/src/auth/http.ts+37-12
......@@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason,
171171 });
172172}
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
174191export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* (
175192 scope: AuthEnvironmentScope,
176193) {
......@@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group(
224241 function* (args) {
225242 yield* annotateEnvironmentRequest(args.endpoint.name);
226243 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;
228260 },
229261 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
230262 failEnvironmentInternal("internal_error", error),
......@@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
241273 args.payload.credential,
242274 deriveAuthClientMetadata({ request }),
243275 );
244 const sessionCookies = yield* Effect.fromResult(
245 Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {
246 expires: DateTime.toDate(result.response.expiresAt),
247 httpOnly: true,
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)),
276 yield* appendSessionCookie(
277 sessions.cookieName,
278 result.sessionToken,
279 result.response.expiresAt,
255280 );
256281 yield* appendCredentialResponseHeaders;
257282 return result.response;
apps/server/src/auth/utils.test.ts+46-19
......@@ -64,6 +64,7 @@ describe("session cookie isolation", () => {
6464 port: 5775,
6565 host: "127.0.0.1",
6666 instanceKey: "/tmp/t3-agent-one",
67 environmentId: "environment-one",
6768 development: true,
6869 });
6970 const second = resolveSessionCookieName({
......@@ -71,6 +72,7 @@ describe("session cookie isolation", () => {
7172 port: 5775,
7273 host: "127.0.0.1",
7374 instanceKey: "/tmp/t3-agent-two",
75 environmentId: "environment-two",
7476 development: true,
7577 });
7678
......@@ -79,25 +81,48 @@ describe("session cookie isolation", () => {
7981 expect(first).not.toBe(second);
8082 });
8183
82 it("keeps the hosted web cookie stable across server instances", () => {
83 expect(
84 resolveSessionCookieName({
85 mode: "web",
86 port: 8080,
87 host: "0.0.0.0",
88 instanceKey: "/srv/release-a",
89 development: false,
90 }),
91 ).toBe("t3_session");
92 expect(
93 resolveSessionCookieName({
94 mode: "web",
95 port: 9090,
96 host: "app.example.com",
97 instanceKey: "/srv/release-b",
98 development: false,
99 }),
100 ).toBe("t3_session");
84 it("isolates remote web servers by server state", () => {
85 const first = resolveSessionCookieName({
86 mode: "web",
87 port: 3773,
88 host: "192.168.1.50",
89 instanceKey: "/srv/t3-one",
90 environmentId: "environment-one",
91 development: false,
92 });
93 const second = resolveSessionCookieName({
94 mode: "web",
95 port: 5775,
96 host: "192.168.1.50",
97 instanceKey: "/srv/t3-two",
98 environmentId: "environment-two",
99 development: false,
100 });
101
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);
101126 });
102127
103128 it("retains desktop port scoping", () => {
......@@ -107,6 +132,7 @@ describe("session cookie isolation", () => {
107132 port: 3773,
108133 host: "127.0.0.1",
109134 instanceKey: "/tmp/desktop",
135 environmentId: "environment-one",
110136 development: true,
111137 }),
112138 ).toBe("t3_session_3773");
......@@ -119,6 +145,7 @@ describe("session cookie isolation", () => {
119145 port: 5775,
120146 host: "0.0.0.0",
121147 instanceKey: "/tmp/t3-wildcard-dev",
148 environmentId: "environment-one",
122149 development: true,
123150 }),
124151 ).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";
1616 * clobbers the first's session and both sides see "Invalid session token
1717 * signature" until someone clears cookies by hand.
1818 *
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.
2021 *
21 * - **Dev servers** (`devUrl` set), which run several at a time across worktrees.
22 * - **Desktop**, which scans upward from 3773 for a free port and binds
22 * Desktop scans upward from 3773 for a free port and binds
2323 * 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.
2724 */
2825export function resolveSessionCookieName(input: {
2926 readonly mode: "web" | "desktop";
3027 readonly port: number;
3128 readonly host: string | undefined;
3229 readonly instanceKey: string;
30 readonly environmentId: string;
3331 readonly development: boolean;
3432}): string {
3533 if (input.mode === "desktop") {
3634 return `${SESSION_COOKIE_NAME}_${input.port}`;
3735 }
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
3946 if (!input.development && isRemoteReachableHost(input.host)) {
40 return SESSION_COOKIE_NAME;
47 return `${SESSION_COOKIE_NAME}_${instanceHash}`;
4148 }
4249
4350 // Cookies are scoped by host, not port. Loopback development servers need an
4451 // instance-specific name or parallel agents overwrite each other's session,
4552 // 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);
5053 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
5154}
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
5366export function isRemoteReachableHost(host: string | undefined): boolean {
5467 if (host === "0.0.0.0" || host === "::" || host === "[::]") {
5568 return true;
apps/server/src/bin.test.ts+27-2
......@@ -13,6 +13,7 @@ import {
1313 ThreadId,
1414} from "@t3tools/contracts";
1515import * as NetService from "@t3tools/shared/Net";
16import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1617import { assert, it } from "@effect/vitest";
1718import * as Effect from "effect/Effect";
1819import * as DateTime from "effect/DateTime";
......@@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole";
2627import { Command } from "effect/unstable/cli";
2728
2829import { 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";
2935import * as ServerConfig from "./config.ts";
36import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
3037import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
3138import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
3239import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
......@@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
4249import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
4350import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
4451
52import packageJson from "../package.json" with { type: "json" };
53
4554const 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);
4670class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
4771
4872const connectCli = makeCli({ cloudEnabled: true });
......@@ -127,6 +151,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
127151 Layer.provideMerge(
128152 EnvironmentAuth.layer.pipe(
129153 Layer.provideMerge(SqlitePersistenceLayerLive),
154 Layer.provide(ServerEnvironment.identityLayer),
130155 Layer.provide(ServerSecretStore.layer),
131156 ),
132157 ),
......@@ -237,7 +262,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
237262 assert.equal(status.linked, false);
238263 assert.equal(status.cloudUserId, null);
239264 assert.equal(status.relayUrl, null);
240 }),
265 }).pipe(Effect.provide(DisconnectedLauncherChildLayer)),
241266 );
242267
243268 it.effect("reports actionable human-readable headless connect state", () =>
......@@ -408,7 +433,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
408433 "relay:write",
409434 ]);
410435 assert.equal("token" in (listed[0] ?? {}), false);
411 }),
436 }).pipe(Effect.provide(DisconnectedLauncherChildLayer)),
412437 );
413438
414439 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
337337 return { status: "not-authenticated" } satisfies RelayUnlinkResult;
338338 }
339339
340 const environment = yield* ServerEnvironment.ServerEnvironment;
340 const environment = yield* ServerEnvironment.ServerEnvironmentIdentity;
341341 const environmentId = yield* environment.getEnvironmentId;
342342 const relayUrl = yield* relayUrlConfig;
343343 const httpClient = yield* HttpClient.HttpClient;
......@@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
432432 | HttpClient.HttpClient
433433 | Prompt.Environment
434434 | ServerConfig.ServerConfig
435 | ServerEnvironment.ServerEnvironment
435 | ServerEnvironment.ServerEnvironmentIdentity
436436 >,
437437 options?: {
438438 readonly quietLogs?: boolean;
......@@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
449449 ),
450450 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
451451 EnvironmentAuth.runtimeLayer,
452 ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
453452 bootServiceLayer(config),
454453 headlessRelayClientTracingLayer,
455454 ).pipe(
apps/server/src/cli/pair.test.ts+24-1
......@@ -6,6 +6,7 @@ import * as NodePath from "node:path";
66
77import * as NodeServices from "@effect/platform-node/NodeServices";
88import * as NetService from "@t3tools/shared/Net";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
910import { assert, describe, expect, it } from "@effect/vitest";
1011import * as Effect from "effect/Effect";
1112import * as Layer from "effect/Layer";
......@@ -13,6 +14,11 @@ import * as TestConsole from "effect/testing/TestConsole";
1314import { Command } from "effect/unstable/cli";
1415
1516import { 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";
1622import {
1723 makePersistedServerRuntimeState,
1824 persistServerRuntimeState,
......@@ -24,6 +30,8 @@ import {
2430 resolveTailscaleLocalTarget,
2531} from "./pair.ts";
2632
33import packageJson from "../../package.json" with { type: "json" };
34
2735const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
2836
2937const baseState = {
......@@ -170,7 +178,22 @@ describe("t3 pair", () => {
170178 assert.equal(credentials.length, 1);
171179 assert.equal(credentials[0]?.label, "t3 pair");
172180 }),
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 ),
174197 );
175198
176199 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 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "@effect/vitest";
3import * as Crypto from "effect/Crypto";
4import * as Deferred from "effect/Deferred";
35import * as Effect from "effect/Effect";
46import * as FileSystem from "effect/FileSystem";
57import * as Layer from "effect/Layer";
......@@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
7173});
7274
7375it.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
74147 it.effect("persists the environment id across service restarts", () =>
75148 Effect.gen(function* () {
76149 const fileSystem = yield* FileSystem.FileSystem;
......@@ -153,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
153226 });
154227 const serverConfig = yield* makeServerConfig(baseDir);
155228 const environmentIdPath = serverConfig.environmentIdPath;
229 const tempPath = `${environmentIdPath}.tmp`;
156230 const methodByOperation = {
157231 check: "exists",
158232 read: "readFileString",
......@@ -172,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
172246 exists: () =>
173247 operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"),
174248 readFileString: () => Effect.fail(cause),
249 makeTempFileScoped: () => Effect.succeed(tempPath),
175250 writeFileString: (path) => {
176251 writeAttempts.push(path);
177252 return Effect.fail(cause);
......@@ -201,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
201276 expect(error.message).toBe(
202277 `Server environment ID ${operation} failed at '${environmentIdPath}'.`,
203278 );
204 expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []);
279 expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []);
205280 }
206281 }),
207282 );
apps/server/src/environment/ServerEnvironment.ts+81-21
......@@ -24,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
2424export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()(
2525 "ServerEnvironmentIdPersistenceError",
2626 {
27 operation: Schema.Literals(["check", "read", "write"]),
27 operation: Schema.Literals(["check", "read", "write", "initialize"]),
2828 environmentIdPath: Schema.String,
29 cause: Schema.Defect(),
29 cause: Schema.optional(Schema.Defect()),
3030 },
3131) {
3232 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 }
3336 return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`;
3437 }
3538}
......@@ -42,6 +45,13 @@ export class ServerEnvironment extends Context.Service<
4245 }
4346>()("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
4555function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] {
4656 switch (platform) {
4757 case "darwin":
......@@ -68,14 +78,10 @@ function platformArch(
6878 }
6979}
7080
71export const make = Effect.gen(function* () {
81const makeIdentity = Effect.gen(function* () {
7282 const fileSystem = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
7483 const serverConfig = yield* ServerConfig.ServerConfig;
75 const secrets = yield* ServerSecretStore.ServerSecretStore;
7684 const crypto = yield* Crypto.Crypto;
77 const hostPlatform = yield* HostProcessPlatform;
78 const hostArchitecture = yield* HostProcessArchitecture;
7985
8086 const readPersistedEnvironmentId = Effect.gen(function* () {
8187 const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe(
......@@ -107,17 +113,42 @@ export const make = Effect.gen(function* () {
107113 return raw.length > 0 ? raw : null;
108114 });
109115
110 const persistEnvironmentId = (value: string) =>
111 fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe(
112 Effect.mapError(
113 (cause) =>
114 new ServerEnvironmentIdPersistenceError({
115 operation: "write",
116 environmentIdPath: serverConfig.environmentIdPath,
117 cause,
118 }),
119 ),
120 );
116 const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")(
117 function* (value: string, mode: "create" | "recover") {
118 const destinationPath =
119 mode === "recover"
120 ? `${serverConfig.environmentIdPath}.recovery`
121 : serverConfig.environmentIdPath;
122 const tempPath = yield* fileSystem.makeTempFileScoped({
123 directory: serverConfig.stateDir,
124 prefix: ".environment-id-",
125 });
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
122153 const environmentIdRaw = yield* Effect.gen(function* () {
123154 const persisted = yield* readPersistedEnvironmentId;
......@@ -126,11 +157,35 @@ export const make = Effect.gen(function* () {
126157 }
127158
128159 const generated = yield* crypto.randomUUIDv4;
129 yield* persistEnvironmentId(generated);
130 return generated;
160 yield* persistEnvironmentId(generated, "create");
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;
131173 });
132174
133175 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;
134189 const cwdBaseName = path.basename(serverConfig.cwd).trim();
135190 const label = yield* resolveServerEnvironmentLabel({ cwdBaseName });
136191 const launcher = yield* resolveServiceLauncherMode();
......@@ -179,10 +234,15 @@ export const make = Effect.gen(function* () {
179234 });
180235});
181236
237export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity);
238
182239/**
183240 * ServerEnvironment is acquired from persisted filesystem and host-process
184241 * state. It intentionally has no fallback Layer.succeed value: callers must
185242 * provide the external platform services, a ServerConfig, and the
186243 * ServerSecretStore backing the descriptor's publishing capability.
187244 */
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 = () =>
291291 EnvironmentAuth.layer.pipe(
292292 Layer.provide(SqlitePersistenceMemory),
293293 Layer.provide(ServerSecretStore.layer),
294 Layer.provide(
295 Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({
296 getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),
297 }),
298 ),
294299 );
295300
296301const makeBrowserOtlpPayload = (spanName: string) =>
......@@ -1674,6 +1679,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
16741679 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
16751680 );
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
16771724 it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>
16781725 Effect.gen(function* () {
16791726 yield* buildAppUnderTest();
apps/server/src/server.ts+6-1
......@@ -362,8 +362,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe(
362362 Layer.provide(T3ProjectFileLoader.layer),
363363);
364364
365const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe(
366 Layer.provide(ServerSecretStore.layer),
367);
368
365369const AuthLayerLive = EnvironmentAuth.layer.pipe(
366370 Layer.provideMerge(PersistenceLayerLive),
371 Layer.provide(ServerEnvironmentLayerLive),
367372 Layer.provide(ServerSecretStore.layer),
368373);
369374
......@@ -418,7 +423,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
418423 Layer.provideMerge(WorkspaceLayerLive),
419424 Layer.provideMerge(ProjectFaviconResolverLayerLive),
420425 Layer.provideMerge(RepositoryIdentityResolver.layer),
421 Layer.provideMerge(ServerEnvironment.layer),
426 Layer.provideMerge(ServerEnvironmentLayerLive),
422427 Layer.provideMerge(AuthLayerLive),
423428 Layer.provideMerge(ServerSecretStore.layer),
424429 Layer.provideMerge(
docs/internals/remote.md+4
......@@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `<state
4141and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile,
4242and 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
4448### Known environments and connection targets
4549
4650A saved client-side entry for an environment the client knows how to reach. It is not