From d32a9bd02ba35e3f6781ed424f65ab3ec25a206d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 01:13:35 -0700 Subject: [PATCH 1/2] fix(relay): show the account's real tunnel cap in T3 Connect settings The tunnel meter computed its cap from the T3 Chat plan on the client, so accounts with a manual override saw "4 of 3 tunnels in use". The T3 Chat link endpoint now returns the relay's enforced cap (override or plan), and the web client uses it, falling back to the plan default for older relays. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/cloud/managedRelayState.ts | 17 ++++-- .../ManagedEndpointProvider.test.ts | 1 + .../environments/ManagedTunnelLimits.test.ts | 25 ++++++++ .../src/environments/ManagedTunnelLimits.ts | 57 ++++++++++++------- infra/relay/src/http/Api.test.ts | 4 ++ infra/relay/src/http/Api.ts | 16 ++++-- packages/contracts/src/relay.ts | 2 + 7 files changed, 90 insertions(+), 32 deletions(-) diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index c5f7536305337..6c66d82f2a89e 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -150,21 +150,26 @@ export function useManagedRelayDevices() { /** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */ export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null; -// Mirrors the relay's cap without manual overrides, which the relay does not expose. +// Older relays omit `maxTunnels`, so fall back to the plan's default cap. function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): { readonly limit: number; readonly upgrade: ManagedTunnelUpgrade; } { - if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null }; + const cap = (planLimit: number, upgrade: ManagedTunnelUpgrade) => { + const limit = t3Chat.maxTunnels ?? planLimit; + // Every upgrade offers Premier, which a manual override may already exceed. + return { limit, upgrade: limit < T3CHAT_PLAN_TUNNEL_LIMITS.premier ? upgrade : null }; + }; + if (!t3Chat.available) return cap(DEFAULT_MANAGED_TUNNEL_LIMIT, null); switch (t3Chat.link?.plan) { case "premier": - return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.premier, upgrade: null }; + return cap(T3CHAT_PLAN_TUNNEL_LIMITS.premier, null); case "pro": - return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.pro, upgrade: "premier" }; + return cap(T3CHAT_PLAN_TUNNEL_LIMITS.pro, "premier"); case "free": - return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "pro" }; + return cap(DEFAULT_MANAGED_TUNNEL_LIMIT, "pro"); case undefined: - return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "link" }; + return cap(DEFAULT_MANAGED_TUNNEL_LIMIT, "link"); } } diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 01230171be9ba..fef3935e57181 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -343,6 +343,7 @@ function makeTunnelLimits( } return yield* reserve; }), + getLimit: () => Effect.die("unused"), }); } diff --git a/infra/relay/src/environments/ManagedTunnelLimits.test.ts b/infra/relay/src/environments/ManagedTunnelLimits.test.ts index 6aca885b85b87..f1031c784ca3c 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.test.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.test.ts @@ -121,6 +121,27 @@ const reserve = ( ), ); +const getLimit = (input: { readonly override?: number; readonly plan: RelayT3ChatPlan | null }) => + Effect.gen(function* () { + const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits; + return yield* limits.getLimit({ userId: "user-1", plan: input.plan }); + }).pipe( + Effect.provide( + ManagedTunnelLimits.layer.pipe( + Layer.provide( + Layer.succeed( + RelayDb.RelayDb, + makeFakeDb({ + otherSlots: () => 0, + ...(input.override === undefined ? {} : { override: input.override }), + }), + ), + ), + Layer.provide(t3ChatLayer("free")), + ), + ), + ); + describe("ManagedTunnelLimits", () => { it.effect("rejects concurrent reservations past the limit", () => { const reserved: Array = []; @@ -362,6 +383,7 @@ describe("ManagedTunnelLimits", () => { yield* reserve({ activeTunnels: limit - 1, plan }); const error = yield* Effect.flip(reserve({ activeTunnels: limit, plan })); expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: limit }); + expect(yield* getLimit({ plan })).toBe(limit); } }), ); @@ -372,6 +394,8 @@ describe("ManagedTunnelLimits", () => { const error = yield* Effect.flip(reserve({ activeTunnels: 3, plan })); expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: 3 }); } + expect(yield* getLimit({ plan: "free" })).toBe(3); + expect(yield* getLimit({ plan: null })).toBe(3); }), ); @@ -383,6 +407,7 @@ describe("ManagedTunnelLimits", () => { reserve({ override, activeTunnels: override, plan: "premier", lookups }), ); expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: override }); + expect(yield* getLimit({ override, plan: "premier" })).toBe(override); } expect(lookups).toEqual([]); }), diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index aa373ad27ffb2..054a2bd200f2c 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -6,7 +6,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as SqlError from "effect/sql/SqlError"; -import { DEFAULT_MANAGED_TUNNEL_LIMIT, T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; +import { + DEFAULT_MANAGED_TUNNEL_LIMIT, + T3CHAT_PLAN_TUNNEL_LIMITS, + type RelayT3ChatPlan, +} from "@t3tools/contracts/relay"; import * as RelayDb from "../db.ts"; import { @@ -106,6 +110,11 @@ export class ManagedTunnelLimits extends Context.Service< input: { readonly userId: string; readonly environmentId: string }, reserve: Effect.Effect, ) => Effect.Effect; + /** The cap `withCapacity` enforces for a user on the given T3 Chat plan. */ + readonly getLimit: (input: { + readonly userId: string; + readonly plan: RelayT3ChatPlan | null; + }) => Effect.Effect; } >()("t3code-relay/environments/ManagedTunnelLimits") {} @@ -113,12 +122,13 @@ const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; const t3Chat = yield* T3ChatAccounts.T3ChatAccounts; + const planTunnelLimit = (plan: RelayT3ChatPlan | null) => + plan && plan !== "free" ? T3CHAT_PLAN_TUNNEL_LIMITS[plan] : 0; + // Asked only at the cap, so most links never call T3 Chat. Any failure keeps the lower cap. const t3ChatTunnelLimit = (userId: string) => t3Chat.getLink({ userId }).pipe( - Effect.map((link) => - link && link.plan !== "free" ? T3CHAT_PLAN_TUNNEL_LIMITS[link.plan] : 0, - ), + Effect.map((link) => planTunnelLimit(link?.plan ?? null)), Effect.catch((error) => Effect.logWarning("T3 Chat plan check failed", { userId, error: error.message }).pipe( Effect.as(0), @@ -126,7 +136,26 @@ const make = Effect.gen(function* () { ), ); + const loadOverride = (userId: string) => + db + .select({ maxTunnels: relayManagedTunnelLimits.maxTunnels }) + .from(relayManagedTunnelLimits) + .where(eq(relayManagedTunnelLimits.userId, userId)) + .limit(1) + .pipe( + Effect.map((rows) => rows[0]?.maxTunnels ?? null), + Effect.mapError( + (cause) => + new ManagedTunnelLimitPersistenceError({ operation: "load-limit", userId, cause }), + ), + ); + return ManagedTunnelLimits.of({ + getLimit: Effect.fn("relay.managed_tunnel_limits.get_limit")(function* (input) { + // A manual override is the exact cap; a T3 Chat plan raises only the default. + const override = yield* loadOverride(input.userId); + return override ?? Math.max(DEFAULT_MANAGED_TUNNEL_LIMIT, planTunnelLimit(input.plan)); + }), withCapacity: Effect.fn("relay.managed_tunnel_limits.with_capacity")(function* ( input: { readonly userId: string; readonly environmentId: string }, reserve: Effect.Effect, @@ -148,22 +177,8 @@ const make = Effect.gen(function* () { ), ); - const overrides = yield* db - .select({ maxTunnels: relayManagedTunnelLimits.maxTunnels }) - .from(relayManagedTunnelLimits) - .where(eq(relayManagedTunnelLimits.userId, input.userId)) - .limit(1) - .pipe( - Effect.mapError( - (cause) => - new ManagedTunnelLimitPersistenceError({ - operation: "load-limit", - userId: input.userId, - cause, - }), - ), - ); - const baseMaxTunnels = overrides[0]?.maxTunnels ?? DEFAULT_MANAGED_TUNNEL_LIMIT; + const override = yield* loadOverride(input.userId); + const baseMaxTunnels = override ?? DEFAULT_MANAGED_TUNNEL_LIMIT; const pendingCutoff = DateTime.formatIso( DateTime.subtract(yield* DateTime.now, { @@ -196,7 +211,7 @@ const make = Effect.gen(function* () { // A manual override is the exact cap; a T3 Chat plan raises only the default. // Runs under the user lock, bounded by the T3 Chat request timeout. const maxTunnels = - t3Chat.available && overrides.length === 0 + t3Chat.available && override === null ? Math.max(baseMaxTunnels, yield* t3ChatTunnelLimit(input.userId)) : baseMaxTunnels; if (activeTunnels >= maxTunnels) { diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index a20989cd3c025..e6db7fc8b7aa0 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -64,6 +64,7 @@ import * as HookInbox from "../hooks/HookInbox.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; +import * as ManagedTunnelLimits from "../environments/ManagedTunnelLimits.ts"; import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; @@ -130,6 +131,7 @@ describe("managed endpoint provisioning errors", () => { Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), Layer.mock(Devices.Devices, {}), Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), + Layer.mock(ManagedTunnelLimits.ManagedTunnelLimits, {}), ), ), Layer.provideMerge( @@ -241,6 +243,7 @@ describe("device listing compatibility", () => { }, }), Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), + Layer.mock(ManagedTunnelLimits.ManagedTunnelLimits, {}), ), ), Layer.provideMerge( @@ -322,6 +325,7 @@ describe("Cloudflare rate-limit retry hint", () => { Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), Layer.mock(Devices.Devices, {}), Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), + Layer.mock(ManagedTunnelLimits.ManagedTunnelLimits, {}), ), ), Layer.provideMerge( diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 5a4d77c28d3c2..59e36bb6c8af9 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -74,6 +74,7 @@ import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts"; import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; +import * as ManagedTunnelLimits from "../environments/ManagedTunnelLimits.ts"; import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; @@ -825,6 +826,7 @@ export const layerClientApi = HttpApiBuilder.group( const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; const devices = yield* Devices.Devices; const t3Chat = yield* T3ChatAccounts.T3ChatAccounts; + const tunnelLimits = yield* ManagedTunnelLimits.ManagedTunnelLimits; return handlers .handle( "listEnvironments", @@ -996,12 +998,16 @@ export const layerClientApi = HttpApiBuilder.group( "getT3ChatLink", Effect.fn("relay.api.client.getT3ChatLink")(function* () { yield* appendRelayCredentialResponseHeaders; - if (!t3Chat.available) return { available: false, link: null }; const { userId } = yield* RelayClientPrincipal; - const link = yield* t3Chat - .getLink({ userId }) - .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); - return { available: true, link }; + const link = t3Chat.available + ? yield* t3Chat + .getLink({ userId }) + .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))) + : null; + const maxTunnels = yield* tunnelLimits + .getLimit({ userId, plan: link?.plan ?? null }) + .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))); + return { available: t3Chat.available, link, maxTunnels }; }, mapRelayCommonApiErrors("not_authorized")), ) .handle( diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 4dbb6a199f472..3cf3b17a60810 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -981,6 +981,8 @@ export const RelayT3ChatLinkResponse = Schema.Struct({ // False when this relay has no T3 Chat secret, so clients hide the feature. available: Schema.Boolean, link: Schema.NullOr(RelayT3ChatLink), + // The account's managed tunnel cap, including manual overrides. Absent from older relays. + maxTunnels: Schema.optionalKey(Schema.Number), }); export type RelayT3ChatLinkResponse = typeof RelayT3ChatLinkResponse.Type; From a01d27a5889ffcd0306f6619bf281230d5eebd35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 01:31:44 -0700 Subject: [PATCH 2/2] fix(relay): send only the manual tunnel override, never offer an upgrade over it A manual override is the exact cap, so a T3 Chat upgrade cannot raise it. The relay now returns `maxTunnelsOverride` only when one is set; the client keeps its plan-derived cap otherwise and hides the upgrade prompt under an override. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/cloud/managedRelayState.ts | 20 +++++++------- .../ManagedEndpointProvider.test.ts | 2 +- .../environments/ManagedTunnelLimits.test.ts | 12 ++++----- .../src/environments/ManagedTunnelLimits.ts | 26 +++++++------------ infra/relay/src/http/Api.ts | 10 ++++--- packages/contracts/src/relay.ts | 4 +-- 6 files changed, 33 insertions(+), 41 deletions(-) diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 6c66d82f2a89e..679f362fdf5a0 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -150,26 +150,24 @@ export function useManagedRelayDevices() { /** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */ export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null; -// Older relays omit `maxTunnels`, so fall back to the plan's default cap. +// Mirrors the relay's cap: a manual override is exact, otherwise the T3 Chat plan raises the default. function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): { readonly limit: number; readonly upgrade: ManagedTunnelUpgrade; } { - const cap = (planLimit: number, upgrade: ManagedTunnelUpgrade) => { - const limit = t3Chat.maxTunnels ?? planLimit; - // Every upgrade offers Premier, which a manual override may already exceed. - return { limit, upgrade: limit < T3CHAT_PLAN_TUNNEL_LIMITS.premier ? upgrade : null }; - }; - if (!t3Chat.available) return cap(DEFAULT_MANAGED_TUNNEL_LIMIT, null); + if (t3Chat.maxTunnelsOverride !== undefined) { + return { limit: t3Chat.maxTunnelsOverride, upgrade: null }; + } + if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null }; switch (t3Chat.link?.plan) { case "premier": - return cap(T3CHAT_PLAN_TUNNEL_LIMITS.premier, null); + return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.premier, upgrade: null }; case "pro": - return cap(T3CHAT_PLAN_TUNNEL_LIMITS.pro, "premier"); + return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.pro, upgrade: "premier" }; case "free": - return cap(DEFAULT_MANAGED_TUNNEL_LIMIT, "pro"); + return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "pro" }; case undefined: - return cap(DEFAULT_MANAGED_TUNNEL_LIMIT, "link"); + return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "link" }; } } diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index fef3935e57181..96661fad42a61 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -343,7 +343,7 @@ function makeTunnelLimits( } return yield* reserve; }), - getLimit: () => Effect.die("unused"), + getOverride: () => Effect.die("unused"), }); } diff --git a/infra/relay/src/environments/ManagedTunnelLimits.test.ts b/infra/relay/src/environments/ManagedTunnelLimits.test.ts index f1031c784ca3c..6b8dd817ee01c 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.test.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.test.ts @@ -121,10 +121,10 @@ const reserve = ( ), ); -const getLimit = (input: { readonly override?: number; readonly plan: RelayT3ChatPlan | null }) => +const getOverride = (override?: number) => Effect.gen(function* () { const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits; - return yield* limits.getLimit({ userId: "user-1", plan: input.plan }); + return yield* limits.getOverride({ userId: "user-1" }); }).pipe( Effect.provide( ManagedTunnelLimits.layer.pipe( @@ -133,7 +133,7 @@ const getLimit = (input: { readonly override?: number; readonly plan: RelayT3Cha RelayDb.RelayDb, makeFakeDb({ otherSlots: () => 0, - ...(input.override === undefined ? {} : { override: input.override }), + ...(override === undefined ? {} : { override }), }), ), ), @@ -383,7 +383,6 @@ describe("ManagedTunnelLimits", () => { yield* reserve({ activeTunnels: limit - 1, plan }); const error = yield* Effect.flip(reserve({ activeTunnels: limit, plan })); expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: limit }); - expect(yield* getLimit({ plan })).toBe(limit); } }), ); @@ -394,8 +393,6 @@ describe("ManagedTunnelLimits", () => { const error = yield* Effect.flip(reserve({ activeTunnels: 3, plan })); expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: 3 }); } - expect(yield* getLimit({ plan: "free" })).toBe(3); - expect(yield* getLimit({ plan: null })).toBe(3); }), ); @@ -407,8 +404,9 @@ describe("ManagedTunnelLimits", () => { reserve({ override, activeTunnels: override, plan: "premier", lookups }), ); expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: override }); - expect(yield* getLimit({ override, plan: "premier" })).toBe(override); + expect(yield* getOverride(override)).toBe(override); } + expect(yield* getOverride()).toBeNull(); expect(lookups).toEqual([]); }), ); diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index 054a2bd200f2c..3af90f86c4163 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -6,11 +6,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as SqlError from "effect/sql/SqlError"; -import { - DEFAULT_MANAGED_TUNNEL_LIMIT, - T3CHAT_PLAN_TUNNEL_LIMITS, - type RelayT3ChatPlan, -} from "@t3tools/contracts/relay"; +import { DEFAULT_MANAGED_TUNNEL_LIMIT, T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; import * as RelayDb from "../db.ts"; import { @@ -110,11 +106,10 @@ export class ManagedTunnelLimits extends Context.Service< input: { readonly userId: string; readonly environmentId: string }, reserve: Effect.Effect, ) => Effect.Effect; - /** The cap `withCapacity` enforces for a user on the given T3 Chat plan. */ - readonly getLimit: (input: { + /** The user's manual cap, which replaces the default and any T3 Chat plan cap. */ + readonly getOverride: (input: { readonly userId: string; - readonly plan: RelayT3ChatPlan | null; - }) => Effect.Effect; + }) => Effect.Effect; } >()("t3code-relay/environments/ManagedTunnelLimits") {} @@ -122,13 +117,12 @@ const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; const t3Chat = yield* T3ChatAccounts.T3ChatAccounts; - const planTunnelLimit = (plan: RelayT3ChatPlan | null) => - plan && plan !== "free" ? T3CHAT_PLAN_TUNNEL_LIMITS[plan] : 0; - // Asked only at the cap, so most links never call T3 Chat. Any failure keeps the lower cap. const t3ChatTunnelLimit = (userId: string) => t3Chat.getLink({ userId }).pipe( - Effect.map((link) => planTunnelLimit(link?.plan ?? null)), + Effect.map((link) => + link && link.plan !== "free" ? T3CHAT_PLAN_TUNNEL_LIMITS[link.plan] : 0, + ), Effect.catch((error) => Effect.logWarning("T3 Chat plan check failed", { userId, error: error.message }).pipe( Effect.as(0), @@ -151,10 +145,8 @@ const make = Effect.gen(function* () { ); return ManagedTunnelLimits.of({ - getLimit: Effect.fn("relay.managed_tunnel_limits.get_limit")(function* (input) { - // A manual override is the exact cap; a T3 Chat plan raises only the default. - const override = yield* loadOverride(input.userId); - return override ?? Math.max(DEFAULT_MANAGED_TUNNEL_LIMIT, planTunnelLimit(input.plan)); + getOverride: Effect.fn("relay.managed_tunnel_limits.get_override")(function* (input) { + return yield* loadOverride(input.userId); }), withCapacity: Effect.fn("relay.managed_tunnel_limits.with_capacity")(function* ( input: { readonly userId: string; readonly environmentId: string }, diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 59e36bb6c8af9..390f3c18f785f 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -1004,10 +1004,14 @@ export const layerClientApi = HttpApiBuilder.group( .getLink({ userId }) .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))) : null; - const maxTunnels = yield* tunnelLimits - .getLimit({ userId, plan: link?.plan ?? null }) + const maxTunnelsOverride = yield* tunnelLimits + .getOverride({ userId }) .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))); - return { available: t3Chat.available, link, maxTunnels }; + return { + available: t3Chat.available, + link, + ...(maxTunnelsOverride === null ? {} : { maxTunnelsOverride }), + }; }, mapRelayCommonApiErrors("not_authorized")), ) .handle( diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 3cf3b17a60810..8ebc9df23a0bb 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -981,8 +981,8 @@ export const RelayT3ChatLinkResponse = Schema.Struct({ // False when this relay has no T3 Chat secret, so clients hide the feature. available: Schema.Boolean, link: Schema.NullOr(RelayT3ChatLink), - // The account's managed tunnel cap, including manual overrides. Absent from older relays. - maxTunnels: Schema.optionalKey(Schema.Number), + // A manual cap that replaces the plan's; T3 Chat upgrades do not raise it. + maxTunnelsOverride: Schema.optionalKey(Schema.Number), }); export type RelayT3ChatLinkResponse = typeof RelayT3ChatLinkResponse.Type;