Skip to content

Commit 0f3daeb

Browse files
committed
Merge branch 'dev' into emu-with-a-q
2 parents 771ed15 + 9cf0d43 commit 0f3daeb

40 files changed

Lines changed: 1444 additions & 831 deletions

File tree

apps/backend/package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stackframe/backend",
3-
"version": "2.8.77",
3+
"version": "2.8.78",
44
"repository": "https://github.com/stack-auth/stack-auth",
55
"private": true,
66
"type": "module",
@@ -62,7 +62,7 @@
6262
"@openrouter/ai-sdk-provider": "2.2.3",
6363
"@opentelemetry/api": "^1.9.0",
6464
"@opentelemetry/api-logs": "^0.53.0",
65-
"@opentelemetry/auto-instrumentations-node": "^0.67.3",
65+
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
6666
"@opentelemetry/context-async-hooks": "^1.26.0",
6767
"@opentelemetry/core": "^1.26.0",
6868
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
@@ -79,16 +79,16 @@
7979
"@prisma/extension-read-replicas": "^0.5.0",
8080
"@prisma/instrumentation": "^7.0.0",
8181
"@react-email/render": "^1.2.1",
82-
"@sentry/nextjs": "^10.11.0",
83-
"@simplewebauthn/server": "^11.0.0",
82+
"@sentry/nextjs": "^10.45.0",
83+
"@simplewebauthn/server": "^13.3.0",
8484
"@stackframe/stack": "workspace:*",
8585
"@stackframe/stack-shared": "workspace:*",
8686
"@upstash/qstash": "^2.8.2",
8787
"@vercel/functions": "^2.0.0",
8888
"@vercel/otel": "^1.10.4",
8989
"@vercel/sandbox": "^1.2.0",
9090
"ai": "^6.0.0",
91-
"bcrypt": "^5.1.1",
91+
"bcrypt": "^6.0.0",
9292
"cel-js": "^0.8.2",
9393
"chokidar-cli": "^3.0.0",
9494
"dotenv": "^16.4.5",
@@ -111,7 +111,7 @@
111111
"semver": "^7.6.3",
112112
"sharp": "^0.34.4",
113113
"stripe": "^18.3.0",
114-
"svix": "^1.25.0",
114+
"svix": "^1.89.0",
115115
"vite": "^6.1.0",
116116
"yaml": "^2.4.5",
117117
"yup": "^1.7.1",

apps/backend/prisma/migrations/20260308000000_add_signup_fraud_protection/migration.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ ALTER TABLE "ProjectUser"
1010
ADD COLUMN "signUpEmailNormalized" TEXT,
1111
ADD COLUMN "signUpEmailBase" TEXT;
1212

13+
-- Backward compatibility during rollout: old backends omit `signedUpAt` on
14+
-- INSERT, so set a default immediately in the first migration.
15+
ALTER TABLE "ProjectUser" ALTER COLUMN "signedUpAt" SET DEFAULT CURRENT_TIMESTAMP;
16+
1317
-- Add the risk score bounds without validating existing rows yet.
1418
ALTER TABLE "ProjectUser"
1519
ADD CONSTRAINT "ProjectUser_risk_score_bot_range"

apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/migration.sql

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_bot_range"
2828
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
2929
ALTER TABLE "ProjectUser" VALIDATE CONSTRAINT "ProjectUser_risk_score_free_trial_abuse_range";
3030

31-
-- Enforce `signedUpAt` after the backfill is complete. We intentionally require
32-
-- inserts to provide the value explicitly instead of hiding that behavior in a trigger.
3331
-- SPLIT_STATEMENT_SENTINEL
3432
-- SINGLE_STATEMENT_SENTINEL
3533
-- RUN_OUTSIDE_TRANSACTION_SENTINEL

apps/backend/prisma/migrations/20260308000002_finalize_signup_fraud_protection/tests/finalized-signup-fraud-protection.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const postMigration = async (sql: Sql) => {
7171
`;
7272
expect(colInfo).toHaveLength(1);
7373
expect(colInfo[0].is_nullable).toBe('NO');
74-
expect(colInfo[0].column_default).toBe(null);
74+
expect(colInfo[0].column_default).toBe('CURRENT_TIMESTAMP');
7575

7676
await sql`
7777
INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode")
@@ -82,7 +82,9 @@ export const postMigration = async (sql: Sql) => {
8282
VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")
8383
`;
8484

85-
await expect(sql`
85+
// INSERT without signedUpAt should succeed — DEFAULT CURRENT_TIMESTAMP fills it in.
86+
const defaultUserId = randomUUID();
87+
await sql`
8688
INSERT INTO "ProjectUser" (
8789
"projectUserId",
8890
"tenancyId",
@@ -92,16 +94,25 @@ export const postMigration = async (sql: Sql) => {
9294
"updatedAt",
9395
"lastActiveAt"
9496
) VALUES (
95-
${userId}::uuid,
97+
${defaultUserId}::uuid,
9698
${tenancyId}::uuid,
9799
${projectId},
98100
'main',
99101
NOW(),
100102
NOW(),
101103
NOW()
102104
)
103-
`).rejects.toThrow(/signedUpAt/);
105+
`;
106+
107+
const defaultRows = await sql`
108+
SELECT "signedUpAt"
109+
FROM "ProjectUser"
110+
WHERE "projectUserId" = ${defaultUserId}::uuid
111+
`;
112+
expect(defaultRows).toHaveLength(1);
113+
expect(defaultRows[0].signedUpAt).not.toBeNull();
104114

115+
// INSERT with explicit signedUpAt should use the provided value.
105116
await sql`
106117
INSERT INTO "ProjectUser" (
107118
"projectUserId",

apps/backend/prisma/migrations/20260323000000_add_signed_up_at_default/migration.sql

Lines changed: 0 additions & 8 deletions
This file was deleted.

apps/backend/src/app/api/latest/auth/passkey/initiate-passkey-registration/route.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ export const POST = createSmartRouteHandler({
5555
timeout: REGISTRATION_TIMEOUT_MS,
5656
};
5757

58-
const registrationOptions = await generateRegistrationOptions(opts);
58+
const registrationOptionsRaw = await generateRegistrationOptions(opts);
59+
const registrationOptions = registrationOptionsRaw.hints != null && registrationOptionsRaw.hints.length === 0
60+
? (() => {
61+
const { hints: _, ...rest } = registrationOptionsRaw;
62+
return rest;
63+
})()
64+
: registrationOptionsRaw;
5965

6066
const { code } = await registerVerificationCodeHandler.createCode({
6167
tenancy,

apps/backend/src/app/api/latest/auth/passkey/register/verification-code-handler.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const registerVerificationCodeHandler = createVerificationCodeHandler({
7575
});
7676

7777

78-
if (!verification.verified || !verification.registrationInfo) {
78+
if (!verification.verified) {
7979
throw new KnownErrors.PasskeyRegistrationFailed("Passkey registration failed because the verification response is invalid");
8080
}
8181

apps/backend/src/lib/email-queue-step.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction
88
import { withTraceSpan } from "@/utils/telemetry";
99
import { allPromisesAndWaitUntilEach } from "@/utils/vercel";
1010
import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays";
11-
import { getEnvBoolean, getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
11+
import { getEnvBoolean, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
1212
import { captureError, errorToNiceString, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1313
import { Json } from "@stackframe/stack-shared/dist/utils/json";
1414
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
1515
import { Result } from "@stackframe/stack-shared/dist/utils/results";
16-
import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry";
1716
import { randomUUID } from "node:crypto";
1817
import { checkEmailWithEmailable, type EmailableCheckResult } from "./emailable";
1918
import { lowLevelSendEmailDirectWithoutRetries } from "./emails-low-level";
@@ -66,14 +65,10 @@ async function verifyEmailDeliverability(
6665
): Promise<EmailableCheckResult> {
6766
// Skip deliverability check if requested or using non-shared email config
6867
if (shouldSkipDeliverabilityCheck || emailConfigType !== "shared") {
69-
return { status: "ok", emailableScore: null };
68+
return { status: "deliverable", emailableScore: null };
7069
}
7170

7271
const result = await checkEmailWithEmailable(email);
73-
// Email queue should not block on emailable failures — treat errors as deliverable
74-
if (result.status === "error") {
75-
return { status: "ok", emailableScore: null };
76-
}
7772
return result;
7873
}
7974

apps/backend/src/lib/emailable.tsx

Lines changed: 62 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
22
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
3+
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
34
import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry";
45
import createEmailableClient from "emailable";
56

@@ -12,9 +13,8 @@ const VERIFY_STATES = ["deliverable", "undeliverable", "risky", "unknown"] as co
1213
type EmailableVerifyResponse = ReturnType<typeof validateVerifyResponse>;
1314

1415
export type EmailableCheckResult =
15-
| { status: "ok", emailableScore: number | null }
16+
| { status: "deliverable", emailableScore: number | null }
1617
| { status: "not-deliverable", emailableResponse: EmailableVerifyResponse, emailableScore: number | null }
17-
| { status: "error", error: unknown, emailableScore: null };
1818

1919

2020
// ── Helpers ────────────────────────────────────────────────────────────
@@ -41,17 +41,15 @@ function validateVerifyResponse(value: unknown) {
4141

4242
async function verifyWithRetries(verifyFn: () => Promise<unknown>, maxAttempts: number, delayBaseMs: number) {
4343
for (let i = 0; i < maxAttempts; i++) {
44-
try {
45-
return await verifyFn();
46-
} catch (error) {
47-
const code = (error != null && typeof error === "object" && !Array.isArray(error))
48-
? Reflect.get(error, "code")
49-
: null;
50-
if (code !== 249) throw error; // only retry rate-limit errors
51-
if (i < maxAttempts - 1) {
52-
await new Promise(r => setTimeout(r, (Math.random() + 0.5) * delayBaseMs * (2 ** i)));
44+
const res: any = await verifyFn();
45+
if (!("state" in res)) {
46+
if ("message" in res && res.message.includes("Your request is taking longer than normal")) {
47+
await wait((Math.random() + 0.5) * delayBaseMs * (2 ** i));
48+
continue;
5349
}
50+
throw new StackAssertionError("Emailable returned an unexpected response body", { response: res });
5451
}
52+
return res;
5553
}
5654
throw new StackAssertionError("Timed out while verifying email address with Emailable");
5755
}
@@ -81,46 +79,47 @@ export async function checkEmailWithEmailable(
8179
_clientFactory?: (apiKey: string) => { verify: (email: string) => Promise<unknown> },
8280
},
8381
): Promise<EmailableCheckResult> {
84-
const rawApiKey = getEnvVariable("STACK_EMAILABLE_API_KEY", "");
85-
const emailDomain = email.split("@")[1]?.toLowerCase() ?? "";
82+
try {
83+
const rawApiKey = getEnvVariable("STACK_EMAILABLE_API_KEY", "");
84+
const emailDomain = email.split("@")[1]?.toLowerCase() ?? "";
85+
86+
// Always reject the explicit test domain, regardless of API key
87+
if (emailDomain === EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN) {
88+
const testResponse = buildTestUndeliverableResponse(email);
89+
return { status: "not-deliverable", emailableResponse: testResponse, emailableScore: testResponse.score };
90+
}
8691

87-
// Always reject the explicit test domain, regardless of API key
88-
if (emailDomain === EMAILABLE_NOT_DELIVERABLE_TEST_DOMAIN) {
89-
const testResponse = buildTestUndeliverableResponse(email);
90-
return { status: "not-deliverable", emailableResponse: testResponse, emailableScore: testResponse.score };
91-
}
92+
if (!rawApiKey) {
93+
if (["development", "test"].includes(getNodeEnvironment())) {
94+
return { status: "deliverable", emailableScore: null };
95+
}
96+
throw new StackAssertionError("STACK_EMAILABLE_API_KEY must not be empty; set it to 'disable_email_validation' to disable email validation");
97+
}
9298

93-
if (!rawApiKey) {
94-
if (["development", "test"].includes(getNodeEnvironment())) {
95-
return { status: "ok", emailableScore: null };
99+
const apiKey = rawApiKey === "disable_email_validation" ? "" : rawApiKey;
100+
if (!apiKey || isReservedTestDomain(emailDomain)) {
101+
return { status: "deliverable", emailableScore: null };
96102
}
97-
throw new StackAssertionError("STACK_EMAILABLE_API_KEY must not be empty; set it to 'disable_email_validation' to disable email validation");
98-
}
99103

100-
const apiKey = rawApiKey === "disable_email_validation" ? "" : rawApiKey;
101-
if (!apiKey || isReservedTestDomain(emailDomain)) {
102-
return { status: "ok", emailableScore: null };
103-
}
104+
const clientFactory = options?._clientFactory ?? createEmailableClient;
105+
const retryDelayBase = options?.retryExponentialDelayBaseMs ?? RETRY_BACKOFF_BASE_MS;
104106

105-
const clientFactory = options?._clientFactory ?? createEmailableClient;
106-
const retryDelayBase = options?.retryExponentialDelayBaseMs ?? RETRY_BACKOFF_BASE_MS;
107-
108-
return await traceSpan("checking email address with Emailable", async () => {
109-
const client = clientFactory(apiKey);
110-
let raw: unknown;
111-
try {
112-
raw = await verifyWithRetries(() => client.verify(email), 4, retryDelayBase);
113-
} catch (error) {
114-
captureError("emailable-api-error", error);
115-
return { status: "error", error, emailableScore: null };
116-
}
117-
const response = validateVerifyResponse(raw);
107+
return await traceSpan("checking email address with Emailable", async () => {
108+
const client = clientFactory(apiKey);
109+
const raw = await verifyWithRetries(() => client.verify(email), 4, retryDelayBase);
110+
console.log("Received emailable response", { email, raw });
111+
const response = validateVerifyResponse(raw);
118112

119-
if (response.state === "undeliverable" || response.disposable) {
120-
return { status: "not-deliverable", emailableResponse: response, emailableScore: response.score };
121-
}
122-
return { status: "ok", emailableScore: response.score };
123-
});
113+
if (response.state === "undeliverable") {
114+
return { status: "not-deliverable", emailableResponse: response, emailableScore: response.score };
115+
}
116+
return { status: "deliverable", emailableScore: response.score };
117+
});
118+
} catch (error) {
119+
captureError("emailable-api-error", new StackAssertionError("Error while checking email address with Emailable", { cause: error, email, options }));
120+
// If there's an error, let's pretend the email is deliverable, albeit with the score unavailable
121+
return { status: "deliverable", emailableScore: null };
122+
}
124123
}
125124

126125

@@ -157,17 +156,29 @@ import.meta.vitest?.describe("checkEmailWithEmailable(...)", () => {
157156

158157
test("returns ok for deliverable email", async ({ expect }) => {
159158
const result = await checkEmailWithEmailable("test@gmail.com", { _clientFactory: deliverableClient });
160-
expect(result.status).toBe("ok");
159+
expect(result).toMatchObject({ status: "deliverable", emailableScore: 95 });
160+
});
161+
162+
test("successfully retries and verifies deliverable email if Emailable asks for a retry the first time", async ({ expect }) => {
163+
let retryCount = 0;
164+
const retryClient = fakeClient(async () => retryCount++ === 0 ? {
165+
message: "Your request is taking longer than normal. Please send your request again."
166+
} : {
167+
state: "deliverable", disposable: false, score: 95, domain: "gmail.com", email: "test@gmail.com", user: "test",
168+
});
169+
const result = await checkEmailWithEmailable("test@gmail.com", { _clientFactory: retryClient });
170+
expect(retryCount).toBe(2);
171+
expect(result).toMatchObject({ status: "deliverable", emailableScore: 95 });
161172
});
162173

163-
test("returns error on API error", async ({ expect }) => {
174+
test("returns deliverable on API error", async ({ expect }) => {
164175
const result = await checkEmailWithEmailable("test@gmail.com", { _clientFactory: errorClient });
165-
expect(result.status).toBe("error");
176+
expect(result).toMatchObject({ status: "deliverable", emailableScore: null });
166177
});
167178

168-
test("throws on malformed Emailable response bodies", async ({ expect }) => {
179+
test("returns deliverable on malformed Emailable response bodies", async ({ expect }) => {
169180
const malformedClient = fakeClient(async () => "definitely not an object");
170-
await expect(checkEmailWithEmailable("test@gmail.com", { _clientFactory: malformedClient }))
171-
.rejects.toThrowError("Emailable returned a non-object response body");
181+
const result = await checkEmailWithEmailable("test@gmail.com", { _clientFactory: malformedClient });
182+
expect(result).toMatchObject({ status: "deliverable", emailableScore: null });
172183
});
173184
});
Submodule implementation added at a93d7ea

0 commit comments

Comments
 (0)