From d78405d7e763394c36cb07d81b8fbb89197d976d Mon Sep 17 00:00:00 2001 From: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:17:21 -0700 Subject: [PATCH] Gate usage limit banners behind HEXCLAVE_DISABLE_PLAN_LIMITS (#1728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Threads `arePlanLimitsEnforced()` from the backend through the plan-usage API response so the dashboard can hide all usage limit banners when `HEXCLAVE_DISABLE_PLAN_LIMITS=true`. Backend adds `are_plan_limits_enforced` to `planUsageResponseSchema` (`.optional().default(true)` for backward compat with older backends) → SDK surfaces it as `PlanUsage.arePlanLimitsEnforced` with `?? true` runtime fallback (since `getPlanUsage()` returns raw JSON without yup validation) → banner components early-return `null` when `!arePlanLimitsEnforced`. For the projects page (outside admin-app context, no project selected), a server action reads the env var directly via `getEnvVariable(\"STACK_DISABLE_PLAN_LIMITS\", \"false\")`. Link to Devin session: https://app.devin.ai/sessions/09ca53f13c294c8e98c7a7227a52217d Requested by: @Developing-Gamer ## Summary by CodeRabbit * **New Features** * Added support for a “plan limits enforced” flag to control messaging across usage and billing-related screens. * Projects and team invitation capacity checks now respect the same enforcement toggle (including admin-seat invitation blocking). * **Bug Fixes** * Limit banners and “plan limit exceeded” alerts now only render when enforcement is enabled (and overage/threshold conditions are met). * Updated usage payload handling to default to enforcement enabled when the flag is missing; added/updated tests for both enabled and disabled scenarios. --------- Co-authored-by: armaan Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/src/lib/plan-usage.ts | 2 ++ apps/dashboard/.env | 1 + apps/dashboard/.env.development | 1 + .../(outside-dashboard)/projects/actions.ts | 5 +++++ .../(outside-dashboard)/projects/page-client.tsx | 9 ++++++--- .../projects/[projectId]/analytics/shared.tsx | 6 ++++-- .../project-settings/usage/page-client.test.tsx | 12 ++++++++++++ .../project-settings/usage/page-client.tsx | 5 ++++- packages/shared/src/interface/plan-usage.ts | 1 + .../apps/implementations/admin-app-impl.ts | 2 ++ .../src/lib/hexclave-app/plan-usage/index.ts | 1 + 11 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/lib/plan-usage.ts b/apps/backend/src/lib/plan-usage.ts index 6c28c398e..14d8195ae 100644 --- a/apps/backend/src/lib/plan-usage.ts +++ b/apps/backend/src/lib/plan-usage.ts @@ -3,6 +3,7 @@ import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { isActiveSubscription } from "@/lib/payments"; import { + arePlanLimitsEnforced, getBillingTeamId, getNonAnonymousUserCountForTenancies, getOwnedProjectAndTenancyIdsForBillingTeam, @@ -423,6 +424,7 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D period_start_millis: period.start.getTime(), period_end_millis: period.end.getTime(), next_plan_id: getNextPlanId(planId), + are_plan_limits_enforced: arePlanLimitsEnforced(), rows: buildRows({ planId, dashboardAdmins, diff --git a/apps/dashboard/.env b/apps/dashboard/.env index b99c169b0..a3358387b 100644 --- a/apps/dashboard/.env +++ b/apps/dashboard/.env @@ -16,5 +16,6 @@ NEXT_PUBLIC_HEXCLAVE_HEAD_TAGS=# a JSON array of head tags to inject, e.g. '[{ " HEXCLAVE_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translation provider here, for example: de-DE. Only works during development, not in production. Optional, by default don't translate NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS=# JSON array of project IDs that get development features (set to '["internal"]' in .env.development). Leave empty here so a platform-set legacy NEXT_PUBLIC_STACK_* value isn't treated as a conflict in prod builds. NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development) +HEXCLAVE_DISABLE_PLAN_LIMITS=# set to "true" to bypass enforcement of plan limits in the dashboard (seat-capacity gate on the projects page). Default unset/false preserves enforcement. HEXCLAVE_FEATUREBASE_JWT_SECRET=# used for Featurebase SSO, you probably won't have to set this HEXCLAVE_CHANGELOG_URL=# Used for raw github link to root changelog.md file. diff --git a/apps/dashboard/.env.development b/apps/dashboard/.env.development index 0b8dd416e..bf14e7d85 100644 --- a/apps/dashboard/.env.development +++ b/apps/dashboard/.env.development @@ -13,4 +13,5 @@ HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50 NEXT_PUBLIC_HEXCLAVE_DEBUGGER_ON_ASSERTION_ERROR=false NEXT_PUBLIC_HEXCLAVE_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]' +HEXCLAVE_DISABLE_PLAN_LIMITS=false HEXCLAVE_FEATUREBASE_JWT_SECRET=secret-value diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts index 807ce9271..816b6a40c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts @@ -1,5 +1,6 @@ "use server"; import { isRemoteDevelopmentEnvironmentEnabled } from "@/lib/remote-development-environment/env"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { hexclaveAppInternalsSymbol } from "@hexclave/next"; async function getServerApp() { @@ -49,3 +50,7 @@ export async function inviteUser(teamId: string, email: string, origin: string) } await team.inviteUser({ email, callbackUrl }); } + +export async function getArePlanLimitsEnforced(): Promise { + return getEnvVariable("STACK_DISABLE_PLAN_LIMITS", "false") !== "true"; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 11fe624ec..bfcf8bba0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -20,7 +20,7 @@ import { stringCompare } from "@hexclave/shared/dist/utils/strings"; import { urlString } from "@hexclave/shared/dist/utils/urls"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; -import { inviteUser, listInvitations, revokeInvitation } from "./actions"; +import { getArePlanLimitsEnforced, inviteUser, listInvitations, revokeInvitation } from "./actions"; import Footer from "./footer"; import PreviewProjectRedirect from "./preview-project-redirect"; @@ -513,14 +513,16 @@ type TeamAddUserDialogData = { userCount: number, seatLimit: number, hasPaidPlan: boolean, + arePlanLimitsEnforced: boolean, }; async function loadTeamAddUserDialogData(team: Team): Promise { - const [invitations, users, admins, products] = await Promise.all([ + const [invitations, users, admins, products, arePlanLimitsEnforced] = await Promise.all([ listInvitations(team.id), team.listUsers(), team.getItem("dashboard_admins"), team.listProducts(), + getArePlanLimitsEnforced(), ]); return { @@ -528,6 +530,7 @@ async function loadTeamAddUserDialogData(team: Team): Promise= dialogData.seatLimit; + const atCapacity = dialogData != null && dialogData.arePlanLimitsEnforced && activeSeats != null && activeSeats >= dialogData.seatLimit; const handleInvite = async () => { if (dialogData == null || atCapacity) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 9e80840fb..a848bedb2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -329,6 +329,7 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () = export function AnalyticsEventLimitBanner() { const adminApp = useAdminApp(); const project = adminApp.useProject(); + const planUsage = adminApp.usePlanUsage(); const user = useDashboardInternalUser(); const teams = user.useTeams(); @@ -337,7 +338,7 @@ export function AnalyticsEventLimitBanner() { [teams, project.ownerTeamId], ); - if (ownerTeam == null) { + if (!planUsage.arePlanLimitsEnforced || ownerTeam == null) { return null; } @@ -351,6 +352,7 @@ export function AnalyticsEventLimitBanner() { export function SessionReplayLimitBanner() { const adminApp = useAdminApp(); const project = adminApp.useProject(); + const planUsage = adminApp.usePlanUsage(); const user = useDashboardInternalUser(); const teams = user.useTeams(); @@ -359,7 +361,7 @@ export function SessionReplayLimitBanner() { [teams, project.ownerTeamId], ); - if (ownerTeam == null) { + if (!planUsage.arePlanLimitsEnforced || ownerTeam == null) { return null; } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx index 4ec7a3934..848047c40 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.test.tsx @@ -15,6 +15,7 @@ function createPlanUsageState() { periodStart: new Date(Date.UTC(2026, 5, 1)), periodEnd: new Date(Date.UTC(2026, 6, 1)), nextPlanId: "team", + arePlanLimitsEnforced: true, rows: [ { itemId: "dashboard_admins", @@ -148,6 +149,17 @@ describe("Usage settings page", () => { expect(authUsageFill.style.width).toBe("0%"); }); + it("hides overage banner when plan limits are not enforced", () => { + planUsageState = { + ...createPlanUsageState(), + arePlanLimitsEnforced: false, + }; + + render(); + + expect(screen.queryByRole("alert")).toBeNull(); + }); + it("starts checkout for the next plan from the upgrade CTA", async () => { render(); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx index cfdac5213..71cb01900 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/usage/page-client.tsx @@ -288,16 +288,18 @@ function UsageBody({ planUsage, analyticsTimeoutRow, onUpgrade, + arePlanLimitsEnforced, }: { planUsage: PlanUsageData, analyticsTimeoutRow: UsageRow | undefined, onUpgrade: (() => void) | undefined, + arePlanLimitsEnforced: boolean, }) { const overageRows = getOverageRows(planUsage.rows); return (
- {overageRows.length > 0 && ( + {arePlanLimitsEnforced && overageRows.length > 0 && (
); diff --git a/packages/shared/src/interface/plan-usage.ts b/packages/shared/src/interface/plan-usage.ts index 3046bf388..43c828a75 100644 --- a/packages/shared/src/interface/plan-usage.ts +++ b/packages/shared/src/interface/plan-usage.ts @@ -26,6 +26,7 @@ export const planUsageResponseSchema = yupObject({ period_start_millis: yupNumber().integer().defined(), period_end_millis: yupNumber().integer().defined(), next_plan_id: yupString().oneOf(UPGRADE_PLAN_IDS).nullable().defined(), + are_plan_limits_enforced: yupBoolean().optional().default(true), rows: yupArray(planUsageRowSchema).defined(), }).defined(); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts index 78ecdddd1..be2426139 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts @@ -351,6 +351,8 @@ export class _HexclaveAdminAppImplIncomplete ({ itemId: row.item_id, displayName: row.display_name, diff --git a/packages/template/src/lib/hexclave-app/plan-usage/index.ts b/packages/template/src/lib/hexclave-app/plan-usage/index.ts index 9226c3873..b0e66596d 100644 --- a/packages/template/src/lib/hexclave-app/plan-usage/index.ts +++ b/packages/template/src/lib/hexclave-app/plan-usage/index.ts @@ -21,5 +21,6 @@ export type PlanUsage = { periodStart: Date, periodEnd: Date, nextPlanId: PlanUsageNextPlanId | null, + arePlanLimitsEnforced: boolean, rows: PlanUsageRow[], };