Skip to content

Commit cd4a451

Browse files
committed
Merge origin/dev into gzip-analytics-batch-body
2 parents 81ed062 + c01c052 commit cd4a451

55 files changed

Lines changed: 2540 additions & 161 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend/prisma/seed.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable no-restricted-syntax */
22
import { usersCrudHandlers } from '@/app/api/latest/users/crud';
3+
import { CustomerType, Prisma, PurchaseCreationSource, SubscriptionStatus } from '@/generated/prisma/client';
34
import { overrideBranchConfigOverride } from '@/lib/config';
45
import {
56
LOCAL_EMULATOR_ADMIN_EMAIL,
@@ -15,9 +16,12 @@ import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from '@/lib/tenanc
1516
import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client';
1617
import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config';
1718
import { ITEM_IDS, PLAN_LIMITS } from '@stackframe/stack-shared/dist/plans';
19+
import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates';
1820
import { throwErr } from '@stackframe/stack-shared/dist/utils/errors';
1921
import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects';
2022

23+
const MONTHLY_REPEAT: DayInterval = [1, "month"];
24+
2125
const DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063';
2226

2327
let didEnableSeedLogTimestamps = false;
@@ -159,9 +163,10 @@ export async function seed() {
159163
includedItems: {
160164
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
161165
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
162-
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
166+
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
163167
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
164-
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
168+
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
169+
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
165170
},
166171
},
167172
team: {
@@ -173,16 +178,18 @@ export async function seed() {
173178
prices: {
174179
monthly: {
175180
USD: "49",
176-
interval: [1, "month"] as any,
181+
interval: MONTHLY_REPEAT,
177182
serverOnly: false,
178183
},
179184
},
180185
includedItems: {
181186
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
182187
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
183-
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
188+
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
184189
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
185-
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
190+
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
191+
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
192+
[ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
186193
},
187194
},
188195
growth: {
@@ -194,16 +201,18 @@ export async function seed() {
194201
prices: {
195202
monthly: {
196203
USD: "299",
197-
interval: [1, "month"] as any,
204+
interval: MONTHLY_REPEAT,
198205
serverOnly: false,
199206
},
200207
},
201208
includedItems: {
202209
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
203210
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
204-
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
211+
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
205212
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
206-
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
213+
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
214+
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const },
215+
[ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
207216
},
208217
},
209218
"extra-seats": {
@@ -215,7 +224,7 @@ export async function seed() {
215224
prices: {
216225
monthly: {
217226
USD: "29",
218-
interval: [1, "month"] as any,
227+
interval: MONTHLY_REPEAT,
219228
serverOnly: false,
220229
},
221230
},
@@ -234,6 +243,8 @@ export async function seed() {
234243
[ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const },
235244
[ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const },
236245
[ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const },
246+
[ITEM_IDS.sessionReplays]: { displayName: "Session Replays", customerType: "team" as const },
247+
[ITEM_IDS.onboardingCall]: { displayName: "Onboarding Call", customerType: "team" as const },
237248
},
238249
},
239250
apps: {
@@ -292,6 +303,60 @@ export async function seed() {
292303
console.log('Internal team created');
293304
}
294305

306+
// The team-create CRUD path auto-grants the free plan to every team in the
307+
// internal project, but the internal team itself is written directly above
308+
// (bypassing that code path), so it would otherwise end up with zero
309+
// entitlements and trip the plan-limit enforcement. Grant it the Growth plan
310+
// so Stack Auth employees using the dashboard get full quotas. Idempotent —
311+
// skipped if an active Growth subscription already exists.
312+
//
313+
// We create the subscription with raw Prisma (matching seed-dummy-data.ts)
314+
// rather than grantProductToCustomer because bulldozer storage tables
315+
// aren't initialized at this point in the seed yet. The Bulldozer init
316+
// call right below this block ingresses the row into the ledger.
317+
const growthProduct = updatedInternalTenancy.config.payments.products.growth;
318+
if (growthProduct.customerType === 'team') {
319+
const existingGrowthSub = await internalPrisma.subscription.findFirst({
320+
where: {
321+
tenancyId: internalTenancy.id,
322+
customerId: internalTeamId,
323+
customerType: CustomerType.TEAM,
324+
productId: 'growth',
325+
status: SubscriptionStatus.active,
326+
},
327+
});
328+
if (!existingGrowthSub) {
329+
const growthPrices = growthProduct.prices === 'include-by-default' ? {} : growthProduct.prices;
330+
const firstPriceId = Object.keys(growthPrices)[0] ?? null;
331+
const now = new Date();
332+
// Clone to ensure the stored JSON snapshot is independent of the config object
333+
// (mirrors the pattern used in seed-dummy-data.ts).
334+
const storedProduct = JSON.parse(JSON.stringify(growthProduct)) as Prisma.InputJsonValue;
335+
// Mirror what a real Stripe checkout would produce, based on whether
336+
// the internal project is running in test mode.
337+
const creationSource = updatedInternalTenancy.config.payments.testMode
338+
? PurchaseCreationSource.TEST_MODE
339+
: PurchaseCreationSource.PURCHASE_PAGE;
340+
await internalPrisma.subscription.create({
341+
data: {
342+
tenancyId: internalTenancy.id,
343+
customerId: internalTeamId,
344+
customerType: CustomerType.TEAM,
345+
status: SubscriptionStatus.active,
346+
productId: 'growth',
347+
priceId: firstPriceId,
348+
product: storedProduct,
349+
quantity: 1,
350+
currentPeriodStart: now,
351+
currentPeriodEnd: new Date('2099-12-31T23:59:59Z'),
352+
cancelAtPeriodEnd: false,
353+
creationSource,
354+
},
355+
});
356+
console.log('Granted Growth plan to internal team');
357+
}
358+
}
359+
295360
// Upsert the internal API key set before any flake-prone work (dummy-project
296361
// seed, email/svix, clickhouse). The emulator CLI authenticates against the
297362
// internal project using the pck stored here, so it must land before the rest

apps/backend/src/app/api/latest/analytics/events/batch/route.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { getClickhouseAdminClient } from "@/lib/clickhouse";
2+
import { getBillingTeamId } from "@/lib/plan-entitlements";
23
import { findRecentSessionReplay } from "@/lib/session-replays";
4+
import { getStackServerApp } from "@/stack";
35
import { getPrismaClientForTenancy } from "@/prisma-client";
46
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
57
import { KnownErrors } from "@stackframe/stack-shared";
8+
import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans";
69
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
710
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
811
import * as zlib from "node:zlib";
@@ -115,6 +118,17 @@ export const POST = createSmartRouteHandler({
115118
const refreshTokenId = auth.refreshTokenId;
116119
const tenancyId = auth.tenancy.id;
117120

121+
const app = getStackServerApp();
122+
123+
const billingTeamId = getBillingTeamId(auth.tenancy.project);
124+
if (billingTeamId != null) {
125+
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
126+
const isDebited = await eventsItem.tryDecreaseQuantity(body.events.length);
127+
if (!isDebited) {
128+
throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, body.events.length);
129+
}
130+
}
131+
118132
const prisma = await getPrismaClientForTenancy(auth.tenancy);
119133
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
120134

apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const ignoredEvents = [
4848
"charge.failed",
4949
"balance.available",
5050
"customer.updated",
51+
"customer.created",
5152
] as const satisfies Stripe.Event.Type[];
5253

5354
const isSubscriptionChangedEvent = (event: Stripe.Event): event is Stripe.Event & { type: (typeof subscriptionChangedEvents)[number] } => {

apps/backend/src/app/api/latest/internal/analytics/query/route.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { getClickhouseExternalClient } from "@/lib/clickhouse";
22
import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors";
3+
import { getBillingTeamId } from "@/lib/plan-entitlements";
34
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
5+
import { getStackServerApp } from "@/stack";
46
import { KnownErrors } from "@stackframe/stack-shared";
7+
import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans";
58
import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
69
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
710
import { Result } from "@stackframe/stack-shared/dist/utils/results";
811
import { randomUUID } from "crypto";
912

10-
const MAX_QUERY_TIMEOUT_MS = 120_000;
13+
const MAX_QUERY_TIMEOUT_MS = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000;
1114
const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
1215

1316
export const POST = createSmartRouteHandler({
@@ -36,6 +39,23 @@ export const POST = createSmartRouteHandler({
3639
if (body.include_all_branches) {
3740
throw new StackAssertionError("include_all_branches is not supported yet");
3841
}
42+
43+
let effectiveTimeoutMs = body.timeout_ms;
44+
const billingTeamId = getBillingTeamId(auth.tenancy.project);
45+
if (billingTeamId != null) {
46+
const app = getStackServerApp();
47+
const timeoutItem = await app.getItem({ itemId: ITEM_IDS.analyticsTimeoutSeconds, teamId: billingTeamId });
48+
// clickHouse treats max_execution_time=0 as
49+
// "unlimited", so a customer with zero timeout entitlement (no active
50+
// plan in the plans line, or a transient gap between paid-plan end
51+
// and free regrant) would otherwise get unbounded query execution.
52+
if (timeoutItem.quantity <= 0) {
53+
throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsTimeoutSeconds, billingTeamId, 1);
54+
}
55+
const maxAllowedMs = timeoutItem.quantity * 1000;
56+
effectiveTimeoutMs = Math.min(body.timeout_ms, maxAllowedMs);
57+
}
58+
3959
const client = getClickhouseExternalClient();
4060
const queryId = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:${randomUUID()}`;
4161
const resultSet = await Result.fromPromise(client.query({
@@ -45,7 +65,7 @@ export const POST = createSmartRouteHandler({
4565
clickhouse_settings: {
4666
SQL_project_id: auth.tenancy.project.id,
4767
SQL_branch_id: auth.tenancy.branchId,
48-
max_execution_time: body.timeout_ms / 1000,
68+
max_execution_time: effectiveTimeoutMs / 1000,
4969
readonly: "1",
5070
allow_ddl: 0,
5171
max_result_rows: MAX_RESULT_ROWS.toString(),

apps/backend/src/app/api/latest/internal/send-test-email/route.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { isSecureEmailPort, lowLevelSendEmailDirectWithoutRetries } from "@/lib/emails-low-level";
2+
import { getBillingTeamId } from "@/lib/plan-entitlements";
23
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { getStackServerApp } from "@/stack";
5+
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans";
37
import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields";
48
import { adaptSchema, adminAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
59
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
@@ -37,6 +41,24 @@ export const POST = createSmartRouteHandler({
3741
}).defined(),
3842
}),
3943
handler: async ({ body, auth }) => {
44+
// Debit the emails_per_month quota before hitting SMTP so this endpoint
45+
// can't be used as an unbounded SMTP-send-through / socket-exhaustion
46+
// vector (admin provides arbitrary recipient_email and email_config, so
47+
// without a quota guard even a compromised/hostile project admin could
48+
// spam an arbitrary recipient or pin our event loop with 10s SMTP waits).
49+
// The debit is refunded on any failure below so admins iterating on an
50+
// incorrect SMTP config don't burn through their monthly quota.
51+
const billingTeamId = getBillingTeamId(auth.tenancy.project);
52+
const emailItem = billingTeamId == null
53+
? null
54+
: await getStackServerApp().getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: billingTeamId });
55+
if (emailItem != null && billingTeamId != null) {
56+
const isDebited = await emailItem.tryDecreaseQuantity(1);
57+
if (!isDebited) {
58+
throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.emailsPerMonth, billingTeamId, 1);
59+
}
60+
}
61+
4062
const resultOuter = await timeout(lowLevelSendEmailDirectWithoutRetries({
4163
tenancyId: auth.tenancy.id,
4264
emailConfig: {
@@ -78,6 +100,14 @@ export const POST = createSmartRouteHandler({
78100
}
79101
}
80102

103+
// Refund the quota if we never actually delivered to SMTP — admins
104+
// iterating on a misconfigured mail server shouldn't burn through
105+
// their monthly allowance. Spam prevention is preserved because a
106+
// successful delivery still consumes 1 from the debit above.
107+
if (result.status === 'error' && emailItem != null) {
108+
await emailItem.increaseQuantity(1);
109+
}
110+
81111
return {
82112
statusCode: 200,
83113
bodyType: 'json',

apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
import { SubscriptionStatus } from "@/generated/prisma/client";
12
import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription } from "@/lib/payments";
23
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
34
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
5+
import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan";
6+
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
7+
import { getStripeForAccount } from "@/lib/stripe";
48
import { getPrismaClientForTenancy } from "@/prisma-client";
59
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
6-
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
710
import { KnownErrors } from "@stackframe/stack-shared";
8-
import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
9-
import { SubscriptionStatus } from "@/generated/prisma/client";
10-
import { getStripeForAccount } from "@/lib/stripe";
11-
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
12-
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
11+
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
12+
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1313

1414
export const DELETE = createSmartRouteHandler({
1515
metadata: {
@@ -150,6 +150,13 @@ export const DELETE = createSmartRouteHandler({
150150
await bulldozerWriteSubscription(prisma, updatedSub);
151151
}
152152

153+
// Regrant the free plan if a Stack Auth billing team just lost their
154+
// only plans-line sub. Scoped to the internal tenancy — customer
155+
// projects' own sub cancellations are for their own products.
156+
if (auth.tenancy.project.id === "internal" && params.customer_type === "team") {
157+
await ensureFreePlanForBillingTeam(params.customer_id);
158+
}
159+
153160
return {
154161
statusCode: 200,
155162
bodyType: "json",

0 commit comments

Comments
 (0)