| author | |
| committer | |
| log | 4ee719a094d4c4f30fab88c1f6969483890e223e |
| tree | 4b9dfc31b8333e631e544b39eeee7f0c8b22a3b2 |
| parent | 01492ebd3b624e544e6375c12540b741c65256b9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>5 files changed, 180 insertions(+), 140 deletions(-)
apps/server/src/cloud/CliTokenManager.ts+71-31| ... | ... | @@ -6,7 +6,6 @@ import * as Clock from "effect/Clock"; |
| 6 | 6 | import * as Console from "effect/Console"; |
| 7 | 7 | import * as Context from "effect/Context"; |
| 8 | 8 | import * as Crypto from "effect/Crypto"; |
| 9 | import * as Data from "effect/Data"; | |
| 10 | 9 | import * as Deferred from "effect/Deferred"; |
| 11 | 10 | import * as Duration from "effect/Duration"; |
| 12 | 11 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -15,10 +14,12 @@ import * as Layer from "effect/Layer"; |
| 15 | 14 | import * as Option from "effect/Option"; |
| 16 | 15 | import * as Schema from "effect/Schema"; |
| 17 | 16 | import * as Semaphore from "effect/Semaphore"; |
| 17 | import * as HttpClient from "effect/unstable/http/HttpClient"; | |
| 18 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; | |
| 19 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | |
| 18 | 20 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; |
| 19 | 21 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 20 | 22 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 21 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 22 | 23 | |
| 23 | 24 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 24 | 25 | import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; |
| ... | ... | @@ -45,35 +46,74 @@ const OAuthTokenResponse = Schema.Struct({ |
| 45 | 46 | token_type: Schema.String, |
| 46 | 47 | }); |
| 47 | 48 | |
| 48 | export class CloudCliTokenManagerError extends Data.TaggedError("CloudCliTokenManagerError")<{ | |
| 49 | readonly message: string; | |
| 50 | readonly cause?: unknown; | |
| 51 | }> {} | |
| 49 | export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()( | |
| 50 | "CloudCliCredentialRemovalError", | |
| 51 | { cause: Schema.Defect() }, | |
| 52 | ) { | |
| 53 | override get message(): string { | |
| 54 | return "Could not remove the stored T3 Connect CLI credential."; | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass<CloudCliCredentialRefreshError>()( | |
| 59 | "CloudCliCredentialRefreshError", | |
| 60 | { cause: Schema.Defect() }, | |
| 61 | ) { | |
| 62 | override get message(): string { | |
| 63 | return "Could not refresh the T3 Connect CLI credential."; | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | export class CloudCliCredentialReadError extends Schema.TaggedErrorClass<CloudCliCredentialReadError>()( | |
| 68 | "CloudCliCredentialReadError", | |
| 69 | { cause: Schema.Defect() }, | |
| 70 | ) { | |
| 71 | override get message(): string { | |
| 72 | return "Could not read the stored T3 Connect CLI credential."; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | export class CloudCliAuthorizationError extends Schema.TaggedErrorClass<CloudCliAuthorizationError>()( | |
| 77 | "CloudCliAuthorizationError", | |
| 78 | { cause: Schema.Defect() }, | |
| 79 | ) { | |
| 80 | override get message(): string { | |
| 81 | return "Could not authorize the T3 Connect CLI."; | |
| 82 | } | |
| 83 | } | |
| 52 | 84 | |
| 53 | export interface CloudCliTokenManagerShape { | |
| 54 | readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>; | |
| 55 | readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>; | |
| 56 | readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>; | |
| 57 | readonly clear: Effect.Effect<void, CloudCliTokenManagerError>; | |
| 85 | export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass<CloudCliAuthorizationTimeoutError>()( | |
| 86 | "CloudCliAuthorizationTimeoutError", | |
| 87 | { cause: Schema.Defect() }, | |
| 88 | ) { | |
| 89 | override get message(): string { | |
| 90 | return "Timed out waiting for T3 Connect authorization."; | |
| 91 | } | |
| 58 | 92 | } |
| 59 | 93 | |
| 94 | export const CloudCliTokenManagerError = Schema.Union([ | |
| 95 | CloudCliCredentialRemovalError, | |
| 96 | CloudCliCredentialRefreshError, | |
| 97 | CloudCliCredentialReadError, | |
| 98 | CloudCliAuthorizationError, | |
| 99 | CloudCliAuthorizationTimeoutError, | |
| 100 | ]); | |
| 101 | export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; | |
| 102 | ||
| 60 | 103 | export class CloudCliTokenManager extends Context.Service< |
| 61 | 104 | CloudCliTokenManager, |
| 62 | CloudCliTokenManagerShape | |
| 105 | { | |
| 106 | readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>; | |
| 107 | readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>; | |
| 108 | readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>; | |
| 109 | readonly clear: Effect.Effect<void, CloudCliTokenManagerError>; | |
| 110 | } | |
| 63 | 111 | >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} |
| 64 | 112 | |
| 65 | 113 | const wrapError = |
| 66 | (message: string) => | |
| 67 | <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, CloudCliTokenManagerError, R> => | |
| 68 | effect.pipe( | |
| 69 | Effect.mapError( | |
| 70 | (cause) => | |
| 71 | new CloudCliTokenManagerError({ | |
| 72 | message, | |
| 73 | cause, | |
| 74 | }), | |
| 75 | ), | |
| 76 | ); | |
| 114 | <WrappedError extends CloudCliTokenManagerError>(makeError: (cause: unknown) => WrappedError) => | |
| 115 | <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, WrappedError, R> => | |
| 116 | effect.pipe(Effect.mapError(makeError)); | |
| 77 | 117 | |
| 78 | 118 | function stringToBytes(value: string): Uint8Array { |
| 79 | 119 | return new TextEncoder().encode(value); |
| ... | ... | @@ -83,7 +123,7 @@ function bytesToString(value: Uint8Array): string { |
| 83 | 123 | return new TextDecoder().decode(value); |
| 84 | 124 | } |
| 85 | 125 | |
| 86 | const make = Effect.gen(function* () { | |
| 126 | export const make = Effect.gen(function* () { | |
| 87 | 127 | const crypto = yield* Crypto.Crypto; |
| 88 | 128 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); |
| 89 | 129 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| ... | ... | @@ -96,7 +136,7 @@ const make = Effect.gen(function* () { |
| 96 | 136 | |
| 97 | 137 | const clear = secrets |
| 98 | 138 | .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) |
| 99 | .pipe(wrapError("Could not remove the stored T3 Connect CLI credential.")); | |
| 139 | .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); | |
| 100 | 140 | |
| 101 | 141 | const read = Effect.fn("cloud.cli_token.read")(function* () { |
| 102 | 142 | const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); |
| ... | ... | @@ -185,10 +225,10 @@ const make = Effect.gen(function* () { |
| 185 | 225 | yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); |
| 186 | 226 | const code = yield* Deferred.await(callback).pipe( |
| 187 | 227 | Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), |
| 188 | Effect.catchTag("TimeoutError", () => | |
| 228 | Effect.catchTag("TimeoutError", (cause) => | |
| 189 | 229 | Effect.fail( |
| 190 | new CloudCliTokenManagerError({ | |
| 191 | message: "Timed out waiting for T3 Connect authorization.", | |
| 230 | new CloudCliAuthorizationTimeoutError({ | |
| 231 | cause, | |
| 192 | 232 | }), |
| 193 | 233 | ), |
| 194 | 234 | ), |
| ... | ... | @@ -213,12 +253,12 @@ const make = Effect.gen(function* () { |
| 213 | 253 | }); |
| 214 | 254 | |
| 215 | 255 | const getExisting = semaphore.withPermits(1)( |
| 216 | getExistingNoLock().pipe(wrapError("Could not refresh the T3 Connect CLI credential.")), | |
| 256 | getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), | |
| 217 | 257 | ); |
| 218 | 258 | const hasCredential = semaphore.withPermits(1)( |
| 219 | 259 | read().pipe( |
| 220 | 260 | Effect.map(Option.isSome), |
| 221 | wrapError("Could not read the stored T3 Connect CLI credential."), | |
| 261 | wrapError((cause) => new CloudCliCredentialReadError({ cause })), | |
| 222 | 262 | ), |
| 223 | 263 | ); |
| 224 | 264 | const get = semaphore.withPermits(1)( |
| ... | ... | @@ -227,7 +267,7 @@ const make = Effect.gen(function* () { |
| 227 | 267 | return Option.isSome(token) |
| 228 | 268 | ? token.value |
| 229 | 269 | : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); |
| 230 | }).pipe(wrapError("Could not authorize the T3 Connect CLI.")), | |
| 270 | }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), | |
| 231 | 271 | ); |
| 232 | 272 | |
| 233 | 273 | return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); |
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+53-45| ... | ... | @@ -4,16 +4,15 @@ import * as Deferred from "effect/Deferred"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Fiber from "effect/Fiber"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | |
| 7 | 8 | import * as PlatformError from "effect/PlatformError"; |
| 8 | 9 | import * as Sink from "effect/Sink"; |
| 9 | 10 | import * as Stream from "effect/Stream"; |
| 10 | 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | 12 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 12 | 13 | |
| 13 | import { | |
| 14 | classifyRelayClientOutput, | |
| 15 | makeCloudManagedEndpointRuntime, | |
| 16 | } from "./ManagedEndpointRuntime.ts"; | |
| 14 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 15 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | |
| 17 | 16 | |
| 18 | 17 | const relayClientAvailableLayer = Layer.succeed( |
| 19 | 18 | RelayClient.RelayClient, |
| ... | ... | @@ -29,12 +28,33 @@ const relayClientAvailableLayer = Layer.succeed( |
| 29 | 28 | }), |
| 30 | 29 | ); |
| 31 | 30 | |
| 32 | const runtimeDependencies = (spawner: ReturnType<typeof ChildProcessSpawner.make>) => | |
| 31 | const runtimeDependencies = ( | |
| 32 | spawner: ReturnType<typeof ChildProcessSpawner.make>, | |
| 33 | relayClientLayer = relayClientAvailableLayer, | |
| 34 | ) => | |
| 33 | 35 | Layer.mergeAll( |
| 34 | 36 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 35 | relayClientAvailableLayer, | |
| 37 | relayClientLayer, | |
| 38 | Layer.mock(ServerSecretStore.ServerSecretStore)({ | |
| 39 | get: () => Effect.succeed(Option.none()), | |
| 40 | }), | |
| 36 | 41 | ); |
| 37 | 42 | |
| 43 | const buildCloudManagedEndpointRuntime = ( | |
| 44 | spawner: ReturnType<typeof ChildProcessSpawner.make>, | |
| 45 | relayClientLayer = relayClientAvailableLayer, | |
| 46 | ) => | |
| 47 | Effect.gen(function* () { | |
| 48 | const context = yield* Layer.build( | |
| 49 | ManagedEndpointRuntime.layer.pipe( | |
| 50 | Layer.provide(runtimeDependencies(spawner, relayClientLayer)), | |
| 51 | ), | |
| 52 | ); | |
| 53 | return yield* Effect.service(ManagedEndpointRuntime.CloudManagedEndpointRuntime).pipe( | |
| 54 | Effect.provide(context), | |
| 55 | ); | |
| 56 | }); | |
| 57 | ||
| 38 | 58 | function makeHandle(input: { |
| 39 | 59 | readonly pid: number; |
| 40 | 60 | readonly onKill: () => void; |
| ... | ... | @@ -62,16 +82,20 @@ function makeHandle(input: { |
| 62 | 82 | describe("CloudManagedEndpointRuntime", () => { |
| 63 | 83 | it("classifies Cloudflare connection and warning output", () => { |
| 64 | 84 | expect( |
| 65 | classifyRelayClientOutput( | |
| 85 | ManagedEndpointRuntime.classifyRelayClientOutput( | |
| 66 | 86 | "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0", |
| 67 | 87 | ), |
| 68 | 88 | ).toBe("connected"); |
| 69 | 89 | expect( |
| 70 | classifyRelayClientOutput("2026-06-17T02:00:00Z ERR Failed to serve tunnel connection"), | |
| 90 | ManagedEndpointRuntime.classifyRelayClientOutput( | |
| 91 | "2026-06-17T02:00:00Z ERR Failed to serve tunnel connection", | |
| 92 | ), | |
| 71 | 93 | ).toBe("warning"); |
| 72 | expect(classifyRelayClientOutput("2026-06-17T02:00:00Z INF Starting metrics server")).toBe( | |
| 73 | "debug", | |
| 74 | ); | |
| 94 | expect( | |
| 95 | ManagedEndpointRuntime.classifyRelayClientOutput( | |
| 96 | "2026-06-17T02:00:00Z INF Starting metrics server", | |
| 97 | ), | |
| 98 | ).toBe("debug"); | |
| 75 | 99 | }); |
| 76 | 100 | |
| 77 | 101 | it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => |
| ... | ... | @@ -97,9 +121,7 @@ describe("CloudManagedEndpointRuntime", () => { |
| 97 | 121 | return handle; |
| 98 | 122 | }), |
| 99 | 123 | ); |
| 100 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 101 | Effect.provide(runtimeDependencies(spawner)), | |
| 102 | ); | |
| 124 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); | |
| 103 | 125 | |
| 104 | 126 | yield* runtime.applyConfig({ |
| 105 | 127 | providerKind: "cloudflare_tunnel", |
| ... | ... | @@ -154,9 +176,7 @@ describe("CloudManagedEndpointRuntime", () => { |
| 154 | 176 | return handle; |
| 155 | 177 | }), |
| 156 | 178 | ); |
| 157 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 158 | Effect.provide(runtimeDependencies(spawner)), | |
| 159 | ); | |
| 179 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); | |
| 160 | 180 | |
| 161 | 181 | const started = yield* runtime.applyConfig({ |
| 162 | 182 | providerKind: "cloudflare_tunnel", |
| ... | ... | @@ -193,9 +213,7 @@ describe("CloudManagedEndpointRuntime", () => { |
| 193 | 213 | return handle; |
| 194 | 214 | }), |
| 195 | 215 | ); |
| 196 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 197 | Effect.provide(runtimeDependencies(spawner)), | |
| 198 | ); | |
| 216 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); | |
| 199 | 217 | const config = { |
| 200 | 218 | providerKind: "cloudflare_tunnel" as const, |
| 201 | 219 | connectorToken: "token", |
| ... | ... | @@ -240,9 +258,7 @@ describe("CloudManagedEndpointRuntime", () => { |
| 240 | 258 | return handle; |
| 241 | 259 | }), |
| 242 | 260 | ); |
| 243 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 244 | Effect.provide(runtimeDependencies(spawner)), | |
| 245 | ); | |
| 261 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); | |
| 246 | 262 | |
| 247 | 263 | const started = yield* runtime.applyConfig({ |
| 248 | 264 | providerKind: "cloudflare_tunnel", |
| ... | ... | @@ -282,9 +298,7 @@ describe("CloudManagedEndpointRuntime", () => { |
| 282 | 298 | return handle; |
| 283 | 299 | }), |
| 284 | 300 | ); |
| 285 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 286 | Effect.provide(runtimeDependencies(spawner)), | |
| 287 | ); | |
| 301 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); | |
| 288 | 302 | |
| 289 | 303 | const first = yield* runtime |
| 290 | 304 | .applyConfig({ |
| ... | ... | @@ -322,9 +336,7 @@ describe("CloudManagedEndpointRuntime", () => { |
| 322 | 336 | }), |
| 323 | 337 | ), |
| 324 | 338 | ); |
| 325 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 326 | Effect.provide(runtimeDependencies(spawner)), | |
| 327 | ); | |
| 339 | const runtime = yield* buildCloudManagedEndpointRuntime(spawner); | |
| 328 | 340 | |
| 329 | 341 | const status = yield* runtime.applyConfig({ |
| 330 | 342 | providerKind: "cloudflare_tunnel", |
| ... | ... | @@ -344,22 +356,18 @@ describe("CloudManagedEndpointRuntime", () => { |
| 344 | 356 | Effect.gen(function* () { |
| 345 | 357 | const spawn = vi.fn(); |
| 346 | 358 | const spawner = ChildProcessSpawner.make(spawn); |
| 347 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | |
| 348 | Effect.provide( | |
| 349 | Layer.mergeAll( | |
| 350 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 351 | Layer.succeed( | |
| 352 | RelayClient.RelayClient, | |
| 353 | RelayClient.RelayClient.of({ | |
| 354 | resolve: Effect.succeed({ | |
| 355 | status: "missing", | |
| 356 | version: RelayClient.CLOUDFLARED_VERSION, | |
| 357 | }), | |
| 358 | install: Effect.die("unused"), | |
| 359 | installWithProgress: () => Effect.die("unused"), | |
| 360 | }), | |
| 361 | ), | |
| 362 | ), | |
| 359 | const runtime = yield* buildCloudManagedEndpointRuntime( | |
| 360 | spawner, | |
| 361 | Layer.succeed( | |
| 362 | RelayClient.RelayClient, | |
| 363 | RelayClient.RelayClient.of({ | |
| 364 | resolve: Effect.succeed({ | |
| 365 | status: "missing", | |
| 366 | version: RelayClient.CLOUDFLARED_VERSION, | |
| 367 | }), | |
| 368 | install: Effect.die("unused"), | |
| 369 | installWithProgress: () => Effect.die("unused"), | |
| 370 | }), | |
| 363 | 371 | ), |
| 364 | 372 | ); |
| 365 | 373 |
apps/server/src/cloud/ManagedEndpointRuntime.ts+26-31| ... | ... | @@ -10,7 +10,8 @@ import * as Result from "effect/Result"; |
| 10 | 10 | import * as Semaphore from "effect/Semaphore"; |
| 11 | 11 | import * as Scope from "effect/Scope"; |
| 12 | 12 | import * as Stream from "effect/Stream"; |
| 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 13 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | |
| 14 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 14 | 15 | |
| 15 | 16 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 16 | 17 | import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; |
| ... | ... | @@ -28,17 +29,6 @@ const readRuntimeConfig = Effect.gen(function* () { |
| 28 | 29 | return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes.value))); |
| 29 | 30 | }); |
| 30 | 31 | |
| 31 | export interface CloudManagedEndpointRuntimeShape { | |
| 32 | readonly applyConfig: ( | |
| 33 | config: RelayManagedEndpointRuntimeConfig | null, | |
| 34 | ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>; | |
| 35 | } | |
| 36 | ||
| 37 | export class CloudManagedEndpointRuntime extends Context.Service< | |
| 38 | CloudManagedEndpointRuntime, | |
| 39 | CloudManagedEndpointRuntimeShape | |
| 40 | >()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} | |
| 41 | ||
| 42 | 32 | export type CloudManagedEndpointRuntimeStatus = |
| 43 | 33 | | { |
| 44 | 34 | readonly status: "disabled"; |
| ... | ... | @@ -62,6 +52,15 @@ export type CloudManagedEndpointRuntimeStatus = |
| 62 | 52 | readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; |
| 63 | 53 | }; |
| 64 | 54 | |
| 55 | export class CloudManagedEndpointRuntime extends Context.Service< | |
| 56 | CloudManagedEndpointRuntime, | |
| 57 | { | |
| 58 | readonly applyConfig: ( | |
| 59 | config: RelayManagedEndpointRuntimeConfig | null, | |
| 60 | ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>; | |
| 61 | } | |
| 62 | >()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} | |
| 63 | ||
| 65 | 64 | interface ActiveConnector { |
| 66 | 65 | readonly child: ChildProcessSpawner.ChildProcessHandle; |
| 67 | 66 | readonly scope: Scope.Closeable; |
| ... | ... | @@ -97,13 +96,13 @@ const stopConnector = (connector: ActiveConnector | null) => |
| 97 | 96 | ) |
| 98 | 97 | : Effect.void; |
| 99 | 98 | |
| 100 | export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { | |
| 99 | export const make = Effect.gen(function* () { | |
| 101 | 100 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 102 | 101 | const relayClient = yield* RelayClient.RelayClient; |
| 103 | 102 | const activeRef = yield* Ref.make<ActiveConnector | null>(null); |
| 104 | 103 | const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null); |
| 105 | 104 | const reconcileSemaphore = yield* Semaphore.make(1); |
| 106 | let reconcileConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; | |
| 105 | let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; | |
| 107 | 106 | |
| 108 | 107 | const stopActive = Effect.gen(function* () { |
| 109 | 108 | const active = yield* Ref.getAndSet(activeRef, null); |
| ... | ... | @@ -301,24 +300,20 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { |
| 301 | 300 | ), |
| 302 | 301 | ); |
| 303 | 302 | |
| 304 | return CloudManagedEndpointRuntime.of({ | |
| 303 | const runtime = CloudManagedEndpointRuntime.of({ | |
| 305 | 304 | applyConfig, |
| 306 | 305 | }); |
| 307 | }); | |
| 308 | 306 | |
| 309 | export const layer = Layer.effect( | |
| 310 | CloudManagedEndpointRuntime, | |
| 311 | Effect.gen(function* () { | |
| 312 | const runtime = yield* makeCloudManagedEndpointRuntime; | |
| 313 | const initialConfig = yield* readRuntimeConfig.pipe( | |
| 314 | Effect.catch((cause) => | |
| 315 | Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( | |
| 316 | Effect.as(null), | |
| 317 | ), | |
| 307 | const initialConfig = yield* readRuntimeConfig.pipe( | |
| 308 | Effect.catch((cause) => | |
| 309 | Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( | |
| 310 | Effect.as(null), | |
| 318 | 311 | ), |
| 319 | ); | |
| 320 | yield* runtime.applyConfig(initialConfig); | |
| 321 | yield* Effect.addFinalizer(() => runtime.applyConfig(null)); | |
| 322 | return runtime; | |
| 323 | }), | |
| 324 | ); | |
| 312 | ), | |
| 313 | ); | |
| 314 | yield* runtime.applyConfig(initialConfig); | |
| 315 | yield* Effect.addFinalizer(() => runtime.applyConfig(null)); | |
| 316 | return runtime; | |
| 317 | }); | |
| 318 | ||
| 319 | export const layer = Layer.effect(CloudManagedEndpointRuntime, make); |
apps/server/src/cloud/http.test.ts+10-13| ... | ... | @@ -9,13 +9,10 @@ import { HttpClient, HttpServerRequest } from "effect/unstable/http"; |
| 9 | 9 | import { RelayClientTracer } from "@t3tools/shared/relayTracing"; |
| 10 | 10 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 11 | 11 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 12 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | |
| 12 | import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; | |
| 13 | 13 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 14 | 14 | import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; |
| 15 | import { | |
| 16 | CloudManagedEndpointRuntime, | |
| 17 | type CloudManagedEndpointRuntimeShape, | |
| 18 | } from "./ManagedEndpointRuntime.ts"; | |
| 15 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | |
| 19 | 16 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; |
| 20 | 17 | |
| 21 | 18 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => |
| ... | ... | @@ -32,8 +29,8 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => |
| 32 | 29 | const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); |
| 33 | 30 | |
| 34 | 31 | function makeSecretStore( |
| 35 | create: ServerSecretStore.ServerSecretStoreShape["create"], | |
| 36 | ): ServerSecretStore.ServerSecretStoreShape { | |
| 32 | create: ServerSecretStore.ServerSecretStore["Service"]["create"], | |
| 33 | ): ServerSecretStore.ServerSecretStore["Service"] { | |
| 37 | 34 | return { |
| 38 | 35 | get: unusedSecretStoreOperation, |
| 39 | 36 | set: unusedSecretStoreOperation, |
| ... | ... | @@ -151,21 +148,21 @@ describe("reconcileDesiredCloudLink", () => { |
| 151 | 148 | makeSecretStore(unusedSecretStoreOperation), |
| 152 | 149 | ), |
| 153 | 150 | Effect.provideService( |
| 154 | ServerEnvironment, | |
| 155 | ServerEnvironment.of({ | |
| 151 | ServerEnvironment.ServerEnvironment, | |
| 152 | ServerEnvironment.ServerEnvironment.of({ | |
| 156 | 153 | getEnvironmentId: unusedSecretStoreOperation(), |
| 157 | 154 | getDescriptor: unusedSecretStoreOperation(), |
| 158 | 155 | }), |
| 159 | 156 | ), |
| 160 | 157 | Effect.provideService( |
| 161 | CloudManagedEndpointRuntime, | |
| 162 | CloudManagedEndpointRuntime.of({ | |
| 158 | ManagedEndpointRuntime.CloudManagedEndpointRuntime, | |
| 159 | ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ | |
| 163 | 160 | applyConfig: unusedSecretStoreOperation, |
| 164 | } satisfies CloudManagedEndpointRuntimeShape), | |
| 161 | } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), | |
| 165 | 162 | ), |
| 166 | 163 | Effect.provideService( |
| 167 | 164 | EnvironmentAuth.EnvironmentAuth, |
| 168 | EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuthShape), | |
| 165 | EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), | |
| 169 | 166 | ), |
| 170 | 167 | Effect.provideService( |
| 171 | 168 | CliTokenManager.CloudCliTokenManager, |
apps/server/src/cloud/http.ts+20-20| ... | ... | @@ -55,14 +55,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 55 | 55 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 56 | 56 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 57 | 57 | import { requireEnvironmentScope } from "../auth/http.ts"; |
| 58 | import { | |
| 59 | ServerEnvironment, | |
| 60 | type ServerEnvironmentShape, | |
| 61 | } from "../environment/Services/ServerEnvironment.ts"; | |
| 62 | import { | |
| 63 | CloudManagedEndpointRuntime, | |
| 64 | type CloudManagedEndpointRuntimeShape, | |
| 65 | } from "./ManagedEndpointRuntime.ts"; | |
| 58 | import * as ServerEnvironment from "../environment/Services/ServerEnvironment.ts"; | |
| 59 | import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | |
| 66 | 60 | import { |
| 67 | 61 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 68 | 62 | CLOUD_LINKED_USER_ID, |
| ... | ... | @@ -103,6 +97,9 @@ const failEnvironmentCloudInternalError = |
| 103 | 97 | Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), |
| 104 | 98 | ); |
| 105 | 99 | |
| 100 | const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => | |
| 101 | failEnvironmentCloudInternalError(error.message)(error.cause); | |
| 102 | ||
| 106 | 103 | const requireRelayUrl = relayUrlConfig.pipe( |
| 107 | 104 | Effect.mapError( |
| 108 | 105 | () => |
| ... | ... | @@ -121,7 +118,7 @@ function stringToBytes(value: string): Uint8Array { |
| 121 | 118 | } |
| 122 | 119 | |
| 123 | 120 | export function consumeCloudReplayGuards(input: { |
| 124 | readonly secrets: ServerSecretStore.ServerSecretStoreShape; | |
| 121 | readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; | |
| 125 | 122 | readonly names: ReadonlyArray<string>; |
| 126 | 123 | readonly value: Uint8Array; |
| 127 | 124 | }) { |
| ... | ... | @@ -208,7 +205,7 @@ function validateRelayConfigPayload( |
| 208 | 205 | } |
| 209 | 206 | |
| 210 | 207 | function validateLinkedCloudUser(input: { |
| 211 | readonly secrets: ServerSecretStore.ServerSecretStoreShape; | |
| 208 | readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; | |
| 212 | 209 | readonly cloudUserId: string; |
| 213 | 210 | }): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | EnvironmentHttpConflictError> { |
| 214 | 211 | return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( |
| ... | ... | @@ -237,7 +234,7 @@ function validateLinkedCloudUser(input: { |
| 237 | 234 | } |
| 238 | 235 | |
| 239 | 236 | function readInstalledCloudUserId( |
| 240 | secrets: ServerSecretStore.ServerSecretStoreShape, | |
| 237 | secrets: ServerSecretStore.ServerSecretStore["Service"], | |
| 241 | 238 | ): Effect.Effect<string, EnvironmentAuth.ServerAuthInternalError> { |
| 242 | 239 | return secrets.get(CLOUD_LINKED_USER_ID).pipe( |
| 243 | 240 | Effect.mapError( |
| ... | ... | @@ -335,19 +332,19 @@ const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentH |
| 335 | 332 | const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); |
| 336 | 333 | |
| 337 | 334 | interface CloudHttpDependencies { |
| 338 | readonly secrets: ServerSecretStore.ServerSecretStoreShape; | |
| 339 | readonly environment: ServerEnvironmentShape; | |
| 340 | readonly endpointRuntime: CloudManagedEndpointRuntimeShape; | |
| 341 | readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape; | |
| 342 | readonly cliTokenManager: CliTokenManager.CloudCliTokenManagerShape; | |
| 335 | readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; | |
| 336 | readonly environment: ServerEnvironment.ServerEnvironment["Service"]; | |
| 337 | readonly endpointRuntime: ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]; | |
| 338 | readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]; | |
| 339 | readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"]; | |
| 343 | 340 | readonly httpClient: HttpClient.HttpClient; |
| 344 | 341 | } |
| 345 | 342 | |
| 346 | 343 | const cloudHttpDependencies = Effect.gen(function* () { |
| 347 | 344 | return { |
| 348 | 345 | secrets: yield* ServerSecretStore.ServerSecretStore, |
| 349 | environment: yield* ServerEnvironment, | |
| 350 | endpointRuntime: yield* CloudManagedEndpointRuntime, | |
| 346 | environment: yield* ServerEnvironment.ServerEnvironment, | |
| 347 | endpointRuntime: yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime, | |
| 351 | 348 | environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, |
| 352 | 349 | cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, |
| 353 | 350 | httpClient: yield* HttpClient.HttpClient, |
| ... | ... | @@ -595,8 +592,11 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi |
| 595 | 592 | }); |
| 596 | 593 | }, |
| 597 | 594 | Effect.catchTags({ |
| 598 | CloudCliTokenManagerError: (error) => | |
| 599 | failEnvironmentCloudInternalError(error.message)(error.cause), | |
| 595 | CloudCliCredentialRemovalError: failCloudCliTokenManagerError, | |
| 596 | CloudCliCredentialRefreshError: failCloudCliTokenManagerError, | |
| 597 | CloudCliCredentialReadError: failCloudCliTokenManagerError, | |
| 598 | CloudCliAuthorizationError: failCloudCliTokenManagerError, | |
| 599 | CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, | |
| 600 | 600 | SecretStoreError: failEnvironmentCloudInternalError( |
| 601 | 601 | "Could not persist desired T3 Connect link state.", |
| 602 | 602 | ), |