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 {...@@ -143,6 +143,8 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
143 return `Relay could not link the environment (${error.reason}).`;143 return `Relay could not link the environment (${error.reason}).`;
144 case "RelayEnvironmentLinkUnavailableError":144 case "RelayEnvironmentLinkUnavailableError":
145 return `Relay cannot provision the managed endpoint (${error.reason}).`;145 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.`;
146 case "RelayAgentActivityPublishProofExpiredError":148 case "RelayAgentActivityPublishProofExpiredError":
147 return "Relay rejected an expired agent activity publish proof.";149 return "Relay rejected an expired agent activity publish proof.";
148 case "RelayAgentActivityPublishProofInvalidError":150 case "RelayAgentActivityPublishProofInvalidError":
apps/web/src/cloud/linkEnvironment.ts+2
...@@ -154,6 +154,8 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {...@@ -154,6 +154,8 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
154 return `Relay could not link the environment (${error.reason}).`;154 return `Relay could not link the environment (${error.reason}).`;
155 case "RelayEnvironmentLinkUnavailableError":155 case "RelayEnvironmentLinkUnavailableError":
156 return `Relay cannot provision the managed endpoint (${error.reason}).`;156 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.`;
157 case "RelayAgentActivityPublishProofExpiredError":159 case "RelayAgentActivityPublishProofExpiredError":
158 return "Relay rejected an expired agent activity publish proof.";160 return "Relay rejected an expired agent activity publish proof.";
159 case "RelayAgentActivityPublishProofInvalidError":161 case "RelayAgentActivityPublishProofInvalidError":
infra/relay/src/environments/ManagedEndpointProvider.test.ts+77
...@@ -9,6 +9,7 @@ import * as Redacted from "effect/Redacted";...@@ -9,6 +9,7 @@ import * as Redacted from "effect/Redacted";
9import * as RelayConfiguration from "../Config.ts";9import * as RelayConfiguration from "../Config.ts";
10import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";10import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
11import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts";11import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts";
12import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts";
1213
13const config = RelayConfiguration.RelayConfiguration.of({14const config = RelayConfiguration.RelayConfiguration.of({
14 relayIssuer: "https://relay.example.test",15 relayIssuer: "https://relay.example.test",
...@@ -200,10 +201,24 @@ function makeAllocations(calls: AllocationCall[] = []) {...@@ -200,10 +201,24 @@ function makeAllocations(calls: AllocationCall[] = []) {
200 });201 });
201}202}
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
203function providerLayer(217function providerLayer(
204 tunnelClient = makeTunnelClient(),218 tunnelClient = makeTunnelClient(),
205 dnsClient = makeDnsClient(),219 dnsClient = makeDnsClient(),
206 allocations = makeAllocations(),220 allocations = makeAllocations(),
221 tunnelLimits = makeTunnelLimits(),
207) {222) {
208 return ManagedEndpointProvider.layer.pipe(223 return ManagedEndpointProvider.layer.pipe(
209 Layer.provideMerge(NodeServices.layer),224 Layer.provideMerge(NodeServices.layer),
...@@ -213,6 +228,7 @@ function providerLayer(...@@ -213,6 +228,7 @@ function providerLayer(
213 Layer.provide(228 Layer.provide(
214 Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations),229 Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations),
215 ),230 ),
231 Layer.provide(Layer.succeed(ManagedTunnelLimits.ManagedTunnelLimits, tunnelLimits)),
216 );232 );
217}233}
218234
...@@ -311,6 +327,67 @@ describe("ManagedEndpointProvider", () => {...@@ -311,6 +327,67 @@ describe("ManagedEndpointProvider", () => {
311 );327 );
312 });328 });
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
314 it.effect("uses stage-scoped stable names without leaking unusual environment ids", () => {391 it.effect("uses stage-scoped stable names without leaking unusual environment ids", () => {
315 const tunnelCalls: TunnelCall[] = [];392 const tunnelCalls: TunnelCall[] = [];
316393
infra/relay/src/environments/ManagedEndpointProvider.ts+25-1
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 managedEndpointTunnelName,23 managedEndpointTunnelName,
24} from "../deploymentConfig.ts";24} from "../deploymentConfig.ts";
25import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";25import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
26import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts";
2627
27export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass<ManagedEndpointProvisioningNotConfigured>()(28export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass<ManagedEndpointProvisioningNotConfigured>()(
28 "ManagedEndpointProvisioningNotConfigured",29 "ManagedEndpointProvisioningNotConfigured",
...@@ -41,6 +42,7 @@ export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedError...@@ -41,6 +42,7 @@ export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedError
4142
42const ManagedEndpointProvisioningStage = Schema.Literals([43const ManagedEndpointProvisioningStage = Schema.Literals([
43 "derive-environment-hash",44 "derive-environment-hash",
45 "check-tunnel-limit",
44 "reserve-allocation",46 "reserve-allocation",
45 "ensure-tunnel",47 "ensure-tunnel",
46 "validate-tunnel-response",48 "validate-tunnel-response",
...@@ -112,7 +114,8 @@ export class ManagedEndpointOriginNotAllowed extends Schema.TaggedErrorClass<Man...@@ -112,7 +114,8 @@ export class ManagedEndpointOriginNotAllowed extends Schema.TaggedErrorClass<Man
112export type ManagedEndpointProviderError =114export type ManagedEndpointProviderError =
113 | ManagedEndpointProvisioningNotConfigured115 | ManagedEndpointProvisioningNotConfigured
114 | ManagedEndpointProvisioningFailed116 | ManagedEndpointProvisioningFailed
115 | ManagedEndpointOriginNotAllowed;117 | ManagedEndpointOriginNotAllowed
118 | ManagedTunnelLimits.ManagedTunnelLimitExceeded;
116119
117export interface ManagedEndpointProvisioningResult {120export interface ManagedEndpointProvisioningResult {
118 readonly endpoint: RelayManagedEndpoint;121 readonly endpoint: RelayManagedEndpoint;
...@@ -331,6 +334,7 @@ export const make = Effect.gen(function* () {...@@ -331,6 +334,7 @@ export const make = Effect.gen(function* () {
331 const tunnels = yield* ManagedEndpointTunnelClient;334 const tunnels = yield* ManagedEndpointTunnelClient;
332 const dns = yield* ManagedEndpointDnsClient;335 const dns = yield* ManagedEndpointDnsClient;
333 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;336 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
337 const tunnelLimits = yield* ManagedTunnelLimits.ManagedTunnelLimits;
334338
335 const updateExistingDnsRecords = Effect.fnUntraced(function* (339 const updateExistingDnsRecords = Effect.fnUntraced(function* (
336 records: ReadonlyArray<{ readonly id: string }>,340 records: ReadonlyArray<{ readonly id: string }>,
...@@ -504,6 +508,26 @@ export const make = Effect.gen(function* () {...@@ -504,6 +508,26 @@ export const make = Effect.gen(function* () {
504 environmentHash,508 environmentHash,
505 );509 );
506 const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash);510 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 );
507 const allocation = yield* allocations531 const allocation = yield* allocations
508 .reserve({532 .reserve({
509 userId: input.userId,533 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 {...@@ -43,6 +43,7 @@ import {
43 RelayEnvironmentLinkProofExpiredError,43 RelayEnvironmentLinkProofExpiredError,
44 RelayEnvironmentLinkProofInvalidError,44 RelayEnvironmentLinkProofInvalidError,
45 RelayEnvironmentLinkUnavailableError,45 RelayEnvironmentLinkUnavailableError,
46 RelayEnvironmentLinkLimitExceededError,
46 RelayEnvironmentPrincipal,47 RelayEnvironmentPrincipal,
47 type RelayEnvironmentConnectRequest,48 type RelayEnvironmentConnectRequest,
48 type RelayDpopAccessTokenScope,49 type RelayDpopAccessTokenScope,
...@@ -536,6 +537,12 @@ export const clientApi = HttpApiBuilder.group(...@@ -536,6 +537,12 @@ export const clientApi = HttpApiBuilder.group(
536 reason: "origin_not_allowed",537 reason: "origin_not_allowed",
537 traceId,538 traceId,
538 }),539 }),
540 ManagedTunnelLimitExceeded: (limitError, traceId) =>
541 new RelayEnvironmentLinkLimitExceededError({
542 code: "environment_link_limit_exceeded",
543 maxTunnels: limitError.maxTunnels,
544 traceId,
545 }),
539 EnvironmentLinkUpsertPersistenceError: (_error, traceId) =>546 EnvironmentLinkUpsertPersistenceError: (_error, traceId) =>
540 new RelayEnvironmentLinkFailedError({547 new RelayEnvironmentLinkFailedError({
541 code: "environment_link_failed",548 code: "environment_link_failed",
infra/relay/src/persistence/schema.ts+7
...@@ -105,6 +105,13 @@ export const relayManagedEndpointAllocations = pgTable(...@@ -105,6 +105,13 @@ export const relayManagedEndpointAllocations = pgTable(
105 ],105 ],
106);106);
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
108export const relayEnvironmentCredentials = pgTable(115export const relayEnvironmentCredentials = pgTable(
109 "relay_environment_credentials",116 "relay_environment_credentials",
110 {117 {
infra/relay/src/worker.ts+8-1
...@@ -55,6 +55,7 @@ import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts";...@@ -55,6 +55,7 @@ import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts";
55import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts";55import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts";
56import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts";56import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts";
57import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts";57import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts";
58import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts";
58import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts";59import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts";
5960
60const webcryptoLayer = Layer.succeed(61const webcryptoLayer = Layer.succeed(
...@@ -205,7 +206,13 @@ export default class Api extends Cloudflare.Worker<Api>()(...@@ -205,7 +206,13 @@ export default class Api extends Cloudflare.Worker<Api>()(
205 Layer.provideMerge(AgentActivityRows.layer),206 Layer.provideMerge(AgentActivityRows.layer),
206 Layer.provideMerge(Devices.layer),207 Layer.provideMerge(Devices.layer),
207 Layer.provideMerge(EnvironmentCredentials.layer),208 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 ),
209 Layer.provideMerge(LiveActivities.layer),216 Layer.provideMerge(LiveActivities.layer),
210 Layer.provideMerge(DeliveryAttempts.layer),217 Layer.provideMerge(DeliveryAttempts.layer),
211 Layer.provideMerge(RelayTokens.layer),218 Layer.provideMerge(RelayTokens.layer),
packages/client-runtime/src/connection/errors.ts+1
...@@ -45,6 +45,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError...@@ -45,6 +45,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError
45 });45 });
46 case "RelayEnvironmentConnectNotAuthorizedError":46 case "RelayEnvironmentConnectNotAuthorizedError":
47 case "RelayEnvironmentLinkProofInvalidError":47 case "RelayEnvironmentLinkProofInvalidError":
48 case "RelayEnvironmentLinkLimitExceededError":
48 return new ConnectionBlockedError({49 return new ConnectionBlockedError({
49 reason: "permission",50 reason: "permission",
50 detail: error.message,51 detail: error.message,
packages/contracts/src/relay.ts+16
...@@ -439,6 +439,20 @@ export class RelayEnvironmentLinkUnavailableError extends Schema.TaggedErrorClas...@@ -439,6 +439,20 @@ export class RelayEnvironmentLinkUnavailableError extends Schema.TaggedErrorClas
439 }439 }
440}440}
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
442export class RelayAgentActivityPublishProofExpiredError extends Schema.TaggedErrorClass<RelayAgentActivityPublishProofExpiredError>()(456export class RelayAgentActivityPublishProofExpiredError extends Schema.TaggedErrorClass<RelayAgentActivityPublishProofExpiredError>()(
443 "RelayAgentActivityPublishProofExpiredError",457 "RelayAgentActivityPublishProofExpiredError",
444 {458 {
...@@ -489,6 +503,7 @@ export const RelayProtectedError = Schema.Union([...@@ -489,6 +503,7 @@ export const RelayProtectedError = Schema.Union([
489 RelayEnvironmentEndpointTimedOutError,503 RelayEnvironmentEndpointTimedOutError,
490 RelayEnvironmentLinkFailedError,504 RelayEnvironmentLinkFailedError,
491 RelayEnvironmentLinkUnavailableError,505 RelayEnvironmentLinkUnavailableError,
506 RelayEnvironmentLinkLimitExceededError,
492 RelayAgentActivityPublishProofExpiredError,507 RelayAgentActivityPublishProofExpiredError,
493 RelayAgentActivityPublishProofInvalidError,508 RelayAgentActivityPublishProofInvalidError,
494 RelayInternalError,509 RelayInternalError,
...@@ -502,6 +517,7 @@ const RelayEnvironmentLinkErrors = [...@@ -502,6 +517,7 @@ const RelayEnvironmentLinkErrors = [
502 RelayEnvironmentLinkProofExpiredError,517 RelayEnvironmentLinkProofExpiredError,
503 RelayEnvironmentLinkProofInvalidError,518 RelayEnvironmentLinkProofInvalidError,
504 RelayEnvironmentLinkUnavailableError,519 RelayEnvironmentLinkUnavailableError,
520 RelayEnvironmentLinkLimitExceededError,
505 RelayEnvironmentLinkFailedError,521 RelayEnvironmentLinkFailedError,
506 RelayInternalError,522 RelayInternalError,
507] as const;523] as const;