Gate usage limit banners behind HEXCLAVE_DISABLE_PLAN_LIMITS (#1728)

## 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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: armaan <armaan@stack-auth.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Armaan Jain 2026-07-05 21:17:21 -07:00 committed by GitHub
parent 56dd0f357b
commit d78405d7e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 39 additions and 6 deletions

View File

@ -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,

View File

@ -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.

View File

@ -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

View File

@ -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<boolean> {
return getEnvVariable("STACK_DISABLE_PLAN_LIMITS", "false") !== "true";
}

View File

@ -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<TeamAddUserDialogData> {
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<TeamAddUserDialogD
userCount: users.length,
seatLimit: admins.quantity,
hasPaidPlan: isPaidPlan(products),
arePlanLimitsEnforced,
};
}
@ -597,7 +600,7 @@ function TeamAddUserDialog(props: { team: Team }) {
}, [props.team]);
const activeSeats = dialogData == null ? null : dialogData.userCount + dialogData.invitations.length;
const atCapacity = dialogData != null && activeSeats != null && activeSeats >= dialogData.seatLimit;
const atCapacity = dialogData != null && dialogData.arePlanLimitsEnforced && activeSeats != null && activeSeats >= dialogData.seatLimit;
const handleInvite = async () => {
if (dialogData == null || atCapacity) {

View File

@ -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;
}

View File

@ -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(<PageClient />);
expect(screen.queryByRole("alert")).toBeNull();
});
it("starts checkout for the next plan from the upgrade CTA", async () => {
render(<PageClient />);

View File

@ -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 (
<div className="flex flex-col gap-4">
{overageRows.length > 0 && (
{arePlanLimitsEnforced && overageRows.length > 0 && (
<div
role="alert"
className="relative grid w-full gap-4 rounded-2xl border border-amber-500/40 bg-amber-500/[0.08] p-4 text-sm sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@ -370,6 +372,7 @@ export default function PageClient() {
planUsage={planUsageForDisplay}
analyticsTimeoutRow={analyticsTimeoutRow}
onUpgrade={handleUpgrade}
arePlanLimitsEnforced={planUsage.arePlanLimitsEnforced}
/>
</PageLayout>
);

View File

@ -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();

View File

@ -351,6 +351,8 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
periodStart: new Date(data.period_start_millis),
periodEnd: new Date(data.period_end_millis),
nextPlanId: data.next_plan_id,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- getPlanUsage() returns raw JSON without yup validation, so this field can be undefined at runtime if the backend hasn't deployed the field yet
arePlanLimitsEnforced: data.are_plan_limits_enforced ?? true,
rows: data.rows.map((row) => ({
itemId: row.item_id,
displayName: row.display_name,

View File

@ -21,5 +21,6 @@ export type PlanUsage = {
periodStart: Date,
periodEnd: Date,
nextPlanId: PlanUsageNextPlanId | null,
arePlanLimitsEnforced: boolean,
rows: PlanUsageRow[],
};