authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 13:51:31+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 13:51:31+02:00
loga78f245dffc3123af36fb66cd2c95aac1a48ffd9
tree69c7c4bfdf29e8ff1e5031943996694730e9d43e
parent23ea08daf27a2f56b85e605dd3d36f66bd1a132d
signature Signed by PGP key B5690EEEBB952194

feat(relay): limit managed tunnels per user (#4530)

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

11 files changed, 404 insertions(+), 2 deletions(-)

apps/mobile/src/features/cloud/linkEnvironment.ts+2
......@@ -143,6 +143,8 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
143143 return `Relay could not link the environment (${error.reason}).`;
144144 case "RelayEnvironmentLinkUnavailableError":
145145 return `Relay cannot provision the managed endpoint (${error.reason}).`;
146 case "RelayEnvironmentLinkLimitExceededError":
147 return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
146148 case "RelayAgentActivityPublishProofExpiredError":
147149 return "Relay rejected an expired agent activity publish proof.";
148150 case "RelayAgentActivityPublishProofInvalidError":
apps/web/src/cloud/linkEnvironment.ts+2
......@@ -154,6 +154,8 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
154154 return `Relay could not link the environment (${error.reason}).`;
155155 case "RelayEnvironmentLinkUnavailableError":
156156 return `Relay cannot provision the managed endpoint (${error.reason}).`;
157 case "RelayEnvironmentLinkLimitExceededError":
158 return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
157159 case "RelayAgentActivityPublishProofExpiredError":
158160 return "Relay rejected an expired agent activity publish proof.";
159161 case "RelayAgentActivityPublishProofInvalidError":
infra/relay/src/environments/ManagedEndpointProvider.test.ts+77
......@@ -9,6 +9,7 @@ import * as Redacted from "effect/Redacted";
99import * as RelayConfiguration from "../Config.ts";
1010import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
1111import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts";
12import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts";
1213
1314const config = RelayConfiguration.RelayConfiguration.of({
1415 relayIssuer: "https://relay.example.test",
......@@ -200,10 +201,24 @@ function makeAllocations(calls: AllocationCall[] = []) {
200201 });
201202}
202203
204function makeTunnelLimits(
205 calls: Array<{ readonly userId: string; readonly environmentId: string }> = [],
206 result: ManagedTunnelLimits.ManagedTunnelLimitExceeded | null = null,
207) {
208 return ManagedTunnelLimits.ManagedTunnelLimits.of({
209 ensureCapacity: (input) =>
210 Effect.suspend(() => {
211 calls.push(input);
212 return result === null ? Effect.void : Effect.fail(result);
213 }),
214 });
215}
216
203217function providerLayer(
204218 tunnelClient = makeTunnelClient(),
205219 dnsClient = makeDnsClient(),
206220 allocations = makeAllocations(),
221 tunnelLimits = makeTunnelLimits(),
207222) {
208223 return ManagedEndpointProvider.layer.pipe(
209224 Layer.provideMerge(NodeServices.layer),
......@@ -213,6 +228,7 @@ function providerLayer(
213228 Layer.provide(
214229 Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations),
215230 ),
231 Layer.provide(Layer.succeed(ManagedTunnelLimits.ManagedTunnelLimits, tunnelLimits)),
216232 );
217233}
218234
......@@ -311,6 +327,67 @@ describe("ManagedEndpointProvider", () => {
311327 );
312328 });
313329
330 it.effect("checks the managed tunnel limit before reserving an allocation", () => {
331 const limitCalls: Array<{ readonly userId: string; readonly environmentId: string }> = [];
332
333 return Effect.gen(function* () {
334 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
335 yield* provider.provision({
336 userId: "user_ABC",
337 environmentId: "env_ABC",
338 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
339 });
340
341 expect(limitCalls).toEqual([{ userId: "user_ABC", environmentId: "env_ABC" }]);
342 }).pipe(
343 Effect.provide(
344 providerLayer(
345 makeTunnelClient(),
346 makeDnsClient(),
347 makeAllocations(),
348 makeTunnelLimits(limitCalls),
349 ),
350 ),
351 );
352 });
353
354 it.effect("refuses to provision past the managed tunnel limit without side effects", () => {
355 const tunnelCalls: TunnelCall[] = [];
356 const dnsCalls: DnsCall[] = [];
357 const allocationCalls: AllocationCall[] = [];
358 const exceeded = new ManagedTunnelLimits.ManagedTunnelLimitExceeded({
359 userId: "user_ABC",
360 environmentId: "env_ABC",
361 maxTunnels: 10,
362 activeTunnels: 10,
363 });
364
365 return Effect.gen(function* () {
366 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
367 const error = yield* Effect.flip(
368 provider.provision({
369 userId: "user_ABC",
370 environmentId: "env_ABC",
371 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
372 }),
373 );
374
375 expect(error).toBe(exceeded);
376 expect(tunnelCalls).toEqual([]);
377 expect(dnsCalls).toEqual([]);
378 expect(allocationCalls).toEqual([]);
379 }).pipe(
380 Effect.provide(
381 providerLayer(
382 makeTunnelClient(tunnelCalls),
383 makeDnsClient(dnsCalls),
384 makeAllocations(allocationCalls),
385 makeTunnelLimits([], exceeded),
386 ),
387 ),
388 );
389 });
390
314391 it.effect("uses stage-scoped stable names without leaking unusual environment ids", () => {
315392 const tunnelCalls: TunnelCall[] = [];
316393
infra/relay/src/environments/ManagedEndpointProvider.ts+25-1
......@@ -23,6 +23,7 @@ import {
2323 managedEndpointTunnelName,
2424} from "../deploymentConfig.ts";
2525import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
26import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts";
2627
2728export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass<ManagedEndpointProvisioningNotConfigured>()(
2829 "ManagedEndpointProvisioningNotConfigured",
......@@ -41,6 +42,7 @@ export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedError
4142
4243const ManagedEndpointProvisioningStage = Schema.Literals([
4344 "derive-environment-hash",
45 "check-tunnel-limit",
4446 "reserve-allocation",
4547 "ensure-tunnel",
4648 "validate-tunnel-response",
......@@ -112,7 +114,8 @@ export class ManagedEndpointOriginNotAllowed extends Schema.TaggedErrorClass<Man
112114export type ManagedEndpointProviderError =
113115 | ManagedEndpointProvisioningNotConfigured
114116 | ManagedEndpointProvisioningFailed
115 | ManagedEndpointOriginNotAllowed;
117 | ManagedEndpointOriginNotAllowed
118 | ManagedTunnelLimits.ManagedTunnelLimitExceeded;
116119
117120export interface ManagedEndpointProvisioningResult {
118121 readonly endpoint: RelayManagedEndpoint;
......@@ -331,6 +334,7 @@ export const make = Effect.gen(function* () {
331334 const tunnels = yield* ManagedEndpointTunnelClient;
332335 const dns = yield* ManagedEndpointDnsClient;
333336 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
337 const tunnelLimits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
334338
335339 const updateExistingDnsRecords = Effect.fnUntraced(function* (
336340 records: ReadonlyArray<{ readonly id: string }>,
......@@ -504,6 +508,26 @@ export const make = Effect.gen(function* () {
504508 environmentHash,
505509 );
506510 const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash);
511 yield* tunnelLimits
512 .ensureCapacity({
513 userId: input.userId,
514 environmentId: input.environmentId,
515 })
516 .pipe(
517 Effect.catchTags({
518 ManagedTunnelLimitPersistenceError: (cause) =>
519 Effect.fail(
520 new ManagedEndpointProvisioningFailed({
521 userId: input.userId,
522 environmentId: input.environmentId,
523 stage: "check-tunnel-limit",
524 hostname: requestedHostname,
525 tunnelName: requestedTunnelName,
526 cause,
527 }),
528 ),
529 }),
530 );
507531 const allocation = yield* allocations
508532 .reserve({
509533 userId: input.userId,
infra/relay/src/environments/ManagedTunnelLimits.test.ts created+145
......@@ -0,0 +1,145 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as RelayDb from "../db.ts";
6import {
7 relayManagedEndpointAllocations,
8 relayManagedTunnelLimits,
9} from "../persistence/schema.ts";
10import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts";
11
12const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
13 ManagedTunnelLimits.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db)));
14
15function makeFakeDb(input: {
16 readonly overrideRows?: Effect.Effect<ReadonlyArray<{ readonly maxTunnels: number }>, Error>;
17 readonly countRows?: Effect.Effect<ReadonlyArray<{ readonly activeTunnels: number }>, Error>;
18}) {
19 return {
20 select: () => ({
21 from: (table: unknown) => {
22 if (table === relayManagedTunnelLimits) {
23 return {
24 where: () => ({
25 limit: () => input.overrideRows ?? Effect.succeed([]),
26 }),
27 };
28 }
29 expect(table).toBe(relayManagedEndpointAllocations);
30 return {
31 where: () => input.countRows ?? Effect.succeed([{ activeTunnels: 0 }]),
32 };
33 },
34 }),
35 } as unknown as RelayDb.RelayDb["Service"];
36}
37
38describe("ManagedTunnelLimits", () => {
39 it.effect("allows provisioning below the default limit", () => {
40 const fakeDb = makeFakeDb({
41 countRows: Effect.succeed([
42 { activeTunnels: ManagedTunnelLimits.DEFAULT_MANAGED_TUNNEL_LIMIT - 1 },
43 ]),
44 });
45
46 return Effect.gen(function* () {
47 const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
48 yield* limits.ensureCapacity({ userId: "user-1", environmentId: "environment-1" });
49 }).pipe(Effect.provide(layerWithDb(fakeDb)));
50 });
51
52 it.effect("rejects provisioning at the default limit of 3", () => {
53 const fakeDb = makeFakeDb({
54 countRows: Effect.succeed([{ activeTunnels: 3 }]),
55 });
56
57 return Effect.gen(function* () {
58 const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
59 const error = yield* Effect.flip(
60 limits.ensureCapacity({ userId: "user-1", environmentId: "environment-1" }),
61 );
62
63 expect(error).toMatchObject({
64 _tag: "ManagedTunnelLimitExceeded",
65 userId: "user-1",
66 environmentId: "environment-1",
67 maxTunnels: 3,
68 activeTunnels: 3,
69 });
70 }).pipe(Effect.provide(layerWithDb(fakeDb)));
71 });
72
73 it.effect("honors a per-user override above the default", () => {
74 const fakeDb = makeFakeDb({
75 overrideRows: Effect.succeed([{ maxTunnels: 25 }]),
76 countRows: Effect.succeed([{ activeTunnels: 10 }]),
77 });
78
79 return Effect.gen(function* () {
80 const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
81 yield* limits.ensureCapacity({ userId: "user-1", environmentId: "environment-1" });
82 }).pipe(Effect.provide(layerWithDb(fakeDb)));
83 });
84
85 it.effect("honors a per-user override below the default", () => {
86 const fakeDb = makeFakeDb({
87 overrideRows: Effect.succeed([{ maxTunnels: 1 }]),
88 countRows: Effect.succeed([{ activeTunnels: 1 }]),
89 });
90
91 return Effect.gen(function* () {
92 const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
93 const error = yield* Effect.flip(
94 limits.ensureCapacity({ userId: "user-1", environmentId: "environment-1" }),
95 );
96
97 expect(error).toMatchObject({
98 _tag: "ManagedTunnelLimitExceeded",
99 maxTunnels: 1,
100 activeTunnels: 1,
101 });
102 }).pipe(Effect.provide(layerWithDb(fakeDb)));
103 });
104
105 it.effect("retains database failures with operation and user identity", () => {
106 const cause = new Error("database unavailable");
107 const fakeDb = makeFakeDb({
108 overrideRows: Effect.fail(cause),
109 });
110
111 return Effect.gen(function* () {
112 const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
113 const error = yield* Effect.flip(
114 limits.ensureCapacity({ userId: "user-1", environmentId: "environment-1" }),
115 );
116
117 expect(error).toMatchObject({
118 _tag: "ManagedTunnelLimitPersistenceError",
119 operation: "load-limit",
120 userId: "user-1",
121 });
122 expect(error).toHaveProperty("cause", cause);
123 }).pipe(Effect.provide(layerWithDb(fakeDb)));
124 });
125
126 it.effect("retains count failures with operation and user identity", () => {
127 const cause = new Error("database unavailable");
128 const fakeDb = makeFakeDb({
129 countRows: Effect.fail(cause),
130 });
131
132 return Effect.gen(function* () {
133 const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
134 const error = yield* Effect.flip(
135 limits.ensureCapacity({ userId: "user-1", environmentId: "environment-1" }),
136 );
137
138 expect(error).toMatchObject({
139 _tag: "ManagedTunnelLimitPersistenceError",
140 operation: "count-tunnels",
141 userId: "user-1",
142 });
143 }).pipe(Effect.provide(layerWithDb(fakeDb)));
144 });
145});
infra/relay/src/environments/ManagedTunnelLimits.ts created+114
......@@ -0,0 +1,114 @@
1import { and, count, eq, ne } from "drizzle-orm";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6
7import * as RelayDb from "../db.ts";
8import {
9 relayManagedEndpointAllocations,
10 relayManagedTunnelLimits,
11} from "../persistence/schema.ts";
12
13/**
14 * Managed tunnels a user may hold at once unless a row in
15 * `relay_managed_tunnel_limits` overrides it for that user.
16 */
17export const DEFAULT_MANAGED_TUNNEL_LIMIT = 3;
18
19export class ManagedTunnelLimitPersistenceError extends Schema.TaggedErrorClass<ManagedTunnelLimitPersistenceError>()(
20 "ManagedTunnelLimitPersistenceError",
21 {
22 operation: Schema.Literals(["load-limit", "count-tunnels"]),
23 userId: Schema.String,
24 cause: Schema.Defect(),
25 },
26) {
27 override get message(): string {
28 return `Managed tunnel limit '${this.operation}' failed for user '${this.userId}'`;
29 }
30}
31
32export class ManagedTunnelLimitExceeded extends Schema.TaggedErrorClass<ManagedTunnelLimitExceeded>()(
33 "ManagedTunnelLimitExceeded",
34 {
35 userId: Schema.String,
36 environmentId: Schema.String,
37 maxTunnels: Schema.Number,
38 activeTunnels: Schema.Number,
39 },
40) {
41 override get message(): string {
42 return `Managed tunnel limit reached for user '${this.userId}': ${this.activeTunnels} of ${this.maxTunnels} tunnels in use`;
43 }
44}
45
46export class ManagedTunnelLimits extends Context.Service<
47 ManagedTunnelLimits,
48 {
49 readonly ensureCapacity: (input: {
50 readonly userId: string;
51 readonly environmentId: string;
52 }) => Effect.Effect<void, ManagedTunnelLimitExceeded | ManagedTunnelLimitPersistenceError>;
53 }
54>()("t3code-relay/environments/ManagedTunnelLimits") {}
55
56export const make = Effect.gen(function* () {
57 const db = yield* RelayDb.RelayDb;
58
59 return ManagedTunnelLimits.of({
60 ensureCapacity: Effect.fn("relay.managed_tunnel_limits.ensure_capacity")(function* (input) {
61 const overrides = yield* db
62 .select({ maxTunnels: relayManagedTunnelLimits.maxTunnels })
63 .from(relayManagedTunnelLimits)
64 .where(eq(relayManagedTunnelLimits.userId, input.userId))
65 .limit(1)
66 .pipe(
67 Effect.mapError(
68 (cause) =>
69 new ManagedTunnelLimitPersistenceError({
70 operation: "load-limit",
71 userId: input.userId,
72 cause,
73 }),
74 ),
75 );
76 const maxTunnels = overrides[0]?.maxTunnels ?? DEFAULT_MANAGED_TUNNEL_LIMIT;
77
78 // Allocations already held for this environment are excluded so that
79 // re-linking an environment the user already has a tunnel for stays
80 // idempotent even when the account is at its limit.
81 const counted = yield* db
82 .select({ activeTunnels: count() })
83 .from(relayManagedEndpointAllocations)
84 .where(
85 and(
86 eq(relayManagedEndpointAllocations.userId, input.userId),
87 ne(relayManagedEndpointAllocations.environmentId, input.environmentId),
88 ),
89 )
90 .pipe(
91 Effect.mapError(
92 (cause) =>
93 new ManagedTunnelLimitPersistenceError({
94 operation: "count-tunnels",
95 userId: input.userId,
96 cause,
97 }),
98 ),
99 );
100 const activeTunnels = counted[0]?.activeTunnels ?? 0;
101
102 if (activeTunnels >= maxTunnels) {
103 return yield* new ManagedTunnelLimitExceeded({
104 userId: input.userId,
105 environmentId: input.environmentId,
106 maxTunnels,
107 activeTunnels,
108 });
109 }
110 }),
111 });
112});
113
114export const layer = Layer.effect(ManagedTunnelLimits, make);
infra/relay/src/http/Api.ts+7
......@@ -43,6 +43,7 @@ import {
4343 RelayEnvironmentLinkProofExpiredError,
4444 RelayEnvironmentLinkProofInvalidError,
4545 RelayEnvironmentLinkUnavailableError,
46 RelayEnvironmentLinkLimitExceededError,
4647 RelayEnvironmentPrincipal,
4748 type RelayEnvironmentConnectRequest,
4849 type RelayDpopAccessTokenScope,
......@@ -536,6 +537,12 @@ export const clientApi = HttpApiBuilder.group(
536537 reason: "origin_not_allowed",
537538 traceId,
538539 }),
540 ManagedTunnelLimitExceeded: (limitError, traceId) =>
541 new RelayEnvironmentLinkLimitExceededError({
542 code: "environment_link_limit_exceeded",
543 maxTunnels: limitError.maxTunnels,
544 traceId,
545 }),
539546 EnvironmentLinkUpsertPersistenceError: (_error, traceId) =>
540547 new RelayEnvironmentLinkFailedError({
541548 code: "environment_link_failed",
infra/relay/src/persistence/schema.ts+7
......@@ -105,6 +105,13 @@ export const relayManagedEndpointAllocations = pgTable(
105105 ],
106106);
107107
108export const relayManagedTunnelLimits = pgTable("relay_managed_tunnel_limits", {
109 userId: varchar("user_id", { length: 191 }).primaryKey(),
110 maxTunnels: integer("max_tunnels").notNull(),
111 createdAt: varchar("created_at", { length: 64 }).notNull(),
112 updatedAt: varchar("updated_at", { length: 64 }).notNull(),
113});
114
108115export const relayEnvironmentCredentials = pgTable(
109116 "relay_environment_credentials",
110117 {
infra/relay/src/worker.ts+8-1
......@@ -55,6 +55,7 @@ import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts";
5555import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts";
5656import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts";
5757import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts";
58import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts";
5859import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts";
5960
6061const webcryptoLayer = Layer.succeed(
......@@ -205,7 +206,13 @@ export default class Api extends Cloudflare.Worker<Api>()(
205206 Layer.provideMerge(AgentActivityRows.layer),
206207 Layer.provideMerge(Devices.layer),
207208 Layer.provideMerge(EnvironmentCredentials.layer),
208 Layer.provideMerge(Layer.mergeAll(EnvironmentLinks.layer, ManagedEndpointAllocations.layer)),
209 Layer.provideMerge(
210 Layer.mergeAll(
211 EnvironmentLinks.layer,
212 ManagedEndpointAllocations.layer,
213 ManagedTunnelLimits.layer,
214 ),
215 ),
209216 Layer.provideMerge(LiveActivities.layer),
210217 Layer.provideMerge(DeliveryAttempts.layer),
211218 Layer.provideMerge(RelayTokens.layer),
packages/client-runtime/src/connection/errors.ts+1
......@@ -45,6 +45,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
4545 });
4646 case "RelayEnvironmentConnectNotAuthorizedError":
4747 case "RelayEnvironmentLinkProofInvalidError":
48 case "RelayEnvironmentLinkLimitExceededError":
4849 return new ConnectionBlockedError({
4950 reason: "permission",
5051 detail: error.message,
packages/contracts/src/relay.ts+16
......@@ -439,6 +439,20 @@ export class RelayEnvironmentLinkUnavailableError extends Schema.TaggedErrorClas
439439 }
440440}
441441
442export class RelayEnvironmentLinkLimitExceededError extends Schema.TaggedErrorClass<RelayEnvironmentLinkLimitExceededError>()(
443 "RelayEnvironmentLinkLimitExceededError",
444 {
445 code: Schema.Literal("environment_link_limit_exceeded"),
446 maxTunnels: Schema.Number,
447 traceId: TrimmedNonEmptyString,
448 },
449 { httpApiStatus: 403 },
450) {
451 override get message(): string {
452 return `Relay managed tunnel limit reached: this account allows at most ${this.maxTunnels} tunnels`;
453 }
454}
455
442456export class RelayAgentActivityPublishProofExpiredError extends Schema.TaggedErrorClass<RelayAgentActivityPublishProofExpiredError>()(
443457 "RelayAgentActivityPublishProofExpiredError",
444458 {
......@@ -489,6 +503,7 @@ export const RelayProtectedError = Schema.Union([
489503 RelayEnvironmentEndpointTimedOutError,
490504 RelayEnvironmentLinkFailedError,
491505 RelayEnvironmentLinkUnavailableError,
506 RelayEnvironmentLinkLimitExceededError,
492507 RelayAgentActivityPublishProofExpiredError,
493508 RelayAgentActivityPublishProofInvalidError,
494509 RelayInternalError,
......@@ -502,6 +517,7 @@ const RelayEnvironmentLinkErrors = [
502517 RelayEnvironmentLinkProofExpiredError,
503518 RelayEnvironmentLinkProofInvalidError,
504519 RelayEnvironmentLinkUnavailableError,
520 RelayEnvironmentLinkLimitExceededError,
505521 RelayEnvironmentLinkFailedError,
506522 RelayInternalError,
507523] as const;