| author | |
| committer | |
| log | a78f245dffc3123af36fb66cd2c95aac1a48ffd9 |
| tree | 69c7c4bfdf29e8ff1e5031943996694730e9d43e |
| parent | 23ea08daf27a2f56b85e605dd3d36f66bd1a132d |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 9 | import * as RelayConfiguration from "../Config.ts"; | 9 | import * as RelayConfiguration from "../Config.ts"; |
| 10 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; | 10 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; |
| 11 | import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; | 11 | import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; |
| 12 | import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts"; | ||
| 12 | 13 | ||
| 13 | const config = RelayConfiguration.RelayConfiguration.of({ | 14 | const 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 | } |
| 202 | 203 | ||
| 204 | function 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 | |||
| 203 | function providerLayer( | 217 | function 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 | } |
| 218 | 234 | ||
| ... | @@ -311,6 +327,67 @@ describe("ManagedEndpointProvider", () => { | ... | @@ -311,6 +327,67 @@ describe("ManagedEndpointProvider", () => { |
| 311 | ); | 327 | ); |
| 312 | }); | 328 | }); |
| 313 | 329 | ||
| 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[] = []; |
| 316 | 393 |
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"; |
| 25 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; | 25 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; |
| 26 | import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts"; | ||
| 26 | 27 | ||
| 27 | export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass<ManagedEndpointProvisioningNotConfigured>()( | 28 | export 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 |
| 41 | 42 | ||
| 42 | const ManagedEndpointProvisioningStage = Schema.Literals([ | 43 | const 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 |
| 112 | export type ManagedEndpointProviderError = | 114 | export type ManagedEndpointProviderError = |
| 113 | | ManagedEndpointProvisioningNotConfigured | 115 | | ManagedEndpointProvisioningNotConfigured |
| 114 | | ManagedEndpointProvisioningFailed | 116 | | ManagedEndpointProvisioningFailed |
| 115 | | ManagedEndpointOriginNotAllowed; | 117 | | ManagedEndpointOriginNotAllowed |
| 118 | | ManagedTunnelLimits.ManagedTunnelLimitExceeded; | ||
| 116 | 119 | ||
| 117 | export interface ManagedEndpointProvisioningResult { | 120 | export 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; | ||
| 334 | 338 | ||
| 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* allocations | 531 | 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | |||
| 5 | import * as RelayDb from "../db.ts"; | ||
| 6 | import { | ||
| 7 | relayManagedEndpointAllocations, | ||
| 8 | relayManagedTunnelLimits, | ||
| 9 | } from "../persistence/schema.ts"; | ||
| 10 | import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts"; | ||
| 11 | |||
| 12 | const layerWithDb = (db: RelayDb.RelayDb["Service"]) => | ||
| 13 | ManagedTunnelLimits.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); | ||
| 14 | |||
| 15 | function 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 | |||
| 38 | describe("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 @@ | ||
| 1 | import { and, count, eq, ne } from "drizzle-orm"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | import * as RelayDb from "../db.ts"; | ||
| 8 | import { | ||
| 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 | */ | ||
| 17 | export const DEFAULT_MANAGED_TUNNEL_LIMIT = 3; | ||
| 18 | |||
| 19 | export 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 | |||
| 32 | export 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 | |||
| 46 | export 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 | |||
| 56 | export 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 | |||
| 114 | export 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 | ); |
| 107 | 107 | ||
| 108 | export 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 | |||
| 108 | export const relayEnvironmentCredentials = pgTable( | 115 | export 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"; |
| 55 | import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts"; | 55 | import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts"; |
| 56 | import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts"; | 56 | import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts"; |
| 57 | import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts"; | 57 | import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts"; |
| 58 | import * as ManagedTunnelLimits from "./environments/ManagedTunnelLimits.ts"; | ||
| 58 | import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts"; | 59 | import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts"; |
| 59 | 60 | ||
| 60 | const webcryptoLayer = Layer.succeed( | 61 | const 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 | } |
| 441 | 441 | ||
| 442 | export 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 | |||
| 442 | export class RelayAgentActivityPublishProofExpiredError extends Schema.TaggedErrorClass<RelayAgentActivityPublishProofExpiredError>()( | 456 | export 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; |