| author | |
| committer | |
| log | c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc |
| tree | 715487357eb5d743673173cefa2bfb062e0ead70 |
| parent | 9ecfc07a8b3acadd1612665b6e24425f625480de |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | ||
| 7 | import * as ServerConfig from "../config.ts"; | 7 | import * as ServerConfig from "../config.ts"; |
| 8 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | 9 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 9 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 10 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 10 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 11 | import * 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 | ); |
| 39 | 41 | ||
| ... | @@ -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 | ); |
| 111 | 113 | ||
| 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"; |
| 30 | import * as Schema from "effect/Schema"; | 30 | import * as Schema from "effect/Schema"; |
| 31 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 31 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 32 | 32 | ||
| 33 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 33 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; | 34 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; |
| 34 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 35 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 35 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 36 | import * 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 | } |
| 564 | 565 | ||
| 566 | export 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 | |||
| 565 | export const make = Effect.gen(function* () { | 594 | export 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( |
| 1003 | 1034 | ||
| 1004 | export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); | 1035 | export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); |
| 1005 | 1036 | ||
| 1006 | export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); | 1037 | export 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"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import * as ServerConfig from "../config.ts"; | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 7 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 8 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 9 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 9 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 10 | import * 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 | ); |
| 40 | 42 |
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"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import * as ServerConfig from "../config.ts"; | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 7 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; | 8 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; |
| 8 | 9 | ||
| 9 | const makeEnvironmentAuthPolicyLayer = ( | 10 | const 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) => { |
| 107 | 109 | ||
| 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(); |
| 144 | 146 | ||
| 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"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import * as ServerConfig from "../config.ts"; | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 7 | import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; | 8 | import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; |
| 8 | 9 | ||
| 9 | export class EnvironmentAuthPolicy extends Context.Service< | 10 | export 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< |
| 15 | 16 | ||
| 16 | export const make = Effect.gen(function* () { | 17 | export 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); |
| 19 | 21 | ||
| 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as Duration from "effect/Duration"; | 4 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * 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"; |
| 7 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 8 | 9 | ||
| 9 | import * as ServerConfig from "../config.ts"; | 10 | import * as ServerConfig from "../config.ts"; |
| 11 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 10 | import { PersistenceSqlError } from "../persistence/Errors.ts"; | 12 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| 11 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | 13 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 12 | import * as AuthSessions from "../persistence/AuthSessions.ts"; | 14 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 13 | import * as SessionStore from "./SessionStore.ts"; | 15 | import * as SessionStore from "./SessionStore.ts"; |
| 14 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 16 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 15 | 17 | ||
| 16 | const makeServerConfigLayer = ( | 18 | const 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-" }))); |
| 29 | 29 | ||
| 30 | const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => | ||
| 31 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | ||
| 32 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 33 | }); | ||
| 34 | |||
| 30 | const makeSessionStoreLayer = ( | 35 | const 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 | ); |
| 38 | 45 | ||
| ... | @@ -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 | ); |
| 63 | 71 | ||
| 64 | it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | 72 | it.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"; |
| 21 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | ||
| 23 | import * as ServerConfig from "../config.ts"; | 23 | import * as ServerConfig from "../config.ts"; |
| 24 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | ||
| 24 | import * as AuthSessions from "../persistence/AuthSessions.ts"; | 25 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 25 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 26 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 26 | import { | 27 | import { |
| 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 |
| 470 | export const make = Effect.gen(function* () { | 473 | export 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); | ||
| 485 | 492 | ||
| 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* () { |
| 930 | 937 | ||
| 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 | } |
| 173 | 173 | ||
| 174 | const 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 | |||
| 174 | export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( | 191 | export 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 | }); |
| 76 | 78 | ||
| ... | @@ -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 | }); |
| 81 | 83 | ||
| 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 | }); |
| 102 | 127 | ||
| 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 token | 16 | * 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 | */ |
| 28 | export function resolveSessionCookieName(input: { | 25 | export 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 | } |
| 38 | 36 | ||
| 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 | } |
| 42 | 49 | ||
| 43 | // Cookies are scoped by host, not port. Loopback development servers need an | 50 | // 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 | } |
| 52 | 55 | ||
| 56 | export 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 | |||
| 53 | export function isRemoteReachableHost(host: string | undefined): boolean { | 66 | export 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"; |
| 15 | import * as NetService from "@t3tools/shared/Net"; | 15 | import * as NetService from "@t3tools/shared/Net"; |
| 16 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | ||
| 16 | import { assert, it } from "@effect/vitest"; | 17 | import { assert, it } from "@effect/vitest"; |
| 17 | import * as Effect from "effect/Effect"; | 18 | import * as Effect from "effect/Effect"; |
| 18 | import * as DateTime from "effect/DateTime"; | 19 | import * 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"; |
| 26 | import { Command } from "effect/unstable/cli"; | 27 | import { Command } from "effect/unstable/cli"; |
| 27 | 28 | ||
| 28 | import { cli, makeCli } from "./bin.ts"; | 29 | import { cli, makeCli } from "./bin.ts"; |
| 30 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; | ||
| 31 | import { | ||
| 32 | SERVICE_LAUNCHER_CONTEXT_ENV, | ||
| 33 | SERVICE_LAUNCHER_PROTOCOL, | ||
| 34 | } from "./cloud/serviceProtocol.ts"; | ||
| 29 | import * as ServerConfig from "./config.ts"; | 35 | import * as ServerConfig from "./config.ts"; |
| 36 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | ||
| 30 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 37 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 31 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; | 38 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 32 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | 39 | import { 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"; |
| 42 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 49 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 43 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 50 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 44 | 51 | ||
| 52 | import packageJson from "../package.json" with { type: "json" }; | ||
| 53 | |||
| 45 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 54 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 55 | const 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 | ); | ||
| 46 | class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} | 70 | class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} |
| 47 | 71 | ||
| 48 | const connectCli = makeCli({ cloudEnabled: true }); | 72 | const 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 | ); |
| 242 | 267 | ||
| 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 | ); |
| 413 | 438 | ||
| 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 | } |
| 339 | 339 | ||
| 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.HttpClient | 432 | | HttpClient.HttpClient |
| 433 | | Prompt.Environment | 433 | | Prompt.Environment |
| 434 | | ServerConfig.ServerConfig | 434 | | ServerConfig.ServerConfig |
| 435 | | ServerEnvironment.ServerEnvironment | 435 | | 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"; |
| 6 | 6 | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import * as NetService from "@t3tools/shared/Net"; | 8 | import * as NetService from "@t3tools/shared/Net"; |
| 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | ||
| 9 | import { assert, describe, expect, it } from "@effect/vitest"; | 10 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 10 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; | 12 | import * 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"; |
| 13 | import { Command } from "effect/unstable/cli"; | 14 | import { Command } from "effect/unstable/cli"; |
| 14 | 15 | ||
| 15 | import { cli } from "../bin.ts"; | 16 | import { cli } from "../bin.ts"; |
| 17 | import { | ||
| 18 | SERVICE_LAUNCHER_CONTEXT_ENV, | ||
| 19 | SERVICE_LAUNCHER_PROTOCOL, | ||
| 20 | } from "../cloud/serviceProtocol.ts"; | ||
| 21 | import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts"; | ||
| 16 | import { | 22 | import { |
| 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"; |
| 26 | 32 | ||
| 33 | import packageJson from "../../package.json" with { type: "json" }; | ||
| 34 | |||
| 27 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 35 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 28 | 36 | ||
| 29 | const baseState = { | 37 | const 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 | ); |
| 175 | 198 | ||
| 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 7 | import * 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 | }); |
| 72 | 74 | ||
| 73 | it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | 75 | it.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"; |
| 24 | export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()( | 24 | export 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") {} |
| 44 | 47 | ||
| 48 | export class ServerEnvironmentIdentity extends Context.Service< | ||
| 49 | ServerEnvironmentIdentity, | ||
| 50 | { | ||
| 51 | readonly getEnvironmentId: Effect.Effect<EnvironmentId>; | ||
| 52 | } | ||
| 53 | >()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} | ||
| 54 | |||
| 45 | function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { | 55 | function 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 | } |
| 70 | 80 | ||
| 71 | export const make = Effect.gen(function* () { | 81 | const 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; | ||
| 79 | 85 | ||
| 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 | }); |
| 109 | 115 | ||
| 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 | ); | ||
| 121 | 152 | ||
| 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 | } |
| 127 | 158 | ||
| 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 | }); |
| 132 | 174 | ||
| 133 | const environmentId = EnvironmentId.make(environmentIdRaw); | 175 | const environmentId = EnvironmentId.make(environmentIdRaw); |
| 176 | return ServerEnvironmentIdentity.of({ | ||
| 177 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 178 | }); | ||
| 179 | }); | ||
| 180 | |||
| 181 | export 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 | }); |
| 181 | 236 | ||
| 237 | export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); | ||
| 238 | |||
| 182 | /** | 239 | /** |
| 183 | * ServerEnvironment is acquired from persisted filesystem and host-process | 240 | * ServerEnvironment is acquired from persisted filesystem and host-process |
| 184 | * state. It intentionally has no fallback Layer.succeed value: callers must | 241 | * state. It intentionally has no fallback Layer.succeed value: callers must |
| 185 | * provide the external platform services, a ServerConfig, and the | 242 | * 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 | */ |
| 188 | export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); | 245 | export 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 | ); |
| 295 | 300 | ||
| 296 | const makeBrowserOtlpPayload = (spanName: string) => | 301 | const 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 | ); |
| 1676 | 1681 | ||
| 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 | ); |
| 364 | 364 | ||
| 365 | const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( | ||
| 366 | Layer.provide(ServerSecretStore.layer), | ||
| 367 | ); | ||
| 368 | |||
| 365 | const AuthLayerLive = EnvironmentAuth.layer.pipe( | 369 | const 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 | ); |
| 369 | 374 | ||
| ... | @@ -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 |
| 41 | and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile, | 41 | and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile, |
| 42 | and web all reason about the same concept. | 42 | and web all reason about the same concept. |
| 43 | 43 | ||
| 44 | Initialization publishes a complete ID atomically. Empty or whitespace-only ID files are repaired | ||
| 45 | using a retained `<stateDir>/environment-id.recovery` file so concurrent and delayed repairs choose | ||
| 46 | the same ID. Existing nonempty ID files remain authoritative. | ||
| 47 | |||
| 44 | ### Known environments and connection targets | 48 | ### Known environments and connection targets |
| 45 | 49 | ||
| 46 | A saved client-side entry for an environment the client knows how to reach. It is not | 50 | A saved client-side entry for an environment the client knows how to reach. It is not |