mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
[Feat]: set flag to disable billing (#1417)
### Context There are some kinks to work out with deploying plan limits onto prod, so we'd like to disable it temporarily. ### Summary of Changes We update all call sites of the item quantity things with a flag based check. Idea is when flag is set to true, it should function as if there are no limits.
This commit is contained in:
parent
bd8c4489ed
commit
bc6347e3c3
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<T extends EventType[]>(
|
||||
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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Array<{ id: string }>>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user