1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
14import * as Stream from "effect/Stream";
15import { HttpClient, HttpClientResponse, HttpServer, type HttpClientRequest } from "effect/http";
16import * as NetAddress from "effect/net/NetAddress";
17
18import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts";
19import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
20import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
21import * as ServerConfigModule from "../config.ts";
22import { writeServiceState } from "../serviceLauncher.ts";
23import {
24 SERVICE_LAUNCHER_PROTOCOL,
25 SERVICE_STATE_FILE,
26 SERVICE_STOP_MARKER_FILE,
27 type ServiceUpdateRecord,
28} from "./serviceProtocol.ts";
29import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
30import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
31import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts";
32import * as CliTokenManager from "./CliTokenManager.ts";
33import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contracts/relay";
34import {
35 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
36 CLOUD_ENDPOINT_RUNTIME_CONFIG,
37 CLOUD_LINK_REVOKED,
38 CLOUD_LINKED_USER_ID,
39 decodeConfirmedOrigin,
40 decodeRuntimeConfig,
41 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
42 RELAY_ISSUER_SECRET,
43 RELAY_URL_SECRET,
44} from "./config.ts";
45import * as CloudLink from "./CloudLink.ts";
46import { consumeCloudReplayGuards } from "./CloudLink.ts";
47import { pendingServiceUpdateExists } from "./updateHandoff.ts";
48import {
49 managedTunnelStartupAction,
50 retryManagedTunnelRegistration,
51} from "./managedTunnelStartup.ts";
52import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
53
54const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
55 new ServerSecretStore.SecretStorePersistError({
56 resource: "cloud replay guard",
57 cause: PlatformError.systemError({
58 _tag: tag,
59 module: "FileSystem",
60 method: "open",
61 pathOrDescriptor: "cloud-replay-guard.bin",
62 }),
63 });
64
65const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
66// Linking wakes the awareness relay; these tests do not run it.
67const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({
68 publishThread: () => Effect.void,
69 drain: Effect.void,
70 requestCatchUp: () => Effect.void,
71 start: () => Effect.void,
72});
73// The connect routes register recovery against the listening port; these tests
74// call the service directly.
75const idleHttpServer = HttpServer.HttpServer.of({
76 address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773),
77 serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"],
78});
79
80const decodeManagedTunnelRecoveryRegistration = Schema.decodeUnknownEffect(
81 Schema.fromJsonString(RelayManagedEndpointRecoveryRegistrationRequest),
82);
83
84function makeSecretStore(
85 create: ServerSecretStore.ServerSecretStore["Service"]["create"],
86): ServerSecretStore.ServerSecretStore["Service"] {
87 return {
88 get: unusedSecretStoreOperation,
89 set: unusedSecretStoreOperation,
90 create,
91 getOrCreateRandom: unusedSecretStoreOperation,
92 remove: unusedSecretStoreOperation,
93 };
94}
95
96it("preserves messages surfaced by cloud 500 responses", () => {
97 const cause = new Error("cloud operation failed");
98
99 expect([
100 new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message,
101 new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message,
102 new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message,
103 new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message,
104 new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message,
105 new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message,
106 new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message,
107 new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message,
108 ]).toEqual([
109 "Could not verify the linked cloud account.",
110 "Could not read the linked cloud account.",
111 "Cloud linked user is not installed for this environment.",
112 "Failed to sign cloud link JWT.",
113 "Cloud mint public key is not installed for this environment.",
114 "Cloud relay issuer is not installed for this environment.",
115 "Failed to sign cloud health JWT.",
116 "Failed to sign cloud mint JWT.",
117 ]);
118});
119
120describe("consumeCloudReplayGuards", () => {
121 it.effect("reports already-created guards as replay conflicts", () =>
122 Effect.gen(function* () {
123 const consumed = yield* consumeCloudReplayGuards({
124 secrets: makeSecretStore(() => Effect.fail(storeFailure("AlreadyExists"))),
125 names: ["cloud-jti", "cloud-nonce"],
126 value: new Uint8Array(),
127 });
128
129 expect(consumed).toBe(false);
130 }),
131 );
132
133 it.effect("preserves replay-store availability failures", () =>
134 Effect.gen(function* () {
135 const failure = storeFailure("PermissionDenied");
136 const error = yield* Effect.flip(
137 consumeCloudReplayGuards({
138 secrets: makeSecretStore(() => Effect.fail(failure)),
139 names: ["cloud-jti", "cloud-nonce"],
140 value: new Uint8Array(),
141 }),
142 );
143
144 expect(error).toBe(failure);
145 }),
146 );
147});
148
149describe("reconcileDesiredCloudLink", () => {
150 it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () =>
151 Effect.gen(function* () {
152 const link = yield* CloudLink.CloudLink;
153 const error = yield* Effect.flip(link.reconcileDesiredLink("http://127.0.0.1:3774"));
154
155 expect(error._tag).toBe("CloudLinkAuthorizationMissingError");
156 }).pipe(
157 Effect.provide(CloudLink.layer),
158 Effect.provideService(HttpServer.HttpServer, idleHttpServer),
159 Effect.provideService(
160 ServerConfigModule.ServerConfig,
161 ServerConfigModule.ServerConfig.of({} as ServerConfigModule.ServerConfig["Service"]),
162 ),
163 Effect.provideService(
164 ServerSecretStore.ServerSecretStore,
165 makeSecretStore(unusedSecretStoreOperation),
166 ),
167 Effect.provideService(
168 ServerEnvironment.ServerEnvironment,
169 ServerEnvironment.ServerEnvironment.of({
170 getEnvironmentId: unusedSecretStoreOperation(),
171 getDescriptor: unusedSecretStoreOperation(),
172 }),
173 ),
174 Effect.provideService(
175 ManagedEndpointRuntime.CloudManagedEndpointRuntime,
176 ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({
177 applyConfig: unusedSecretStoreOperation,
178 recoveryRequests: Stream.empty,
179 requestRecovery: () => Effect.void,
180 tunnelConnected: Stream.empty,
181 withLinkStateLock: (effect) => effect,
182 } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]),
183 ),
184 Effect.provideService(
185 EnvironmentAuth.EnvironmentAuth,
186 EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]),
187 ),
188 Effect.provideService(
189 CliTokenManager.CloudCliTokenManager,
190 CliTokenManager.CloudCliTokenManager.of({
191 get: unusedSecretStoreOperation(),
192 getExisting: Effect.succeedNone,
193 hasCredential: unusedSecretStoreOperation(),
194 store: () => unusedSecretStoreOperation(),
195 clear: unusedSecretStoreOperation(),
196 }),
197 ),
198 Effect.provideService(
199 HttpClient.HttpClient,
200 HttpClient.make(() => unusedSecretStoreOperation()),
201 ),
202 Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay),
203 Effect.provide(NodeServices.layer),
204 ),
205 );
206});
207
208describe("releaseManagedTunnelOnShutdown", () => {
209 const cliToken: CliTokenManager.PersistedToken = {
210 accessToken: "cli-access-token",
211 refreshToken: "cli-refresh-token",
212 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
213 };
214
215 function makeMemorySecretStore(initial: Iterable<readonly [string, string]> = []) {
216 const values = new Map<string, Uint8Array>(
217 Array.from(initial, ([name, value]) => [name, new TextEncoder().encode(value)] as const),
218 );
219 const store: ServerSecretStore.ServerSecretStore["Service"] = {
220 get: (name) => Effect.sync(() => Option.fromNullishOr(values.get(name))),
221 set: (name, value) =>
222 Effect.sync(() => {
223 values.set(name, value);
224 }),
225 create: (name, value) =>
226 Effect.sync(() => {
227 values.set(name, value);
228 }),
229 getOrCreateRandom: unusedSecretStoreOperation,
230 remove: (name) =>
231 Effect.sync(() => {
232 values.delete(name);
233 }),
234 };
235 return { store, values };
236 }
237
238 interface ReleaseHarness {
239 readonly store: ServerSecretStore.ServerSecretStore["Service"];
240 readonly applyConfigCalls: Array<unknown>;
241 readonly requests: Array<HttpClientRequest.HttpClientRequest>;
242 readonly onRequest?: (request: HttpClientRequest.HttpClientRequest) => Effect.Effect<void>;
243 readonly respond?: () => Response;
244 readonly respondEffect?: Effect.Effect<Response>;
245 }
246
247 // Writes the launcher's durable state file into this test's baseDir with
248 // the launcher's own writer; the release reads it to detect an in-flight
249 // update handoff.
250 const writeLauncherState = (update: ServiceUpdateRecord) =>
251 Effect.gen(function* () {
252 const path = yield* Path.Path;
253 const config = yield* ServerConfigModule.ServerConfig;
254 const statePath = path.join(config.baseDir, "runtime", SERVICE_STATE_FILE);
255 yield* Effect.promise(() =>
256 writeServiceState(statePath, {
257 protocol: SERVICE_LAUNCHER_PROTOCOL,
258 activeVersion: "0.0.30",
259 update,
260 }),
261 );
262 });
263
264 // Writes the marker the desktop app leaves just before it stops its backend
265 // to install an update, and returns when it was written.
266 const writeDesktopUpdateRestartMarker = Effect.gen(function* () {
267 const fs = yield* FileSystem.FileSystem;
268 const path = yield* Path.Path;
269 const config = yield* ServerConfigModule.ServerConfig;
270 const runtimeDir = path.join(config.baseDir, "runtime");
271 const markerPath = path.join(runtimeDir, DESKTOP_UPDATE_RESTART_MARKER_FILE);
272 yield* fs.makeDirectory(runtimeDir, { recursive: true });
273 yield* fs.writeFileString(markerPath, "");
274 const { mtime } = yield* fs.stat(markerPath);
275 return Option.getOrThrow(mtime).getTime();
276 });
277
278 const provideReleaseHarness =
279 (harness: ReleaseHarness) =>
280 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
281 effect.pipe(
282 Effect.provide(CloudLink.layer),
283 Effect.provideService(HttpServer.HttpServer, idleHttpServer),
284 Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store),
285 Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay),
286 Effect.provideService(
287 ServerEnvironment.ServerEnvironment,
288 ServerEnvironment.ServerEnvironment.of({
289 getEnvironmentId: Effect.succeed(EnvironmentId.make("env_123")),
290 getDescriptor: Effect.die("unused"),
291 }),
292 ),
293 Effect.provideService(
294 ManagedEndpointRuntime.CloudManagedEndpointRuntime,
295 ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({
296 applyConfig: (config) =>
297 Effect.sync(() => {
298 harness.applyConfigCalls.push(config);
299 return config === null
300 ? ({
301 status: "disabled",
302 } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus)
303 : ({
304 status: "running",
305 providerKind: "cloudflare_tunnel",
306 pid: 123,
307 } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus);
308 }),
309 recoveryRequests: Stream.empty,
310 requestRecovery: () => Effect.void,
311 tunnelConnected: Stream.empty,
312 withLinkStateLock: (effect) => effect,
313 }),
314 ),
315 Effect.provideService(
316 EnvironmentAuth.EnvironmentAuth,
317 EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]),
318 ),
319 Effect.provideService(
320 CliTokenManager.CloudCliTokenManager,
321 CliTokenManager.CloudCliTokenManager.of({
322 get: unusedSecretStoreOperation(),
323 getExisting: Effect.succeedSome(cliToken),
324 hasCredential: unusedSecretStoreOperation(),
325 store: () => unusedSecretStoreOperation(),
326 clear: unusedSecretStoreOperation(),
327 }),
328 ),
329 Effect.provideService(
330 HttpClient.HttpClient,
331 HttpClient.make((request) =>
332 Effect.sync(() => {
333 harness.requests.push(request);
334 }).pipe(
335 Effect.andThen(harness.onRequest?.(request) ?? Effect.void),
336 Effect.andThen(
337 harness.respondEffect ??
338 Effect.sync(() => (harness.respond ?? (() => Response.json({ ok: true })))()),
339 ),
340 Effect.map((response) => HttpClientResponse.fromWeb(request, response)),
341 ),
342 ),
343 ),
344 // The release consults the launcher state file under the configured
345 // baseDir, so every harness run gets a scoped temp baseDir.
346 Effect.provide(
347 ServerConfigModule.layerTest("/", { prefix: "t3-http-release-test-" }).pipe(
348 Layer.provideMerge(NodeServices.layer),
349 ),
350 ),
351 Effect.scoped,
352 );
353
354 // The persisted state of a CLI-managed link whose tunnel is releasable.
355 const managedLinkSecrets = [
356 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"],
357 [CLOUD_ENDPOINT_CONFIRMED_ORIGIN, "confirmed-origin"],
358 [RELAY_URL_SECRET, "https://relay.example.test"],
359 [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"],
360 ] as const;
361
362 it.effect("does not recreate a link that was unlinked while startup registration retried", () => {
363 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
364 const applyConfigCalls: Array<unknown> = [];
365 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
366
367 return Effect.gen(function* () {
368 const link = yield* CloudLink.CloudLink;
369 // Registration started while this marker existed. Unlink removes it
370 // before startup receives the relay's final not_linked response.
371 values.delete(CLOUD_CLI_DESIRED_LINK_SECRET);
372
373 expect(yield* link.reconcileDesiredLinkIfStillDesired("http://127.0.0.1:3773")).toBeNull();
374 expect(requests).toEqual([]);
375 expect(applyConfigCalls).toEqual([]);
376 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
377 });
378
379 it.effect("stops the connector, releases the relay tunnel, and drops the dead token", () => {
380 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
381 const applyConfigCalls: Array<unknown> = [];
382 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
383
384 return Effect.gen(function* () {
385 const link = yield* CloudLink.CloudLink;
386 const released = yield* link.releaseManagedTunnelOnShutdown();
387
388 expect(released).toBe(true);
389 expect(applyConfigCalls).toEqual([null]);
390 expect(requests).toHaveLength(1);
391 const request = requests[0]!;
392 expect(request.method).toBe("DELETE");
393 expect(request.url).toBe(
394 "https://relay.example.test/v1/client/environment-links/env_123/tunnel",
395 );
396 expect(request.headers.authorization).toBe("Bearer cli-access-token");
397 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
398 expect(values.has(CLOUD_ENDPOINT_CONFIRMED_ORIGIN)).toBe(false);
399 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
400 });
401
402 it.effect("does nothing for links without a stored managed tunnel runtime config", () => {
403 const { store } = makeMemorySecretStore();
404 const applyConfigCalls: Array<unknown> = [];
405 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
406
407 return Effect.gen(function* () {
408 const link = yield* CloudLink.CloudLink;
409 const released = yield* link.releaseManagedTunnelOnShutdown();
410
411 expect(released).toBe(false);
412 expect(applyConfigCalls).toEqual([]);
413 expect(requests).toEqual([]);
414 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
415 });
416
417 it.effect("leaves the tunnel of a web/mobile-installed link untouched", () => {
418 // A managed runtime config without a CLI-desired link: the environment was
419 // linked by a web/mobile client, and nothing re-provisions the tunnel on
420 // the next boot, so shutdown must not release it.
421 const { store, values } = makeMemorySecretStore([
422 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"],
423 [RELAY_URL_SECRET, "https://relay.example.test"],
424 ]);
425 const applyConfigCalls: Array<unknown> = [];
426 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
427
428 return Effect.gen(function* () {
429 const link = yield* CloudLink.CloudLink;
430 const released = yield* link.releaseManagedTunnelOnShutdown();
431
432 expect(released).toBe(false);
433 expect(applyConfigCalls).toEqual([]);
434 expect(requests).toEqual([]);
435 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
436 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
437 });
438
439 it.effect("leaves the tunnel of a publish-only desired link untouched", () => {
440 const { store, values } = makeMemorySecretStore([
441 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"],
442 [RELAY_URL_SECRET, "https://relay.example.test"],
443 [CLOUD_CLI_DESIRED_LINK_SECRET, "publish_only"],
444 ]);
445 const applyConfigCalls: Array<unknown> = [];
446 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
447
448 return Effect.gen(function* () {
449 const link = yield* CloudLink.CloudLink;
450 const released = yield* link.releaseManagedTunnelOnShutdown();
451
452 expect(released).toBe(false);
453 expect(applyConfigCalls).toEqual([]);
454 expect(requests).toEqual([]);
455 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
456 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
457 });
458
459 it.effect("keeps the tunnel when shutdown hands off to a pending update", () => {
460 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
461 const applyConfigCalls: Array<unknown> = [];
462 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
463
464 return Effect.gen(function* () {
465 const link = yield* CloudLink.CloudLink;
466 yield* writeLauncherState({
467 id: "update-1",
468 fromVersion: "0.0.30",
469 targetVersion: "0.0.31",
470 dbPath: "/tmp/state.sqlite",
471 status: "pending",
472 });
473
474 const released = yield* link.releaseManagedTunnelOnShutdown();
475
476 // The launcher restarts a server immediately, so the tunnel is not
477 // orphaned; keeping it avoids the hostname route re-propagation that
478 // dominates update downtime. The stored config must survive so the
479 // next boot respawns the connector against the same tunnel.
480 expect(released).toBe(false);
481 expect(applyConfigCalls).toEqual([]);
482 expect(requests).toEqual([]);
483 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
484 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
485 });
486
487 it.effect("keeps the tunnel once when the desktop app restarts it for an update", () => {
488 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
489 const applyConfigCalls: Array<unknown> = [];
490 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
491
492 return Effect.gen(function* () {
493 const link = yield* CloudLink.CloudLink;
494 yield* TestClock.setTime(yield* writeDesktopUpdateRestartMarker);
495
496 expect(yield* link.releaseManagedTunnelOnShutdown()).toBe(false);
497 expect(requests).toEqual([]);
498 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
499
500 // The shutdown consumed the marker, so a later quit releases the tunnel.
501 expect(yield* link.releaseManagedTunnelOnShutdown()).toBe(true);
502 expect(requests).toHaveLength(1);
503 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
504 });
505
506 it.effect("releases the tunnel when the desktop update marker is stale", () => {
507 const { store } = makeMemorySecretStore(managedLinkSecrets);
508 const applyConfigCalls: Array<unknown> = [];
509 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
510
511 return Effect.gen(function* () {
512 const link = yield* CloudLink.CloudLink;
513 const writtenAt = yield* writeDesktopUpdateRestartMarker;
514 yield* TestClock.setTime(writtenAt + Duration.toMillis(Duration.minutes(2)));
515
516 expect(yield* link.releaseManagedTunnelOnShutdown()).toBe(true);
517 expect(requests).toHaveLength(1);
518 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
519 });
520
521 it.effect("still releases a pending update when the launcher is stopping", () => {
522 // `t3 service uninstall` or `systemctl stop` during the pending window:
523 // the launcher writes its stop marker before signalling the child, so no
524 // replacement server is coming and the tunnel must not be kept.
525 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
526 const applyConfigCalls: Array<unknown> = [];
527 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
528
529 return Effect.gen(function* () {
530 const link = yield* CloudLink.CloudLink;
531 yield* writeLauncherState({
532 id: "update-1",
533 fromVersion: "0.0.30",
534 targetVersion: "0.0.31",
535 dbPath: "/tmp/state.sqlite",
536 status: "pending",
537 });
538 const fs = yield* FileSystem.FileSystem;
539 const path = yield* Path.Path;
540 const config = yield* ServerConfigModule.ServerConfig;
541 yield* fs.writeFileString(path.join(config.baseDir, "runtime", SERVICE_STOP_MARKER_FILE), "");
542
543 expect(yield* pendingServiceUpdateExists).toBe(true);
544 const released = yield* link.releaseManagedTunnelOnShutdown();
545
546 expect(released).toBe(true);
547 expect(requests).toHaveLength(1);
548 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
549 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
550 });
551
552 it.effect("still releases when the recorded update already settled", () => {
553 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
554 const applyConfigCalls: Array<unknown> = [];
555 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
556
557 return Effect.gen(function* () {
558 const link = yield* CloudLink.CloudLink;
559 yield* writeLauncherState({
560 id: "update-1",
561 fromVersion: "0.0.30",
562 targetVersion: "0.0.31",
563 status: "committed",
564 });
565
566 const released = yield* link.releaseManagedTunnelOnShutdown();
567
568 expect(released).toBe(true);
569 expect(requests).toHaveLength(1);
570 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
571 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
572 });
573
574 it.effect("keeps a runtime config that a fast restart replaced mid-release", () => {
575 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
576 const applyConfigCalls: Array<unknown> = [];
577 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
578 const freshConfig = new TextEncoder().encode("fresh-runtime-config");
579
580 return Effect.gen(function* () {
581 const link = yield* CloudLink.CloudLink;
582 const released = yield* link.releaseManagedTunnelOnShutdown();
583
584 expect(released).toBe(true);
585 // The finalizer only drops the config it released; the one written by
586 // the restarted process while the DELETE was in flight stays.
587 expect(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(freshConfig);
588 }).pipe(
589 provideReleaseHarness({
590 store,
591 applyConfigCalls,
592 requests,
593 respond: () => {
594 // A restarted process reconciled and stored a fresh connector config
595 // while this shutdown's release request was in flight.
596 values.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, freshConfig);
597 return Response.json({ ok: true });
598 },
599 }),
600 );
601 });
602
603 it.effect("keeps the stored connector token when the relay skipped the release", () => {
604 // ok:false means a concurrent provision owns the recorded tunnel, so the
605 // stored runtime config (possibly freshly written by that provision) must
606 // survive.
607 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
608 const applyConfigCalls: Array<unknown> = [];
609 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
610
611 return Effect.gen(function* () {
612 const link = yield* CloudLink.CloudLink;
613 const released = yield* link.releaseManagedTunnelOnShutdown();
614
615 expect(released).toBe(false);
616 expect(requests).toHaveLength(1);
617 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
618 }).pipe(
619 provideReleaseHarness({
620 store,
621 applyConfigCalls,
622 requests,
623 respond: () => Response.json({ ok: false }),
624 }),
625 );
626 });
627
628 it.effect("keeps the stored connector token when the relay release request fails", () => {
629 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
630 const applyConfigCalls: Array<unknown> = [];
631 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
632
633 return Effect.gen(function* () {
634 const link = yield* CloudLink.CloudLink;
635 const result = yield* Effect.result(link.releaseManagedTunnelOnShutdown());
636
637 expect(result._tag).toBe("Failure");
638 expect(requests).toHaveLength(1);
639 // The tunnel still exists, so the stored token stays valid across the
640 // restart and the next boot can bring the connector back immediately.
641 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
642 }).pipe(
643 provideReleaseHarness({
644 store,
645 applyConfigCalls,
646 requests,
647 respond: () => Response.json({ ok: false }, { status: 503 }),
648 }),
649 );
650 });
651
652 it.effect("registers an existing tunnel and starts the confirmed connector", () => {
653 const { store } = makeMemorySecretStore([
654 [
655 CLOUD_ENDPOINT_RUNTIME_CONFIG,
656 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}',
657 ],
658 [RELAY_URL_SECRET, "https://relay.example.test"],
659 [CLOUD_LINKED_USER_ID, "user-123"],
660 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
661 ]);
662 const applyConfigCalls: Array<unknown> = [];
663 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
664
665 return Effect.gen(function* () {
666 const link = yield* CloudLink.CloudLink;
667 expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toMatchObject({
668 status: "ready",
669 });
670 expect(requests).toHaveLength(1);
671 expect(requests[0]?.method).toBe("POST");
672 expect(requests[0]?.url).toBe(
673 "https://relay.example.test/v1/environments/env_123/tunnel/recovery",
674 );
675 expect(requests[0]?.headers.authorization).toBe("Bearer environment-credential");
676 const body = requests[0]?.body;
677 expect(body?._tag).toBe("Uint8Array");
678 if (body?._tag === "Uint8Array") {
679 expect(
680 yield* decodeManagedTunnelRecoveryRegistration(new TextDecoder().decode(body.body)),
681 ).toMatchObject({
682 cloudUserId: "user-123",
683 tunnelId: "existing-tunnel",
684 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
685 });
686 }
687 expect(applyConfigCalls).toHaveLength(1);
688 }).pipe(
689 provideReleaseHarness({
690 store,
691 applyConfigCalls,
692 requests,
693 respond: () => Response.json({ status: "ready" }),
694 }),
695 );
696 });
697
698 it.effect("marks a link the relay rejects as revoked without dropping or replacing it", () => {
699 const { store, values } = makeMemorySecretStore([
700 [
701 CLOUD_ENDPOINT_RUNTIME_CONFIG,
702 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}',
703 ],
704 [RELAY_URL_SECRET, "https://relay.example.test"],
705 [CLOUD_LINKED_USER_ID, "user-123"],
706 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
707 [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"],
708 ]);
709 const applyConfigCalls: Array<unknown> = [];
710 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
711
712 return Effect.gen(function* () {
713 const link = yield* CloudLink.CloudLink;
714 expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({
715 status: "revoked",
716 });
717 expect((yield* link.linkState()).linkRevoked).toBe(true);
718 // One registration request, no relink, and nothing started or removed.
719 expect(requests).toHaveLength(1);
720 expect(applyConfigCalls).toEqual([]);
721 expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true);
722 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
723 }).pipe(
724 provideReleaseHarness({
725 store,
726 applyConfigCalls,
727 requests,
728 respond: () =>
729 Response.json(
730 {
731 _tag: "RelayAuthInvalidError",
732 code: "auth_invalid",
733 reason: "not_authorized",
734 traceId: "trace-revoked",
735 },
736 { status: 401 },
737 ),
738 }),
739 );
740 });
741
742 it.effect("does not mark a link that was replaced while its rejection was in flight", () => {
743 const { store, values } = makeMemorySecretStore([
744 [
745 CLOUD_ENDPOINT_RUNTIME_CONFIG,
746 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}',
747 ],
748 [RELAY_URL_SECRET, "https://relay.example.test"],
749 [CLOUD_LINKED_USER_ID, "user-123"],
750 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "old-credential"],
751 ]);
752
753 return Effect.gen(function* () {
754 const link = yield* CloudLink.CloudLink;
755 expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({
756 status: "superseded",
757 });
758 expect(values.has(CLOUD_LINK_REVOKED)).toBe(false);
759 }).pipe(
760 provideReleaseHarness({
761 store,
762 applyConfigCalls: [],
763 requests: [],
764 respond: () => {
765 values.set(
766 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
767 new TextEncoder().encode("replacement-credential"),
768 );
769 return Response.json(
770 {
771 _tag: "RelayAuthInvalidError",
772 code: "auth_invalid",
773 reason: "not_authorized",
774 traceId: "trace-stale",
775 },
776 { status: 401 },
777 );
778 },
779 }),
780 );
781 });
782
783 it.effect("clears the revoked mark when the environment is linked again", () => {
784 const { store } = makeMemorySecretStore([
785 [RELAY_URL_SECRET, "https://relay.example.test"],
786 [CLOUD_LINKED_USER_ID, "user-123"],
787 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "revoked-credential"],
788 [CLOUD_LINK_REVOKED, "true"],
789 ]);
790 const cloudMintPublicKey =
791 "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----";
792
793 return Effect.gen(function* () {
794 const link = yield* CloudLink.CloudLink;
795 expect((yield* link.linkState()).linkRevoked).toBe(true);
796 yield* link.applyRelayConfig({
797 relayUrl: "https://relay.example.test",
798 cloudUserId: "user-123",
799 environmentCredential: "fresh-credential",
800 cloudMintPublicKey,
801 endpointRuntime: null,
802 });
803 expect(yield* link.linkState()).toMatchObject({ linked: true, linkRevoked: false });
804 }).pipe(provideReleaseHarness({ store, applyConfigCalls: [], requests: [] }));
805 });
806
807 it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => {
808 const config = {
809 providerKind: "cloudflare_tunnel" as const,
810 connectorToken: "existing-token",
811 tunnelId: "existing-tunnel",
812 };
813 const { store } = makeMemorySecretStore([
814 [CLOUD_ENDPOINT_RUNTIME_CONFIG, JSON.stringify(config)],
815 [
816 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
817 JSON.stringify({
818 config,
819 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
820 }),
821 ],
822 [RELAY_URL_SECRET, "https://relay.example.test"],
823 [CLOUD_LINKED_USER_ID, "user-123"],
824 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
825 ]);
826 const applyConfigCalls: Array<unknown> = [];
827 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
828 let relayAvailable = false;
829 const localOrigin = "http://127.0.0.1:4884";
830
831 return Effect.gen(function* () {
832 const link = yield* CloudLink.CloudLink;
833 const fallbackStarted = yield* Deferred.make<void>();
834 const firstFailure = yield* Deferred.make<void>();
835 expect(yield* link.startManagedTunnelIfOriginConfirmed(localOrigin)).toBe(false);
836 const registration = yield* Effect.forkChild(
837 retryManagedTunnelRegistration(
838 link
839 .registerManagedTunnelRecovery(localOrigin)
840 .pipe(Effect.tapError(() => Deferred.succeed(firstFailure, undefined))),
841 CloudLink.shouldRetryCloudLink,
842 link
843 .startManagedTunnelIfOriginConfirmed(localOrigin, {
844 requireConfirmedOrigin: false,
845 })
846 .pipe(
847 Effect.orDie,
848 Effect.tap((started) => {
849 expect(started).toBe(true);
850 return Deferred.succeed(fallbackStarted, undefined);
851 }),
852 Effect.asVoid,
853 ),
854 ),
855 { startImmediately: true },
856 );
857 yield* Deferred.await(firstFailure);
858 yield* TestClock.adjust("15 minutes");
859 yield* Effect.raceFirst(
860 Deferred.await(fallbackStarted),
861 Fiber.join(registration).pipe(
862 Effect.andThen(Effect.die("Registration ended before starting the fallback")),
863 ),
864 );
865 expect(applyConfigCalls).toEqual([config]);
866 const attemptsBeforeRecovery = requests.length;
867
868 relayAvailable = true;
869 yield* TestClock.adjust("1 minute");
870 expect(yield* Fiber.join(registration)).toMatchObject({ status: "ready" });
871 expect(requests.length).toBeGreaterThan(attemptsBeforeRecovery);
872 const marker = yield* store.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);
873 expect(Option.isSome(marker)).toBe(true);
874 if (Option.isSome(marker)) {
875 expect(
876 Option.getOrThrow(decodeConfirmedOrigin(new TextDecoder().decode(marker.value))),
877 ).toEqual({
878 config,
879 origin: { localHttpHost: "127.0.0.1", localHttpPort: 4884 },
880 });
881 }
882 }).pipe(
883 provideReleaseHarness({
884 store,
885 applyConfigCalls,
886 requests,
887 respond: () =>
888 relayAvailable
889 ? Response.json({ status: "ready" })
890 : Response.json({ message: "relay unavailable" }, { status: 503 }),
891 }),
892 );
893 });
894
895 it.effect(
896 "starts a connector with a marker for the current origin without contacting relay",
897 () => {
898 const configJson =
899 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}';
900 const config = {
901 providerKind: "cloudflare_tunnel" as const,
902 connectorToken: "existing-token",
903 tunnelId: "existing-tunnel",
904 };
905 const { store } = makeMemorySecretStore([
906 [CLOUD_ENDPOINT_RUNTIME_CONFIG, configJson],
907 [
908 CLOUD_ENDPOINT_CONFIRMED_ORIGIN,
909 `{"config":${configJson},"origin":{"localHttpHost":"127.0.0.1","localHttpPort":3773}}`,
910 ],
911 ]);
912 const applyConfigCalls: Array<unknown> = [];
913 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
914
915 return Effect.gen(function* () {
916 const link = yield* CloudLink.CloudLink;
917 expect(yield* link.startManagedTunnelIfOriginConfirmed("http://127.0.0.1:3773")).toBe(true);
918 expect(applyConfigCalls).toEqual([config]);
919 expect(requests).toEqual([]);
920 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
921 },
922 );
923
924 it.effect.each([
925 { name: "missing", marker: undefined, origin: "http://127.0.0.1:3773" },
926 {
927 name: "stale",
928 marker:
929 '{"config":{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"},"origin":{"localHttpHost":"127.0.0.1","localHttpPort":3773}}',
930 origin: "http://127.0.0.1:4884",
931 },
932 ])("does not start a connector with a $name origin marker", ({ marker, origin }) => {
933 const entries: Array<readonly [string, string]> = [
934 [
935 CLOUD_ENDPOINT_RUNTIME_CONFIG,
936 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}',
937 ],
938 ];
939 if (marker !== undefined) entries.push([CLOUD_ENDPOINT_CONFIRMED_ORIGIN, marker]);
940 const { store } = makeMemorySecretStore(entries);
941 const applyConfigCalls: Array<unknown> = [];
942 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
943
944 return Effect.gen(function* () {
945 const link = yield* CloudLink.CloudLink;
946 expect(yield* link.startManagedTunnelIfOriginConfirmed(origin)).toBe(false);
947 expect(applyConfigCalls).toEqual([]);
948 expect(requests).toEqual([]);
949 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
950 });
951
952 it.effect(
953 "starts the stored connector without a marker when confirmation is not required",
954 () => {
955 const config = {
956 providerKind: "cloudflare_tunnel" as const,
957 connectorToken: "existing-token",
958 tunnelId: "existing-tunnel",
959 };
960 const { store } = makeMemorySecretStore([
961 [CLOUD_ENDPOINT_RUNTIME_CONFIG, JSON.stringify(config)],
962 ]);
963 const applyConfigCalls: Array<unknown> = [];
964 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
965
966 return Effect.gen(function* () {
967 const link = yield* CloudLink.CloudLink;
968 expect(
969 yield* link.startManagedTunnelIfOriginConfirmed("http://127.0.0.1:3773", {
970 requireConfirmedOrigin: false,
971 }),
972 ).toBe(true);
973 expect(applyConfigCalls).toEqual([config]);
974 expect(requests).toEqual([]);
975 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
976 },
977 );
978
979 it.effect.each(["replaced", "removed"] as const)(
980 "does not activate a tunnel when its runtime config is %s during registration",
981 (mutation) => {
982 const originalConfig =
983 '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}';
984 const { store, values } = makeMemorySecretStore([
985 [CLOUD_ENDPOINT_RUNTIME_CONFIG, originalConfig],
986 [RELAY_URL_SECRET, "https://relay.example.test"],
987 [CLOUD_LINKED_USER_ID, "user-123"],
988 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
989 ]);
990 const applyConfigCalls: Array<unknown> = [];
991 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
992
993 return Effect.gen(function* () {
994 const link = yield* CloudLink.CloudLink;
995 expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({
996 status: "superseded",
997 });
998 expect(applyConfigCalls).toEqual([]);
999 expect(values.has(CLOUD_ENDPOINT_CONFIRMED_ORIGIN)).toBe(false);
1000 }).pipe(
1001 provideReleaseHarness({
1002 store,
1003 applyConfigCalls,
1004 requests,
1005 respond: () => {
1006 if (mutation === "replaced") {
1007 values.set(
1008 CLOUD_ENDPOINT_RUNTIME_CONFIG,
1009 new TextEncoder().encode(
1010 '{"providerKind":"cloudflare_tunnel","connectorToken":"fresh-token","tunnelId":"fresh-tunnel"}',
1011 ),
1012 );
1013 } else {
1014 values.delete(CLOUD_ENDPOINT_RUNTIME_CONFIG);
1015 }
1016 return Response.json({ status: "ready" });
1017 },
1018 }),
1019 );
1020 },
1021 );
1022
1023 it.effect("requests startup recovery for a legacy config without a recorded tunnel ID", () => {
1024 const { store } = makeMemorySecretStore([
1025 [
1026 CLOUD_ENDPOINT_RUNTIME_CONFIG,
1027 '{"providerKind":"cloudflare_tunnel","connectorToken":"token"}',
1028 ],
1029 [RELAY_URL_SECRET, "https://relay.example.test"],
1030 [CLOUD_LINKED_USER_ID, "user-123"],
1031 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1032 ]);
1033 const applyConfigCalls: Array<unknown> = [];
1034 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1035
1036 return Effect.gen(function* () {
1037 const link = yield* CloudLink.CloudLink;
1038 const registration = yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773");
1039 expect(registration).toEqual({
1040 status: "recovery_required",
1041 config: { providerKind: "cloudflare_tunnel", connectorToken: "token" },
1042 });
1043 expect(
1044 managedTunnelStartupAction({
1045 wantsCliLink: false,
1046 registration,
1047 }),
1048 ).toEqual({
1049 action: "request_recovery",
1050 config: { providerKind: "cloudflare_tunnel", connectorToken: "token" },
1051 });
1052 expect(requests).toEqual([]);
1053 expect(applyConfigCalls).toEqual([]);
1054 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
1055 });
1056
1057 it.effect("recovers a web-linked tunnel and clears its revoked mark", () => {
1058 const oldConfig =
1059 '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}';
1060 const nextConfig = {
1061 providerKind: "cloudflare_tunnel",
1062 connectorToken: "new-token",
1063 tunnelId: "new-tunnel",
1064 } as const;
1065 const { store, values } = makeMemorySecretStore([
1066 [CLOUD_ENDPOINT_RUNTIME_CONFIG, oldConfig],
1067 [RELAY_URL_SECRET, "https://relay.example.test"],
1068 [CLOUD_LINKED_USER_ID, "user-123"],
1069 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1070 [CLOUD_LINK_REVOKED, "true"],
1071 ]);
1072 const applyConfigCalls: Array<unknown> = [];
1073 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1074
1075 return Effect.gen(function* () {
1076 const link = yield* CloudLink.CloudLink;
1077 expect(yield* link.recoverManagedTunnel("http://127.0.0.1:3773")).toBe(true);
1078 expect(requests).toHaveLength(1);
1079 expect(requests[0]?.method).toBe("POST");
1080 expect(requests[0]?.url).toBe("https://relay.example.test/v1/environments/env_123/tunnel");
1081 expect(requests[0]?.headers.authorization).toBe("Bearer environment-credential");
1082 expect(applyConfigCalls).toEqual([nextConfig]);
1083 expect(
1084 Option.getOrNull(
1085 decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))),
1086 ),
1087 ).toEqual(nextConfig);
1088 expect(values.has(CLOUD_LINK_REVOKED)).toBe(false);
1089 }).pipe(
1090 provideReleaseHarness({
1091 store,
1092 applyConfigCalls,
1093 requests,
1094 respond: () =>
1095 Response.json({
1096 endpoint: {
1097 httpBaseUrl: "https://environment.example.test/",
1098 wsBaseUrl: "wss://environment.example.test/ws",
1099 providerKind: "cloudflare_tunnel",
1100 },
1101 endpointRuntime: nextConfig,
1102 }),
1103 }),
1104 );
1105 });
1106
1107 it.effect("marks the link revoked when the relay rejects a running host's recovery", () => {
1108 const { store, values } = makeMemorySecretStore([
1109 [
1110 CLOUD_ENDPOINT_RUNTIME_CONFIG,
1111 '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}',
1112 ],
1113 [RELAY_URL_SECRET, "https://relay.example.test"],
1114 [CLOUD_LINKED_USER_ID, "user-123"],
1115 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1116 ]);
1117 const applyConfigCalls: Array<unknown> = [];
1118
1119 return Effect.gen(function* () {
1120 const link = yield* CloudLink.CloudLink;
1121 const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773"));
1122 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" });
1123 expect((yield* link.linkState()).linkRevoked).toBe(true);
1124 expect(applyConfigCalls).toEqual([]);
1125 expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true);
1126 }).pipe(
1127 provideReleaseHarness({
1128 store,
1129 applyConfigCalls,
1130 requests: [],
1131 respond: () =>
1132 Response.json(
1133 {
1134 _tag: "RelayAuthInvalidError",
1135 code: "auth_invalid",
1136 reason: "not_authorized",
1137 traceId: "trace-recovery-revoked",
1138 },
1139 { status: 401 },
1140 ),
1141 }),
1142 );
1143 });
1144
1145 it.effect("allows managed tunnel provisioning to take longer than ten seconds", () =>
1146 Effect.gen(function* () {
1147 const oldConfig =
1148 '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}';
1149 const nextConfig = {
1150 providerKind: "cloudflare_tunnel" as const,
1151 connectorToken: "new-token",
1152 tunnelId: "new-tunnel",
1153 };
1154 const { store } = makeMemorySecretStore([
1155 [CLOUD_ENDPOINT_RUNTIME_CONFIG, oldConfig],
1156 [RELAY_URL_SECRET, "https://relay.example.test"],
1157 [CLOUD_LINKED_USER_ID, "user-123"],
1158 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1159 ]);
1160 const applyConfigCalls: Array<unknown> = [];
1161 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1162 const requestStarted = yield* Deferred.make<void>();
1163 const response = yield* Deferred.make<Response>();
1164 const recovery = yield* CloudLink.CloudLink.use((link) =>
1165 link.recoverManagedTunnel("http://127.0.0.1:3773"),
1166 ).pipe(
1167 provideReleaseHarness({
1168 store,
1169 applyConfigCalls,
1170 requests,
1171 onRequest: () => Deferred.succeed(requestStarted, undefined),
1172 respondEffect: Deferred.await(response),
1173 }),
1174 Effect.forkChild({ startImmediately: true }),
1175 );
1176
1177 yield* Deferred.await(requestStarted);
1178 expect(requests).toHaveLength(1);
1179 yield* TestClock.adjust("11 seconds");
1180 yield* Effect.yieldNow;
1181 yield* Deferred.succeed(
1182 response,
1183 Response.json({
1184 endpoint: {
1185 httpBaseUrl: "https://environment.example.test/",
1186 wsBaseUrl: "wss://environment.example.test/ws",
1187 providerKind: "cloudflare_tunnel",
1188 },
1189 endpointRuntime: nextConfig,
1190 }),
1191 );
1192
1193 expect(yield* Fiber.join(recovery)).toBe(true);
1194 expect(requests).toHaveLength(1);
1195 expect(applyConfigCalls).toEqual([nextConfig]);
1196 }),
1197 );
1198
1199 it.effect("does not recover an environment without a managed tunnel credential", () => {
1200 const { store } = makeMemorySecretStore([
1201 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"],
1202 [RELAY_URL_SECRET, "https://relay.example.test"],
1203 ]);
1204 const applyConfigCalls: Array<unknown> = [];
1205 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1206
1207 return Effect.gen(function* () {
1208 const link = yield* CloudLink.CloudLink;
1209 expect(yield* link.recoverManagedTunnel("http://127.0.0.1:3773")).toBe(false);
1210 expect(applyConfigCalls).toEqual([]);
1211 expect(requests).toEqual([]);
1212 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
1213 });
1214
1215 it.effect("ignores recovery requests for a tunnel that has already been replaced", () => {
1216 const { store } = makeMemorySecretStore([
1217 [
1218 CLOUD_ENDPOINT_RUNTIME_CONFIG,
1219 '{"providerKind":"cloudflare_tunnel","connectorToken":"current-token","tunnelId":"current-tunnel"}',
1220 ],
1221 [RELAY_URL_SECRET, "https://relay.example.test"],
1222 [CLOUD_LINKED_USER_ID, "user-123"],
1223 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1224 ]);
1225 const applyConfigCalls: Array<unknown> = [];
1226 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1227
1228 return Effect.gen(function* () {
1229 const link = yield* CloudLink.CloudLink;
1230 expect(
1231 yield* link.recoverManagedTunnel("http://127.0.0.1:3773", {
1232 providerKind: "cloudflare_tunnel",
1233 connectorToken: "old-token",
1234 tunnelId: "old-tunnel",
1235 }),
1236 ).toBe(false);
1237 expect(requests).toEqual([]);
1238 expect(applyConfigCalls).toEqual([]);
1239 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
1240 });
1241
1242 it.effect.each([
1243 { status: 401, rejection: "unauthorized" },
1244 { status: 403, rejection: "forbidden" },
1245 { status: 409, rejection: "rejected" },
1246 ])("preserves a permanent $status relay recovery failure", ({ status, rejection }) => {
1247 const { store } = makeMemorySecretStore([
1248 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"],
1249 [RELAY_URL_SECRET, "https://relay.example.test"],
1250 [CLOUD_LINKED_USER_ID, "user-123"],
1251 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1252 ]);
1253 const applyConfigCalls: Array<unknown> = [];
1254 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1255
1256 return Effect.gen(function* () {
1257 const link = yield* CloudLink.CloudLink;
1258 const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773"));
1259
1260 expect(error).toMatchObject({ _tag: "RelayRequestError", rejection });
1261 expect(CloudLink.shouldRetryCloudLink(error)).toBe(false);
1262 expect(requests).toHaveLength(1);
1263 expect(applyConfigCalls).toEqual([]);
1264 }).pipe(
1265 provideReleaseHarness({
1266 store,
1267 applyConfigCalls,
1268 requests,
1269 respond: () => Response.json({}, { status }),
1270 }),
1271 );
1272 });
1273
1274 it.effect("keeps a tunnel configuration replaced during recovery", () => {
1275 const { store, values } = makeMemorySecretStore([
1276 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"],
1277 [RELAY_URL_SECRET, "https://relay.example.test"],
1278 [CLOUD_LINKED_USER_ID, "user-123"],
1279 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"],
1280 ]);
1281 const applyConfigCalls: Array<unknown> = [];
1282 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1283 const freshConfig = new TextEncoder().encode("fresh-config");
1284
1285 return Effect.gen(function* () {
1286 const link = yield* CloudLink.CloudLink;
1287 expect(yield* link.recoverManagedTunnel("http://127.0.0.1:3773")).toBe(false);
1288 expect(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(freshConfig);
1289 expect(applyConfigCalls).toEqual([]);
1290 }).pipe(
1291 provideReleaseHarness({
1292 store,
1293 applyConfigCalls,
1294 requests,
1295 respond: () => {
1296 values.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, freshConfig);
1297 return Response.json({
1298 endpoint: {
1299 httpBaseUrl: "https://environment.example.test/",
1300 wsBaseUrl: "wss://environment.example.test/ws",
1301 providerKind: "cloudflare_tunnel",
1302 },
1303 endpointRuntime: {
1304 providerKind: "cloudflare_tunnel",
1305 connectorToken: "replacement-token",
1306 },
1307 });
1308 },
1309 }),
1310 );
1311 });
1312
1313 // The relay commits the link before the server stores it (#11898). A write
1314 // that fails part-way must not leave the stale tunnel config that reports
1315 // the link as managed, or Settings never relinks.
1316 it.effect("does not report a managed tunnel after a relay config write fails part-way", () => {
1317 const { store, values } = makeMemorySecretStore([
1318 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "managed-config"],
1319 [CLOUD_ENDPOINT_CONFIRMED_ORIGIN, "confirmed-origin"],
1320 [RELAY_URL_SECRET, "https://relay.example.test"],
1321 [CLOUD_LINKED_USER_ID, "user-123"],
1322 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "old-credential"],
1323 ]);
1324 const failingStore: ServerSecretStore.ServerSecretStore["Service"] = {
1325 ...store,
1326 set: (name, value) =>
1327 name === RELAY_ISSUER_SECRET
1328 ? Effect.fail(
1329 new ServerSecretStore.SecretStorePersistError({
1330 resource: name,
1331 cause: PlatformError.systemError({
1332 _tag: "PermissionDenied",
1333 module: "FileSystem",
1334 method: "rename",
1335 pathOrDescriptor: `${name}.bin`,
1336 }),
1337 }),
1338 )
1339 : store.set(name, value),
1340 };
1341 const applyConfigCalls: Array<unknown> = [];
1342 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1343 const cloudMintPublicKey =
1344 "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAMY09Vo5CzEA6aEkhB/0h27t4Qs71SHS1fTSI4K0KvZQ=\n-----END PUBLIC KEY-----\n";
1345
1346 return Effect.gen(function* () {
1347 const link = yield* CloudLink.CloudLink;
1348 const error = yield* Effect.flip(
1349 link.applyRelayConfig({
1350 relayUrl: "https://relay.example.test",
1351 cloudUserId: "user-123",
1352 environmentCredential: "new-credential",
1353 cloudMintPublicKey,
1354 endpointRuntime: null,
1355 }),
1356 );
1357
1358 expect(error._tag).toBe("CloudLinkInternalError");
1359 expect(yield* link.linkState()).toMatchObject({
1360 linked: true,
1361 cloudUserId: "user-123",
1362 managedTunnelActive: false,
1363 });
1364 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
1365 }).pipe(provideReleaseHarness({ store: failingStore, applyConfigCalls, requests }));
1366 });
1367});