From fa7d0d2590203b392d953484328c50947ce0b1f4 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 6 May 2026 13:43:43 -0700 Subject: [PATCH] feat: set flag to disable billing --- apps/backend/.env | 2 ++ apps/backend/.env.development | 6 ++++ .../latest/analytics/events/batch/route.tsx | 4 +-- .../latest/internal/analytics/query/route.ts | 4 +-- .../latest/internal/send-test-email/route.tsx | 4 +-- .../latest/session-replays/batch/route.tsx | 4 +-- .../team-invitations/[id]/accept/route.tsx | 3 +- .../accept/verification-code-handler.tsx | 3 +- .../backend/src/app/api/latest/users/crud.tsx | 5 ++- apps/backend/src/lib/email-queue-step.tsx | 4 +-- apps/backend/src/lib/events.tsx | 3 +- .../backend/src/lib/plan-entitlements.test.ts | 33 ++++++++++++++++++- apps/backend/src/lib/plan-entitlements.ts | 23 +++++++++++++ 13 files changed, 83 insertions(+), 15 deletions(-) diff --git a/apps/backend/.env b/apps/backend/.env index 81c48a817c..cceae5f704 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -34,6 +34,8 @@ STACK_SPOTIFY_CLIENT_SECRET=# client secret STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=# allow shared oauth provider to also use connected account access token, this should only be used for development and testing +STACK_DISABLE_PLAN_LIMITS=# set to "true" to bypass enforcement of Stack Auth's own internal-tenancy plan limits (analytics_events, session_replays, emails_per_month, dashboard_admins seat cap, auth_users soft cap, analytics_timeout_seconds). Default unset/false preserves enforcement. Intended as a temporary cutover safety net while the plan-limits infrastructure rolls out — customer projects' own item APIs are unaffected by this flag. + # Email # For local development, you can spin up a local SMTP server like inbucket STACK_EMAIL_HOST=# for local inbucket: 127.0.0.1 diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 96add53c7c..8266efbc2d 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -43,6 +43,12 @@ STACK_SPOTIFY_CLIENT_SECRET=MOCK STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true +# Default to enforcing plan limits in local dev so behavior matches prod. +# Flip to "true" to bypass every Stack-Auth-internal plan-limit enforcement +# site (e.g. session_replays, analytics_events, emails_per_month). See +# apps/backend/src/lib/plan-entitlements.ts:arePlanLimitsEnforced. +STACK_DISABLE_PLAN_LIMITS=false + STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28/stackframe STACK_DATABASE_REPLICA_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34/stackframe STACK_DATABASE_REPLICATION_WAIT_STRATEGY=pg-stat-replication diff --git a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index c4bb460441..02bae18aa1 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -1,5 +1,5 @@ import { getClickhouseAdminClient } from "@/lib/clickhouse"; -import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { findRecentSessionReplay } from "@/lib/session-replays"; import { getStackServerApp } from "@/stack"; import { getPrismaClientForTenancy } from "@/prisma-client"; @@ -121,7 +121,7 @@ export const POST = createSmartRouteHandler({ const app = getStackServerApp(); const billingTeamId = getBillingTeamId(auth.tenancy.project); - if (billingTeamId != null) { + if (billingTeamId != null && arePlanLimitsEnforced()) { const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); const isDebited = await eventsItem.tryDecreaseQuantity(body.events.length); if (!isDebited) { diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts index 4b39958629..9e8d4526a2 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts @@ -1,6 +1,6 @@ import { getClickhouseExternalClient } from "@/lib/clickhouse"; import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors"; -import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { getStackServerApp } from "@/stack"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -42,7 +42,7 @@ export const POST = createSmartRouteHandler({ let effectiveTimeoutMs = body.timeout_ms; const billingTeamId = getBillingTeamId(auth.tenancy.project); - if (billingTeamId != null) { + if (billingTeamId != null && arePlanLimitsEnforced()) { const app = getStackServerApp(); const timeoutItem = await app.getItem({ itemId: ITEM_IDS.analyticsTimeoutSeconds, teamId: billingTeamId }); // clickHouse treats max_execution_time=0 as diff --git a/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx b/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx index a40341d915..a243a71633 100644 --- a/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx +++ b/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx @@ -1,5 +1,5 @@ import { isSecureEmailPort, lowLevelSendEmailDirectWithoutRetries } from "@/lib/emails-low-level"; -import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { getStackServerApp } from "@/stack"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -49,7 +49,7 @@ export const POST = createSmartRouteHandler({ // The debit is refunded on any failure below so admins iterating on an // incorrect SMTP config don't burn through their monthly quota. const billingTeamId = getBillingTeamId(auth.tenancy.project); - const emailItem = billingTeamId == null + const emailItem = billingTeamId == null || !arePlanLimitsEnforced() ? null : await getStackServerApp().getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: billingTeamId }); if (emailItem != null && billingTeamId != null) { diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index fe02e16c22..402e4f8438 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -2,7 +2,7 @@ import { getPrismaClientForTenancy } from "@/prisma-client"; import { uploadBytes } from "@/s3"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { Prisma } from "@/generated/prisma/client"; -import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { findRecentSessionReplay } from "@/lib/session-replays"; import { getStackServerApp } from "@/stack"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -113,7 +113,7 @@ export const POST = createSmartRouteHandler({ const isNewSession = recentSession == null; const billingTeamId = getBillingTeamId(auth.tenancy.project); - if (isNewSession && billingTeamId != null) { + if (isNewSession && billingTeamId != null && arePlanLimitsEnforced()) { const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId }); const isDebited = await replaysItem.tryDecreaseQuantity(1); if (!isDebited) { diff --git a/apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx b/apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx index dbab8a9010..7fe5881aeb 100644 --- a/apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx +++ b/apps/backend/src/app/api/latest/team-invitations/[id]/accept/route.tsx @@ -1,5 +1,6 @@ import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud"; import { getItemQuantityForCustomer } from "@/lib/payments/customer-data"; +import { arePlanLimitsEnforced } from "@/lib/plan-entitlements"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { globalPrismaClient } from "@/prisma-client"; import { VerificationCodeType } from "@/generated/prisma/client"; @@ -104,7 +105,7 @@ export const POST = createSmartRouteHandler({ } await retryTransaction(prisma, async (tx) => { - if (auth.tenancy.project.id === "internal") { + if (auth.tenancy.project.id === "internal" && arePlanLimitsEnforced()) { const currentMemberCount = await tx.teamMember.count({ where: { tenancyId: auth.tenancy.id, diff --git a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx index 30acac9f5e..f303e86579 100644 --- a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx @@ -1,6 +1,7 @@ import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud"; import { normalizeEmail, sendEmailFromDefaultTemplate } from "@/lib/emails"; import { getItemQuantityForCustomer } from "@/lib/payments/customer-data"; +import { arePlanLimitsEnforced } from "@/lib/plan-entitlements"; import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; @@ -102,7 +103,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ } const prisma = await getPrismaClientForTenancy(tenancy); - if (tenancy.project.id === "internal") { + if (tenancy.project.id === "internal" && arePlanLimitsEnforced()) { const currentMemberCount = await prisma.teamMember.count({ where: { tenancyId: tenancy.id, diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 689e60ea74..66314b5562 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -2,7 +2,7 @@ import { BooleanTrue, Prisma } from "@/generated/prisma/client"; import { getRenderedOrganizationConfigQuery, getRenderedProjectConfigQuery } from "@/lib/config"; import { demoteAllContactChannelsToNonPrimary, setContactChannelAsPrimaryByValue } from "@/lib/contact-channel"; import { normalizeEmail } from "@/lib/emails"; -import { getBillingTeamId, getTeamWideAuthUsersCapacity, getTeamWideNonAnonymousUserCount } from "@/lib/plan-entitlements"; +import { arePlanLimitsEnforced, getBillingTeamId, getTeamWideAuthUsersCapacity, getTeamWideNonAnonymousUserCount } from "@/lib/plan-entitlements"; import { recordExternalDbSyncContactChannelDeletionsForUser, recordExternalDbSyncDeletion, recordExternalDbSyncNotificationPreferenceDeletionsForUser, recordExternalDbSyncOAuthAccountDeletionsForUser, recordExternalDbSyncProjectPermissionDeletionsForUser, recordExternalDbSyncRefreshTokenDeletionsForUser, recordExternalDbSyncTeamMemberDeletionsForUser, recordExternalDbSyncTeamPermissionDeletionsForUser, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { grantDefaultProjectPermissions } from "@/lib/permissions"; import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; @@ -268,6 +268,9 @@ async function checkAuthUsersSoftLimit(tenancy: Tenancy) { if (getEnvVariable('STACK_SEED_MODE', '') === 'true') { return; } + if (!arePlanLimitsEnforced()) { + return; + } const billingTeamId = getBillingTeamId(tenancy.project); if (billingTeamId == null) { return; diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index e97e5f46db..c2864d322b 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -3,7 +3,7 @@ import { calculateCapacityRate, getEmailCapacityBoostExpiresAt, getEmailDelivery import { getEmailThemeForThemeId, renderEmailsForTenancyBatched } from "@/lib/email-rendering"; import { EmailOutboxRecipient, getEmailConfig, } from "@/lib/emails"; import { generateUnsubscribeLink, getNotificationCategoryById, hasNotificationEnabled, listNotificationCategories } from "@/lib/notification-categories"; -import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { getStackServerApp } from "@/stack"; import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { getTenancy, Tenancy } from "@/lib/tenancies"; @@ -693,7 +693,7 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO } } - if (context.billingTeamId != null && row.sendRetries === 0) { + if (context.billingTeamId != null && row.sendRetries === 0 && arePlanLimitsEnforced()) { const app = getStackServerApp(); const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); const isDebited = await emailItem.tryDecreaseQuantity(1); diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 2f59b56d0b..d151feba58 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -1,4 +1,5 @@ import withPostHog from "@/analytics"; +import { arePlanLimitsEnforced } from "@/lib/plan-entitlements"; import { globalPrismaClient } from "@/prisma-client"; import { getStackServerApp } from "@/stack"; import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; @@ -276,7 +277,7 @@ export async function logEvent( runAsynchronouslyAndWaitUntil((async () => { const billingTeamId = options.billingTeamId; - if (billingTeamId != null) { + if (billingTeamId != null && arePlanLimitsEnforced()) { const app = getStackServerApp(); const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); const isDebited = await eventsItem.tryDecreaseQuantity(1); diff --git a/apps/backend/src/lib/plan-entitlements.test.ts b/apps/backend/src/lib/plan-entitlements.test.ts index 7377720b26..3c98434ddc 100644 --- a/apps/backend/src/lib/plan-entitlements.test.ts +++ b/apps/backend/src/lib/plan-entitlements.test.ts @@ -1,7 +1,8 @@ import type { PrismaClientTransaction } from "@/prisma-client"; import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { + arePlanLimitsEnforced, getBillingTeamId, getOwnedProjectIdsForBillingTeam, getOwnedTenancyIdsForBillingTeam, @@ -186,3 +187,33 @@ describe("capacity lookup helpers", () => { )).rejects.toThrow("Unsupported team-wide capacity item id"); }); }); + +describe("arePlanLimitsEnforced", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns true when env var is unset (default-on enforcement)", () => { + vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", ""); + expect(arePlanLimitsEnforced()).toBe(true); + }); + + it("returns false when env var is exactly 'true'", () => { + vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", "true"); + expect(arePlanLimitsEnforced()).toBe(false); + }); + + it("returns true when env var is 'false'", () => { + vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", "false"); + expect(arePlanLimitsEnforced()).toBe(true); + }); + + it("returns true for any non-'true' value (e.g. '1', 'yes', 'TRUE')", () => { + // Explicit string match is intentional — we don't want to risk a typo + // like STACK_DISABLE_PLAN_LIMITS=trueee silently disabling enforcement. + for (const value of ["1", "yes", "TRUE", "True", " true", "true ", "trueee"]) { + vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", value); + expect(arePlanLimitsEnforced()).toBe(true); + } + }); +}); diff --git a/apps/backend/src/lib/plan-entitlements.ts b/apps/backend/src/lib/plan-entitlements.ts index 1a8fc2d955..65549dfa03 100644 --- a/apps/backend/src/lib/plan-entitlements.ts +++ b/apps/backend/src/lib/plan-entitlements.ts @@ -1,9 +1,32 @@ import { getItemQuantityForCustomer } from "@/lib/payments/customer-data"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "./tenancies"; +/** + * Whether Stack Auth's own plan-limit enforcement (quotas like `analytics_events`, + * `session_replays`, `emails_per_month`, the `auth_users` soft cap, and the + * `dashboard_admins` seat check) should be enforced for billing teams in the + * internal tenancy. + * + * Setting `STACK_DISABLE_PLAN_LIMITS=true` short-circuits every enforcement + * site BEFORE the underlying `getItem` lookup, so missing item config (e.g. + * a deploy where the internal tenancy hasn't been migrated to include the + * new items yet) cannot cascade into 500s either. + * + * Intended as a temporary cutover safety net while the plan-limits + * infrastructure rolls out to prod; the flag should be removed once we trust + * enforcement to behave correctly in every environment. + * + * Customer projects' own item APIs (`/payments/items/.../update-quantity`) + * are unaffected by this flag. + */ +export function arePlanLimitsEnforced(): boolean { + return getEnvVariable("STACK_DISABLE_PLAN_LIMITS", "false") !== "true"; +} + type GlobalPrismaLike = { project: { findMany: (args: { where: { ownerTeamId: string }, select: { id: true } }) => Promise>,