Skip to content

Commit 3042146

Browse files
authored
Merge branch 'dev' into codex/tanstack-start-integration
2 parents 8b7ce66 + b0812c8 commit 3042146

117 files changed

Lines changed: 5917 additions & 1044 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.

.claude/CLAUDE-KNOWLEDGE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,6 @@ A: Invalid `tools` entries are rejected by `requestBodySchema` in `apps/backend/
361361

362362
## Q: Why did the internal metrics E2E snapshots need to change in April 2026?
363363
A: The `/api/v1/internal/metrics` response now intentionally includes `analytics_overview.daily_anonymous_visitors_fallback`, `analytics_overview.anonymous_visitors_fallback`, and `active_users_by_country`. Those additions are reflected in `packages/stack-shared/src/interface/admin-metrics.ts` and the backend route, so the E2E snapshots must include them instead of treating them as regressions.
364+
365+
## Q: Why can environment config override writes fail with a product/product-line customer type warning after creating a preview project?
366+
A: The environment override endpoint validates the new environment override against the rendered branch config. Preview dummy payments data must therefore be internally coherent: products assigned to a product line need the same `customerType` as that product line, otherwise unrelated environment patches can fail with warnings like `Product "growth" has customer type "user" but its product line "workspace" has customer type "team"`.

apps/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stackframe/backend",
3-
"version": "2.8.86",
3+
"version": "2.8.87",
44
"repository": "https://github.com/stack-auth/stack-auth",
55
"private": true,
66
"type": "module",

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: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
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";
11+
import * as zlib from "node:zlib";
812

913
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
1014

1115
const MAX_EVENTS = 500;
16+
const MAX_COMPRESSED_BYTES = 1 * 1024 * 1024;
17+
const MAX_DECOMPRESSED_BYTES = 8 * 1024 * 1024;
1218

1319
// Lone surrogates (\uD800-\uDFFF not part of a valid pair) are technically
1420
// representable in JS strings but rejected by ClickHouse's JSON parser.
@@ -32,6 +38,35 @@ function stripLoneSurrogates(value: unknown): unknown {
3238
return value;
3339
}
3440

41+
// Bodies sent as application/octet-stream are gzipped JSON. The encoding is
42+
// purely to evade keyword-matching adblockers (e.g. filters on "$click").
43+
// We gunzip + JSON.parse here so the rest of the schema can validate the
44+
// decoded object normally.
45+
function maybeDecodeBinaryBody(value: unknown): unknown {
46+
let bytes: Uint8Array | undefined;
47+
if (value instanceof ArrayBuffer) {
48+
bytes = new Uint8Array(value);
49+
} else if (value instanceof Uint8Array) {
50+
bytes = value;
51+
}
52+
if (!bytes) return value;
53+
54+
if (bytes.byteLength > MAX_COMPRESSED_BYTES) {
55+
throw new StatusError(StatusError.BadRequest, "Encoded analytics body too large");
56+
}
57+
let decompressed: Buffer;
58+
try {
59+
decompressed = zlib.gunzipSync(bytes, { maxOutputLength: MAX_DECOMPRESSED_BYTES });
60+
} catch {
61+
throw new StatusError(StatusError.BadRequest, "Invalid encoded analytics body");
62+
}
63+
try {
64+
return JSON.parse(decompressed.toString("utf-8"));
65+
} catch {
66+
throw new StatusError(StatusError.BadRequest, "Invalid encoded analytics body");
67+
}
68+
}
69+
3570
export const POST = createSmartRouteHandler({
3671
metadata: {
3772
summary: "Upload analytics event batch",
@@ -57,7 +92,7 @@ export const POST = createSmartRouteHandler({
5792
data: yupMixed().defined(),
5893
}).defined(),
5994
).defined().min(1).max(MAX_EVENTS),
60-
}).defined(),
95+
}).defined().transform((_value, originalValue) => maybeDecodeBinaryBody(originalValue)),
6196
}),
6297
response: yupObject({
6398
statusCode: yupNumber().oneOf([200]).defined(),
@@ -83,6 +118,17 @@ export const POST = createSmartRouteHandler({
83118
const refreshTokenId = auth.refreshTokenId;
84119
const tenancyId = auth.tenancy.id;
85120

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+
86132
const prisma = await getPrismaClientForTenancy(auth.tenancy);
87133
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
88134

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(),

0 commit comments

Comments
 (0)