authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 17:33:41+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 17:33:41+02:00
log96398e3775fc6c74525e4a211e75e580c6cf4225
tree30c82c624ae9daa8b2edb5ebfcaf617ac64cc78d
parentd2d40b6c1aa2464b38357d52b7f991cfb422a669
signature Signed by PGP key B5690EEEBB952194

feat(connect): release the Cloudflare tunnel when the environment shuts down (#4531)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

10 files changed, 722 insertions(+), 17 deletions(-)

apps/server/src/cloud/http.test.ts+254-1
...@@ -4,19 +4,28 @@ import * as Effect from "effect/Effect";...@@ -4,19 +4,28 @@ import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as PlatformError from "effect/PlatformError";5import * as PlatformError from "effect/PlatformError";
6import * as Tracer from "effect/Tracer";6import * as Tracer from "effect/Tracer";
7import { HttpClient, HttpServerRequest } from "effect/unstable/http";7import {
8 HttpClient,
9 HttpClientResponse,
10 HttpServerRequest,
11 type HttpClientRequest,
12} from "effect/unstable/http";
813
14import { EnvironmentId } from "@t3tools/contracts";
9import { RelayClientTracer } from "@t3tools/shared/relayTracing";15import { RelayClientTracer } from "@t3tools/shared/relayTracing";
10import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";16import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as ServerEnvironment from "../environment/ServerEnvironment.ts";18import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
19import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts";
13import * as CliTokenManager from "./CliTokenManager.ts";20import * as CliTokenManager from "./CliTokenManager.ts";
14import type { RelayLinkProofRequest } from "@t3tools/contracts/relay";21import type { RelayLinkProofRequest } from "@t3tools/contracts/relay";
22import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./config.ts";
15import {23import {
16 consumeCloudReplayGuards,24 consumeCloudReplayGuards,
17 isSupportedLinkProviderKind,25 isSupportedLinkProviderKind,
18 linkProofScopes,26 linkProofScopes,
19 reconcileDesiredCloudLink,27 reconcileDesiredCloudLink,
28 releaseManagedTunnelOnShutdown,
20} from "./http.ts";29} from "./http.ts";
21import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";30import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
22import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";31import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
...@@ -213,6 +222,250 @@ describe("reconcileDesiredCloudLink", () => {...@@ -213,6 +222,250 @@ describe("reconcileDesiredCloudLink", () => {
213 );222 );
214});223});
215224
225describe("releaseManagedTunnelOnShutdown", () => {
226 const cliToken: CliTokenManager.PersistedToken = {
227 accessToken: "cli-access-token",
228 refreshToken: "cli-refresh-token",
229 expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
230 };
231
232 function makeMemorySecretStore(initial: Iterable<readonly [string, string]> = []) {
233 const values = new Map<string, Uint8Array>(
234 Array.from(initial, ([name, value]) => [name, new TextEncoder().encode(value)] as const),
235 );
236 const store: ServerSecretStore.ServerSecretStore["Service"] = {
237 get: (name) => Effect.sync(() => Option.fromNullishOr(values.get(name))),
238 set: (name, value) =>
239 Effect.sync(() => {
240 values.set(name, value);
241 }),
242 create: unusedSecretStoreOperation,
243 getOrCreateRandom: unusedSecretStoreOperation,
244 remove: (name) =>
245 Effect.sync(() => {
246 values.delete(name);
247 }),
248 };
249 return { store, values };
250 }
251
252 interface ReleaseHarness {
253 readonly store: ServerSecretStore.ServerSecretStore["Service"];
254 readonly applyConfigCalls: Array<unknown>;
255 readonly requests: Array<HttpClientRequest.HttpClientRequest>;
256 readonly respond?: () => Response;
257 }
258
259 const provideReleaseHarness =
260 (harness: ReleaseHarness) =>
261 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
262 effect.pipe(
263 Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store),
264 Effect.provideService(
265 ServerEnvironment.ServerEnvironment,
266 ServerEnvironment.ServerEnvironment.of({
267 getEnvironmentId: Effect.succeed(EnvironmentId.make("env_123")),
268 getDescriptor: Effect.die("unused"),
269 }),
270 ),
271 Effect.provideService(
272 ManagedEndpointRuntime.CloudManagedEndpointRuntime,
273 ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({
274 applyConfig: (config) =>
275 Effect.sync(() => {
276 harness.applyConfigCalls.push(config);
277 return {
278 status: "disabled",
279 } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus;
280 }),
281 }),
282 ),
283 Effect.provideService(
284 EnvironmentAuth.EnvironmentAuth,
285 EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]),
286 ),
287 Effect.provideService(
288 CliTokenManager.CloudCliTokenManager,
289 CliTokenManager.CloudCliTokenManager.of({
290 get: unusedSecretStoreOperation(),
291 getExisting: Effect.succeed(Option.some(cliToken)),
292 hasCredential: unusedSecretStoreOperation(),
293 store: () => unusedSecretStoreOperation(),
294 clear: unusedSecretStoreOperation(),
295 }),
296 ),
297 Effect.provideService(
298 HttpClient.HttpClient,
299 HttpClient.make((request) =>
300 Effect.sync(() => {
301 harness.requests.push(request);
302 return HttpClientResponse.fromWeb(
303 request,
304 (harness.respond ?? (() => Response.json({ ok: true })))(),
305 );
306 }),
307 ),
308 ),
309 );
310
311 // The persisted state of a CLI-managed link whose tunnel is releasable.
312 const managedLinkSecrets = [
313 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"],
314 [RELAY_URL_SECRET, "https://relay.example.test"],
315 [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"],
316 ] as const;
317
318 it.effect("stops the connector, releases the relay tunnel, and drops the dead token", () => {
319 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
320 const applyConfigCalls: Array<unknown> = [];
321 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
322
323 return Effect.gen(function* () {
324 const released = yield* releaseManagedTunnelOnShutdown();
325
326 expect(released).toBe(true);
327 expect(applyConfigCalls).toEqual([null]);
328 expect(requests).toHaveLength(1);
329 const request = requests[0]!;
330 expect(request.method).toBe("DELETE");
331 expect(request.url).toBe(
332 "https://relay.example.test/v1/client/environment-links/env_123/tunnel",
333 );
334 expect(request.headers.authorization).toBe("Bearer cli-access-token");
335 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
336 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
337 });
338
339 it.effect("does nothing for links without a stored managed tunnel runtime config", () => {
340 const { store } = makeMemorySecretStore();
341 const applyConfigCalls: Array<unknown> = [];
342 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
343
344 return Effect.gen(function* () {
345 const released = yield* releaseManagedTunnelOnShutdown();
346
347 expect(released).toBe(false);
348 expect(applyConfigCalls).toEqual([]);
349 expect(requests).toEqual([]);
350 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
351 });
352
353 it.effect("leaves the tunnel of a web/mobile-installed link untouched", () => {
354 // A managed runtime config without a CLI-desired link: the environment was
355 // linked by a web/mobile client, and nothing re-provisions the tunnel on
356 // the next boot, so shutdown must not release it.
357 const { store, values } = makeMemorySecretStore([
358 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"],
359 [RELAY_URL_SECRET, "https://relay.example.test"],
360 ]);
361 const applyConfigCalls: Array<unknown> = [];
362 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
363
364 return Effect.gen(function* () {
365 const released = yield* releaseManagedTunnelOnShutdown();
366
367 expect(released).toBe(false);
368 expect(applyConfigCalls).toEqual([]);
369 expect(requests).toEqual([]);
370 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
371 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
372 });
373
374 it.effect("leaves the tunnel of a publish-only desired link untouched", () => {
375 const { store, values } = makeMemorySecretStore([
376 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"],
377 [RELAY_URL_SECRET, "https://relay.example.test"],
378 [CLOUD_CLI_DESIRED_LINK_SECRET, "publish_only"],
379 ]);
380 const applyConfigCalls: Array<unknown> = [];
381 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
382
383 return Effect.gen(function* () {
384 const released = yield* releaseManagedTunnelOnShutdown();
385
386 expect(released).toBe(false);
387 expect(applyConfigCalls).toEqual([]);
388 expect(requests).toEqual([]);
389 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
390 }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests }));
391 });
392
393 it.effect("keeps a runtime config that a fast restart replaced mid-release", () => {
394 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
395 const applyConfigCalls: Array<unknown> = [];
396 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
397 const freshConfig = new TextEncoder().encode("fresh-runtime-config");
398
399 return Effect.gen(function* () {
400 const released = yield* releaseManagedTunnelOnShutdown();
401
402 expect(released).toBe(true);
403 // The finalizer only drops the config it released; the one written by
404 // the restarted process while the DELETE was in flight stays.
405 expect(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(freshConfig);
406 }).pipe(
407 provideReleaseHarness({
408 store,
409 applyConfigCalls,
410 requests,
411 respond: () => {
412 // A restarted process reconciled and stored a fresh connector config
413 // while this shutdown's release request was in flight.
414 values.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, freshConfig);
415 return Response.json({ ok: true });
416 },
417 }),
418 );
419 });
420
421 it.effect("keeps the stored connector token when the relay skipped the release", () => {
422 // ok:false means a concurrent provision owns the recorded tunnel, so the
423 // stored runtime config (possibly freshly written by that provision) must
424 // survive.
425 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
426 const applyConfigCalls: Array<unknown> = [];
427 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
428
429 return Effect.gen(function* () {
430 const released = yield* releaseManagedTunnelOnShutdown();
431
432 expect(released).toBe(false);
433 expect(requests).toHaveLength(1);
434 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
435 }).pipe(
436 provideReleaseHarness({
437 store,
438 applyConfigCalls,
439 requests,
440 respond: () => Response.json({ ok: false }),
441 }),
442 );
443 });
444
445 it.effect("keeps the stored connector token when the relay release request fails", () => {
446 const { store, values } = makeMemorySecretStore(managedLinkSecrets);
447 const applyConfigCalls: Array<unknown> = [];
448 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
449
450 return Effect.gen(function* () {
451 const result = yield* Effect.result(releaseManagedTunnelOnShutdown());
452
453 expect(result._tag).toBe("Failure");
454 expect(requests).toHaveLength(1);
455 // The tunnel still exists, so the stored token stays valid across the
456 // restart and the next boot can bring the connector back immediately.
457 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true);
458 }).pipe(
459 provideReleaseHarness({
460 store,
461 applyConfigCalls,
462 requests,
463 respond: () => Response.json({ ok: false }, { status: 503 }),
464 }),
465 );
466 });
467});
468
216describe("link proof provider kinds", () => {469describe("link proof provider kinds", () => {
217 const proofRequest = (470 const proofRequest = (
218 providerKind: RelayLinkProofRequest["endpoint"]["providerKind"],471 providerKind: RelayLinkProofRequest["endpoint"]["providerKind"],
apps/server/src/cloud/http.ts+72-1
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 RelayEnvironmentLinkProofPayload,28 RelayEnvironmentLinkProofPayload,
29 RelayLinkProofRequest,29 RelayLinkProofRequest,
30 RelayManagedEndpointOrigin,30 RelayManagedEndpointOrigin,
31 RelayOkResponse,
31} from "@t3tools/contracts/relay";32} from "@t3tools/contracts/relay";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";33import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
33import {34import {
...@@ -68,7 +69,11 @@ import {...@@ -68,7 +69,11 @@ import {
68 RELAY_URL_SECRET,69 RELAY_URL_SECRET,
69} from "./config.ts";70} from "./config.ts";
70import { relayUrlConfig } from "./publicConfig.ts";71import { relayUrlConfig } from "./publicConfig.ts";
71import { readCliDesiredLinkMode, setCliDesiredCloudLink } from "./CliState.ts";72import {
73 readCliDesiredCloudLink,
74 readCliDesiredLinkMode,
75 setCliDesiredCloudLink,
76} from "./CliState.ts";
72import * as CliTokenManager from "./CliTokenManager.ts";77import * as CliTokenManager from "./CliTokenManager.ts";
73import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";78import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
74import { traceRelayRequest } from "./traceRelayRequest.ts";79import { traceRelayRequest } from "./traceRelayRequest.ts";
...@@ -622,6 +627,72 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD...@@ -622,6 +627,72 @@ export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileD
622 },627 },
623);628);
624629
630// Cloudflare bills per provisioned tunnel, so an environment that goes offline
631// must not leave its tunnel behind. Releasing deletes only the tunnel — the
632// relay keeps the link and its hostname reservation, and the next startup's
633// link reconcile provisions a replacement tunnel under the same URL.
634export const releaseManagedTunnelOnShutdown = Effect.fn(
635 "environment.cloud.releaseManagedTunnelOnShutdown",
636)(function* () {
637 const dependencies = yield* cloudHttpDependencies;
638 // Only a managed link stores a runtime config; publish-only links have no
639 // tunnel to release.
640 const runtimeConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
641 if (Option.isNone(runtimeConfig)) {
642 return false;
643 }
644 // Only CLI-desired managed links release on shutdown, because the startup
645 // reconcile that provisions the replacement tunnel only runs for them. A
646 // link installed by a web/mobile client comes back after a restart by
647 // reapplying the stored connector token — it has no boot-time re-provision
648 // path — so its tunnel must survive the restart. (Unlink still deletes it.)
649 if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") {
650 return false;
651 }
652 const token = yield* dependencies.cliTokenManager.getExisting;
653 if (Option.isNone(token)) {
654 return false;
655 }
656 // The link belongs to the relay it was installed against, so target the
657 // persisted URL: T3CODE_RELAY_URL may have changed since the link was made.
658 const relayUrl = yield* dependencies.secrets.get(RELAY_URL_SECRET);
659 if (Option.isNone(relayUrl)) {
660 return false;
661 }
662 const environmentId = yield* dependencies.environment.getEnvironmentId;
663 // Stop the local connector before the relay deletes the tunnel it serves.
664 yield* dependencies.endpointRuntime.applyConfig(null);
665 const response = yield* HttpClientRequest.delete(
666 `${bytesToString(relayUrl.value)}/v1/client/environment-links/${encodeURIComponent(environmentId)}/tunnel`,
667 ).pipe(
668 HttpClientRequest.bearerToken(token.value.accessToken),
669 dependencies.httpClient.execute,
670 Effect.flatMap(HttpClientResponse.filterStatusOk),
671 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
672 withRelayClientTracing,
673 );
674 // ok:false means the relay skipped deletion because a concurrent provision
675 // owns the recorded tunnel now — leave the stored config alone.
676 if (!response.ok) {
677 return false;
678 }
679 // The connector token died with the tunnel. Drop the stored config so the
680 // next start waits for the link reconcile instead of respawning the relay
681 // client with a dead token. Kept when the release request fails: the tunnel
682 // still exists, so the stored token keeps working across the restart.
683 // Only dropped while it is still the config this shutdown released — a fast
684 // restart may already have reconciled and stored a fresh config for its
685 // replacement tunnel, and that one must survive this finalizer.
686 const storedConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
687 if (
688 Option.isSome(storedConfig) &&
689 bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value)
690 ) {
691 yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG);
692 }
693 return true;
694});
695
625const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (696const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* (
626 dependencies: CloudHttpDependencies,697 dependencies: CloudHttpDependencies,
627) {698) {
apps/server/src/server.ts+25-1
...@@ -75,7 +75,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";...@@ -75,7 +75,11 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
75import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";75import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
76import * as ServerSecretStore from "./auth/ServerSecretStore.ts";76import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
77import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";77import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
78import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";78import {
79 connectHttpApiLayer,
80 reconcileDesiredCloudLink,
81 releaseManagedTunnelOnShutdown,
82} from "./cloud/http.ts";
79import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts";83import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts";
80import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";84import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
81import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";85import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
...@@ -458,6 +462,26 @@ export const makeServerLayer = Layer.unwrap(...@@ -458,6 +462,26 @@ export const makeServerLayer = Layer.unwrap(
458 const cloudDesiredLinkReconcileLayer = Layer.effectDiscard(462 const cloudDesiredLinkReconcileLayer = Layer.effectDiscard(
459 Effect.gen(function* () {463 Effect.gen(function* () {
460 if (!hasCloudPublicConfig) return;464 if (!hasCloudPublicConfig) return;
465 // Idle Cloudflare tunnels are billed, so a stopping server releases its
466 // tunnel; the persisted desired link brings one back — same hostname,
467 // fresh tunnel — when the environment starts again. Registered even
468 // when no link is desired yet: a client can link a running server, and
469 // that tunnel needs the same disposal on shutdown.
470 yield* Effect.addFinalizer(() =>
471 releaseManagedTunnelOnShutdown().pipe(
472 Effect.timeout("10 seconds"),
473 Effect.tap((released) =>
474 released ? Effect.logInfo("Released the managed tunnel on shutdown") : Effect.void,
475 ),
476 Effect.catchCause((cause) =>
477 Effect.logWarning(
478 "Failed to release the managed tunnel on shutdown; the next link reuses it",
479 { cause },
480 ),
481 ),
482 Effect.asVoid,
483 ),
484 );
461 if (!(yield* CloudCliState.readCliDesiredCloudLink)) return;485 if (!(yield* CloudCliState.readCliDesiredCloudLink)) return;
462 const server = yield* HttpServer.HttpServer;486 const server = yield* HttpServer.HttpServer;
463 const address = server.address;487 const address = server.address;
infra/relay/src/environments/EnvironmentConnector.test.ts+3
...@@ -188,6 +188,7 @@ function makeAllocations(...@@ -188,6 +188,7 @@ function makeAllocations(
188 tunnelName: "tunnel-name",188 tunnelName: "tunnel-name",
189 dnsRecordId: "dns-record-id",189 dnsRecordId: "dns-record-id",
190 readyAt: "2026-05-25T00:00:00.000Z",190 readyAt: "2026-05-25T00:00:00.000Z",
191 updatedAt: "2026-05-25T00:00:00.000Z",
191 },192 },
192): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] {193): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] {
193 return {194 return {
...@@ -196,6 +197,7 @@ function makeAllocations(...@@ -196,6 +197,7 @@ function makeAllocations(
196 recordTunnel: () => Effect.die("unused"),197 recordTunnel: () => Effect.die("unused"),
197 recordDns: () => Effect.die("unused"),198 recordDns: () => Effect.die("unused"),
198 markReady: () => Effect.die("unused"),199 markReady: () => Effect.die("unused"),
200 claimRelease: () => Effect.die("unused"),
199 remove: () => Effect.die("unused"),201 remove: () => Effect.die("unused"),
200 };202 };
201}203}
...@@ -467,6 +469,7 @@ describe("EnvironmentConnector", () => {...@@ -467,6 +469,7 @@ describe("EnvironmentConnector", () => {
467 tunnelName: "tunnel-name",469 tunnelName: "tunnel-name",
468 dnsRecordId: "dns-record-id",470 dnsRecordId: "dns-record-id",
469 readyAt: null,471 readyAt: null,
472 updatedAt: "2026-05-25T00:00:00.000Z",
470 }),473 }),
471 }),474 }),
472 ),475 ),
infra/relay/src/environments/EnvironmentLinker.test.ts+1
...@@ -137,6 +137,7 @@ function testLayer(input?: {...@@ -137,6 +137,7 @@ function testLayer(input?: {
137 }),137 }),
138 Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {138 Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {
139 deprovision: input?.deprovision ?? (() => Effect.void),139 deprovision: input?.deprovision ?? (() => Effect.void),
140 release: () => Effect.succeed(true),
140 provision: () =>141 provision: () =>
141 Effect.succeed({142 Effect.succeed({
142 endpoint: {143 endpoint: {
infra/relay/src/environments/ManagedEndpointAllocations.ts+54
...@@ -18,6 +18,11 @@ export interface ManagedEndpointAllocation {...@@ -18,6 +18,11 @@ export interface ManagedEndpointAllocation {
18 readonly tunnelName: string;18 readonly tunnelName: string;
19 readonly dnsRecordId: string | null;19 readonly dnsRecordId: string | null;
20 readonly readyAt: string | null;20 readonly readyAt: string | null;
21 /**
22 * Doubles as the allocation's generation marker: every mutation rewrites it,
23 * so `claimRelease` can detect a provision that raced a release.
24 */
25 readonly updatedAt: string;
21}26}
2227
23export function resolveReadyManagedEndpoint(input: {28export function resolveReadyManagedEndpoint(input: {
...@@ -45,6 +50,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro...@@ -45,6 +50,7 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
45 "record-tunnel",50 "record-tunnel",
46 "record-dns",51 "record-dns",
47 "mark-ready",52 "mark-ready",
53 "claim-release",
48 "remove",54 "remove",
49 ]),55 ]),
50 stage: Schema.Literals(["database-request", "resolve-reservation"]),56 stage: Schema.Literals(["database-request", "resolve-reservation"]),
...@@ -80,6 +86,11 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey {...@@ -80,6 +86,11 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey {
80 readonly dnsRecordId: string;86 readonly dnsRecordId: string;
81}87}
8288
89interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey {
90 readonly tunnelId: string;
91 readonly updatedAt: string;
92}
93
83export class ManagedEndpointAllocations extends Context.Service<94export class ManagedEndpointAllocations extends Context.Service<
84 ManagedEndpointAllocations,95 ManagedEndpointAllocations,
85 {96 {
...@@ -98,6 +109,16 @@ export class ManagedEndpointAllocations extends Context.Service<...@@ -98,6 +109,16 @@ export class ManagedEndpointAllocations extends Context.Service<
98 readonly markReady: (109 readonly markReady: (
99 input: ManagedEndpointAllocationKey,110 input: ManagedEndpointAllocationKey,
100 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;111 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;
112 /**
113 * Atomically claims the right to delete the allocation's tunnel: succeeds
114 * only while the recorded tunnel and generation still match what the
115 * caller loaded. A concurrent provision rewrites `updatedAt` when it
116 * records its tunnel, which makes a stale claim fail and keeps the freshly
117 * issued tunnel alive.
118 */
119 readonly claimRelease: (
120 input: ClaimManagedEndpointReleaseInput,
121 ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
101 readonly remove: (122 readonly remove: (
102 input: ManagedEndpointAllocationKey,123 input: ManagedEndpointAllocationKey,
103 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;124 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;
...@@ -112,6 +133,7 @@ const allocationSelection = {...@@ -112,6 +133,7 @@ const allocationSelection = {
112 tunnelName: relayManagedEndpointAllocations.tunnelName,133 tunnelName: relayManagedEndpointAllocations.tunnelName,
113 dnsRecordId: relayManagedEndpointAllocations.dnsRecordId,134 dnsRecordId: relayManagedEndpointAllocations.dnsRecordId,
114 readyAt: relayManagedEndpointAllocations.readyAt,135 readyAt: relayManagedEndpointAllocations.readyAt,
136 updatedAt: relayManagedEndpointAllocations.updatedAt,
115};137};
116138
117const whereAllocation = (input: ManagedEndpointAllocationKey) =>139const whereAllocation = (input: ManagedEndpointAllocationKey) =>
...@@ -267,6 +289,38 @@ export const make = Effect.gen(function* () {...@@ -267,6 +289,38 @@ export const make = Effect.gen(function* () {
267 ),289 ),
268 );290 );
269 }),291 }),
292 claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* (
293 input: ClaimManagedEndpointReleaseInput,
294 ) {
295 const claimed = yield* db
296 .update(relayManagedEndpointAllocations)
297 .set({
298 updatedAt: DateTime.formatIso(yield* DateTime.now),
299 })
300 .where(
301 and(
302 whereAllocation(input),
303 eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId),
304 eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt),
305 ),
306 )
307 .returning({ userId: relayManagedEndpointAllocations.userId })
308 .pipe(
309 Effect.map((rows) => rows.length > 0),
310 Effect.mapError(
311 (cause) =>
312 new ManagedEndpointAllocationPersistenceError({
313 operation: "claim-release",
314 stage: "database-request",
315 userId: input.userId,
316 environmentId: input.environmentId,
317 tunnelId: input.tunnelId,
318 cause,
319 }),
320 ),
321 );
322 return claimed;
323 }),
270 remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (324 remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (
271 input: ManagedEndpointAllocationKey,325 input: ManagedEndpointAllocationKey,
272 ) {326 ) {
infra/relay/src/environments/ManagedEndpointProvider.test.ts+198-14
...@@ -41,7 +41,14 @@ interface DnsCall {...@@ -41,7 +41,14 @@ interface DnsCall {
41}41}
4242
43interface AllocationCall {43interface AllocationCall {
44 readonly operation: "get" | "reserve" | "recordTunnel" | "recordDns" | "markReady" | "remove";44 readonly operation:
45 | "get"
46 | "reserve"
47 | "recordTunnel"
48 | "recordDns"
49 | "markReady"
50 | "claimRelease"
51 | "remove";
45 readonly input: unknown;52 readonly input: unknown;
46}53}
4754
...@@ -148,6 +155,18 @@ function makeDnsClient(...@@ -148,6 +155,18 @@ function makeDnsClient(
148155
149function makeAllocations(calls: AllocationCall[] = []) {156function makeAllocations(calls: AllocationCall[] = []) {
150 const allocations = new Map<string, ManagedEndpointAllocations.ManagedEndpointAllocation>();157 const allocations = new Map<string, ManagedEndpointAllocations.ManagedEndpointAllocation>();
158 let generation = 0;
159 const mutate = (
160 key: string,
161 change: (
162 allocation: ManagedEndpointAllocations.ManagedEndpointAllocation,
163 ) => ManagedEndpointAllocations.ManagedEndpointAllocation,
164 ) => {
165 const allocation = allocations.get(key);
166 if (allocation !== undefined) {
167 allocations.set(key, { ...change(allocation), updatedAt: `generation-${++generation}` });
168 }
169 };
151 return ManagedEndpointAllocations.ManagedEndpointAllocations.of({170 return ManagedEndpointAllocations.ManagedEndpointAllocations.of({
152 get: (input) =>171 get: (input) =>
153 Effect.sync(() => {172 Effect.sync(() => {
...@@ -162,6 +181,7 @@ function makeAllocations(calls: AllocationCall[] = []) {...@@ -162,6 +181,7 @@ function makeAllocations(calls: AllocationCall[] = []) {
162 tunnelId: null,181 tunnelId: null,
163 dnsRecordId: null,182 dnsRecordId: null,
164 readyAt: null,183 readyAt: null,
184 updatedAt: `generation-${++generation}`,
165 };185 };
166 allocations.set(allocationKey(input), allocation);186 allocations.set(allocationKey(input), allocation);
167 return allocation;187 return allocation;
...@@ -169,29 +189,40 @@ function makeAllocations(calls: AllocationCall[] = []) {...@@ -169,29 +189,40 @@ function makeAllocations(calls: AllocationCall[] = []) {
169 recordTunnel: (input) =>189 recordTunnel: (input) =>
170 Effect.sync(() => {190 Effect.sync(() => {
171 calls.push({ operation: "recordTunnel", input });191 calls.push({ operation: "recordTunnel", input });
172 const allocation = allocations.get(allocationKey(input));192 mutate(allocationKey(input), (allocation) => ({
173 if (allocation !== undefined) {193 ...allocation,
174 allocations.set(allocationKey(input), { ...allocation, tunnelId: input.tunnelId });194 tunnelId: input.tunnelId,
175 }195 }));
176 }),196 }),
177 recordDns: (input) =>197 recordDns: (input) =>
178 Effect.sync(() => {198 Effect.sync(() => {
179 calls.push({ operation: "recordDns", input });199 calls.push({ operation: "recordDns", input });
180 const allocation = allocations.get(allocationKey(input));200 mutate(allocationKey(input), (allocation) => ({
181 if (allocation !== undefined) {201 ...allocation,
182 allocations.set(allocationKey(input), { ...allocation, dnsRecordId: input.dnsRecordId });202 dnsRecordId: input.dnsRecordId,
183 }203 }));
184 }),204 }),
185 markReady: (input) =>205 markReady: (input) =>
186 Effect.sync(() => {206 Effect.sync(() => {
187 calls.push({ operation: "markReady", input });207 calls.push({ operation: "markReady", input });
208 mutate(allocationKey(input), (allocation) => ({
209 ...allocation,
210 readyAt: "2026-06-02T00:00:00.000Z",
211 }));
212 }),
213 claimRelease: (input) =>
214 Effect.sync(() => {
215 calls.push({ operation: "claimRelease", input });
188 const allocation = allocations.get(allocationKey(input));216 const allocation = allocations.get(allocationKey(input));
189 if (allocation !== undefined) {217 if (
190 allocations.set(allocationKey(input), {218 allocation === undefined ||
191 ...allocation,219 allocation.tunnelId !== input.tunnelId ||
192 readyAt: "2026-06-02T00:00:00.000Z",220 allocation.updatedAt !== input.updatedAt
193 });221 ) {
222 return false;
194 }223 }
224 mutate(allocationKey(input), (current) => current);
225 return true;
195 }),226 }),
196 remove: (input) =>227 remove: (input) =>
197 Effect.sync(() => {228 Effect.sync(() => {
...@@ -706,6 +737,159 @@ describe("ManagedEndpointProvider", () => {...@@ -706,6 +737,159 @@ describe("ManagedEndpointProvider", () => {
706 },737 },
707 );738 );
708739
740 it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => {
741 const tunnelCalls: TunnelCall[] = [];
742 const dnsCalls: DnsCall[] = [];
743 const allocationCalls: AllocationCall[] = [];
744 const layer = providerLayer(
745 makePersistentTunnelClient(tunnelCalls),
746 makeDnsClient(dnsCalls),
747 makeAllocations(allocationCalls),
748 );
749
750 return Effect.gen(function* () {
751 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
752 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
753 const origin = { localHttpHost: "127.0.0.1", localHttpPort: 3773 } as const;
754 const first = yield* provider.provision({ ...key, origin });
755 const released = yield* provider.release(key);
756 const second = yield* provider.provision({ ...key, origin });
757
758 expect(released).toBe(true);
759 expect(second.endpoint).toEqual(first.endpoint);
760 expect(tunnelCalls.map((call) => call.operation)).toEqual([
761 // first provision
762 "list",
763 "create",
764 "putConfiguration",
765 "getToken",
766 // release deletes only the tunnel...
767 "delete",
768 // ...and the next provision recreates it under the same name
769 "list",
770 "create",
771 "putConfiguration",
772 "getToken",
773 ]);
774 // The DNS record survives the release and is repointed, never deleted.
775 expect(dnsCalls.map((call) => call.operation)).toEqual([
776 "listRecords",
777 "createRecord",
778 "updateRecord",
779 ]);
780 expect(allocationCalls.map((call) => call.operation)).not.toContain("remove");
781 }).pipe(Effect.provide(layer));
782 });
783
784 it.effect("treats an environment without a recorded tunnel as already released", () => {
785 const tunnelCalls: TunnelCall[] = [];
786 const dnsCalls: DnsCall[] = [];
787 const layer = providerLayer(
788 makePersistentTunnelClient(tunnelCalls),
789 makeDnsClient(dnsCalls),
790 makeAllocations(),
791 );
792
793 return Effect.gen(function* () {
794 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
795 const released = yield* provider.release({ userId: "user_ABC", environmentId: "env_ABC" });
796
797 expect(released).toBe(true);
798 expect(tunnelCalls).toEqual([]);
799 expect(dnsCalls).toEqual([]);
800 }).pipe(Effect.provide(layer));
801 });
802
803 it.effect("keeps the tunnel alive when a concurrent provision outdates the release claim", () => {
804 const tunnelCalls: TunnelCall[] = [];
805 const allocations = makeAllocations();
806 // Simulates a provision racing the release: the allocation generation no
807 // longer matches what the release loaded, so the claim fails.
808 const outdated = ManagedEndpointAllocations.ManagedEndpointAllocations.of({
809 ...allocations,
810 claimRelease: () => Effect.succeed(false),
811 });
812 const layer = providerLayer(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), outdated);
813
814 return Effect.gen(function* () {
815 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
816 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
817 yield* provider.provision({
818 ...key,
819 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
820 });
821 const released = yield* provider.release(key);
822
823 // false tells the caller its connector token is still live, so it must
824 // keep its runtime config.
825 expect(released).toBe(false);
826 expect(tunnelCalls.map((call) => call.operation)).toEqual([
827 "list",
828 "create",
829 "putConfiguration",
830 "getToken",
831 ]);
832 }).pipe(Effect.provide(layer));
833 });
834
835 it.effect("treats an already deleted tunnel as successfully released", () => {
836 const notFound = { _tag: "NotFound" } as const;
837 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
838 ...makeTunnelClient(),
839 delete: (tunnelId) =>
840 Effect.fail(
841 new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
842 operation: "delete",
843 tunnelId,
844 cause: notFound,
845 }),
846 ),
847 });
848 const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations());
849
850 return Effect.gen(function* () {
851 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
852 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
853 yield* provider.provision({
854 ...key,
855 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
856 });
857 yield* provider.release(key);
858 }).pipe(Effect.provide(layer));
859 });
860
861 it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => {
862 const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
863 operation: "delete",
864 tunnelId: "tunnel-id",
865 cause: "Cloudflare tunnel deletion failed",
866 });
867 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
868 ...makeTunnelClient(),
869 delete: () => Effect.fail(failure),
870 });
871 const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations());
872
873 return Effect.gen(function* () {
874 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
875 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
876 yield* provider.provision({
877 ...key,
878 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
879 });
880 const error = yield* Effect.flip(provider.release(key));
881
882 expect(error).toMatchObject({
883 _tag: "ManagedEndpointDeprovisioningFailed",
884 stage: "delete-tunnel",
885 userId: key.userId,
886 environmentId: key.environmentId,
887 tunnelId: "tunnel-id",
888 });
889 expect(error.cause).toBe(failure);
890 }).pipe(Effect.provide(layer));
891 });
892
709 it.effect("treats an absent allocation as already deprovisioned", () => {893 it.effect("treats an absent allocation as already deprovisioned", () => {
710 const tunnelCalls: TunnelCall[] = [];894 const tunnelCalls: TunnelCall[] = [];
711 const dnsCalls: DnsCall[] = [];895 const dnsCalls: DnsCall[] = [];
infra/relay/src/environments/ManagedEndpointProvider.ts+83
...@@ -76,6 +76,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M...@@ -76,6 +76,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M
7676
77const ManagedEndpointDeprovisioningStage = Schema.Literals([77const ManagedEndpointDeprovisioningStage = Schema.Literals([
78 "load-allocation",78 "load-allocation",
79 "claim-release",
79 "delete-dns-record",80 "delete-dns-record",
80 "delete-tunnel",81 "delete-tunnel",
81 "remove-allocation",82 "remove-allocation",
...@@ -134,6 +135,22 @@ export class ManagedEndpointProvider extends Context.Service<...@@ -134,6 +135,22 @@ export class ManagedEndpointProvider extends Context.Service<
134 readonly userId: string;135 readonly userId: string;
135 readonly environmentId: string;136 readonly environmentId: string;
136 }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>;137 }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>;
138 /**
139 * Deletes the provisioned Cloudflare tunnel while keeping the allocation
140 * (hostname + tunnel name reservation) and DNS record. Cloudflare bills per
141 * provisioned tunnel, so environments release the tunnel when they shut
142 * down; the next `provision` recreates it under the same name and repoints
143 * the CNAME, preserving the endpoint URL.
144 *
145 * Resolves to whether the caller's connector token is now dead: true when
146 * the tunnel was deleted (or none was recorded to begin with), false when
147 * a concurrent provision outbid the release claim and the recorded tunnel
148 * — and any token issued for it — stays live.
149 */
150 readonly release: (input: {
151 readonly userId: string;
152 readonly environmentId: string;
153 }) => Effect.Effect<boolean, ManagedEndpointDeprovisioningFailed>;
137 }154 }
138>()("t3code-relay/environments/ManagedEndpointProvider") {}155>()("t3code-relay/environments/ManagedEndpointProvider") {}
139156
...@@ -467,6 +484,72 @@ export const make = Effect.gen(function* () {...@@ -467,6 +484,72 @@ export const make = Effect.gen(function* () {
467 ),484 ),
468 );485 );
469 }),486 }),
487 release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) {
488 yield* Effect.annotateCurrentSpan({
489 "relay.user_id": input.userId,
490 "relay.environment_id": input.environmentId,
491 });
492 const allocation = yield* allocations.get(input).pipe(
493 Effect.mapError(
494 (cause) =>
495 new ManagedEndpointDeprovisioningFailed({
496 ...input,
497 stage: "load-allocation",
498 cause,
499 }),
500 ),
501 );
502 const tunnelId = allocation?.tunnelId ?? null;
503 if (allocation === null || tunnelId === null) {
504 return true;
505 }
506 // Claim the release against the allocation's current generation before
507 // touching Cloudflare. A provision racing this release (fast environment
508 // restart) rewrites updatedAt when it records its tunnel, so a stale
509 // claim means the recorded tunnel may already back a fresh connector and
510 // must be left alive. A provision that starts after the claim instead
511 // fails loudly on the deleted tunnel and the client-side retry
512 // provisions a replacement.
513 const claimed = yield* allocations
514 .claimRelease({
515 userId: input.userId,
516 environmentId: input.environmentId,
517 tunnelId,
518 updatedAt: allocation.updatedAt,
519 })
520 .pipe(
521 Effect.mapError(
522 (cause) =>
523 new ManagedEndpointDeprovisioningFailed({
524 ...input,
525 stage: "claim-release",
526 tunnelId,
527 cause,
528 }),
529 ),
530 );
531 if (!claimed) {
532 return false;
533 }
534 yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe(
535 Effect.mapError(
536 (cause) =>
537 new ManagedEndpointDeprovisioningFailed({
538 ...input,
539 stage: "delete-tunnel",
540 tunnelId,
541 cause,
542 }),
543 ),
544 );
545 // The recorded tunnelId is now stale, but the allocation row is left
546 // untouched deliberately: connect/status authorization requires a fully
547 // recorded allocation, and an offline environment must keep reporting
548 // "offline" (health probe fails) rather than "not authorized". The next
549 // provision lists tunnels by name, finds none, creates a replacement and
550 // re-records the fresh id.
551 return true;
552 }),
470 provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) {553 provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) {
471 yield* Effect.annotateCurrentSpan({554 yield* Effect.annotateCurrentSpan({
472 "relay.user_id": input.userId,555 "relay.user_id": input.userId,
infra/relay/src/http/Api.ts+17
...@@ -617,6 +617,23 @@ export const clientApi = HttpApiBuilder.group(...@@ -617,6 +617,23 @@ export const clientApi = HttpApiBuilder.group(
617 }617 }
618 return { ok: unlinked };618 return { ok: unlinked };
619 }, mapRelayCommonApiErrors("not_authorized")),619 }, mapRelayCommonApiErrors("not_authorized")),
620 )
621 .handle(
622 "releaseEnvironmentTunnel",
623 Effect.fn("relay.api.client.releaseEnvironmentTunnel")(function* (args) {
624 const { params } = args;
625 const { userId } = yield* RelayClientPrincipal;
626 // ok mirrors whether the connector token is now dead: false means a
627 // concurrent provision kept the recorded tunnel alive, so the caller
628 // must not discard its runtime config.
629 const released = yield* managedEndpointProvider
630 .release({
631 userId,
632 environmentId: params.environmentId,
633 })
634 .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable")));
635 return { ok: released };
636 }, mapRelayCommonApiErrors("not_authorized")),
620 );637 );
621 }),638 }),
622);639);
packages/contracts/src/relay.ts+15
...@@ -962,6 +962,21 @@ export const RelayClientGroup = HttpApiGroup.make("client")...@@ -962,6 +962,21 @@ export const RelayClientGroup = HttpApiGroup.make("client")
962 success: RelayOkResponse,962 success: RelayOkResponse,
963 error: RelayAuthAndInternalErrors,963 error: RelayAuthAndInternalErrors,
964 }).annotate(OpenApi.Summary, "Unlink an environment"),964 }).annotate(OpenApi.Summary, "Unlink an environment"),
965 HttpApiEndpoint.delete(
966 "releaseEnvironmentTunnel",
967 "/v1/client/environment-links/:environmentId/tunnel",
968 {
969 headers: RelayBearerRequestHeaders,
970 params: RelayEnvironmentUnlinkParams,
971 success: RelayOkResponse,
972 error: RelayAuthAndInternalErrors,
973 },
974 )
975 .annotate(OpenApi.Summary, "Release an environment's managed tunnel")
976 .annotate(
977 OpenApi.Description,
978 "Deletes the provisioned Cloudflare tunnel while keeping the environment link and its hostname reservation, so a later link re-provisions the tunnel under the same URL. Environments call this when they shut down; Cloudflare bills per provisioned tunnel, so idle tunnels should not outlive their environment.",
979 ),
965 )980 )
966 .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.")981 .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.")
967 .middleware(RelayClientAuth);982 .middleware(RelayClientAuth);