Skip to content
52 changes: 52 additions & 0 deletions apps/web/app/(ee)/api/cron/better-auth/migrate-accounts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { qstash } from "@/lib/cron";
import { withCron } from "@/lib/cron/with-cron";
import { prisma } from "@/lib/prisma";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { logAndRespond } from "../../utils";

export const dynamic = "force-dynamic";
export const maxDuration = 300;

const BATCH_SIZE = 1000;
const ITERATIONS = 10;

// POST /api/cron/better-auth/migrate-accounts
export const POST = withCron(async () => {
for (let i = 0; i < ITERATIONS; i++) {
const count = await prisma.$executeRaw`
UPDATE Account
SET
accountId = COALESCE(accountId, providerAccountId),
providerId = COALESCE(providerId, provider),
accessToken = COALESCE(accessToken, access_token),
refreshToken = COALESCE(refreshToken, refresh_token),
idToken = COALESCE(idToken, id_token)
WHERE
accountId IS NULL
AND providerAccountId IS NOT NULL
ORDER BY id
LIMIT ${BATCH_SIZE}
`;

console.log(`Migrated ${count} accounts.`);

if (count === 0) {
return logAndRespond("Finished migrating accounts.");
}
}

const qstashResponse = await qstash.publishJSON({
method: "POST",
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/better-auth/migrate-accounts`,
delay: "10s",
retries: 0,
flowControl: {
key: "better-auth-migrate-accounts",
parallelism: 1,
},
});

return logAndRespond(
`Scheduled next batch of accounts to migrate ${qstashResponse.messageId}`,
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { qstash } from "@/lib/cron";
import { withCron } from "@/lib/cron/with-cron";
import { prisma } from "@/lib/prisma";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { logAndRespond } from "../../utils";

export const dynamic = "force-dynamic";
export const maxDuration = 300;

const BATCH_SIZE = 1000;
const ITERATIONS = 10;

// POST /api/cron/better-auth/migrate-credentials
export const POST = withCron(async () => {
for (let i = 0; i < ITERATIONS; i++) {
const users = await prisma.user.findMany({
where: {
passwordHash: {
not: null,
},
accounts: {
none: {
providerId: "credential",
},
},
},
select: {
id: true,
passwordHash: true,
},
orderBy: {
id: "asc",
},
take: BATCH_SIZE,
});

if (users.length === 0) {
return logAndRespond("Finished migrating credentials.");
}

const { count } = await prisma.account.createMany({
skipDuplicates: true,
data: users.map((user) => ({
userId: user.id,
accountId: user.id,
providerId: "credential",
password: user.passwordHash,
})),
});

console.log(`Migrated ${count} credentials.`);

// No rows inserted while candidates remain means the batch cannot progress.
if (count === 0) {
return logAndRespond(
`Stopped migrating credentials: ${users.length} users matched but no accounts were created.`,
{ logLevel: "error" },
);
}
}
Comment on lines +37 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Add a progress guard. The batch can repeat forever.

createMany uses skipDuplicates: true. The returned count is logged but never checked. If count is 0 while users.length is greater than 0, the findMany filter still matches the same users on the next iteration. The loop then processes the same 1000 rows 10 times and schedules another QStash message. retries: 0 does not stop this, because each run publishes a new message. The chain repeats every 10 seconds without end.

migrate-users and migrate-accounts both exit on count === 0. This route needs the same guard.

🐛 Proposed fix
     const { count } = await prisma.account.createMany({
       skipDuplicates: true,
       data: users.map((user) => ({
         userId: user.id,
         accountId: user.id,
         providerId: "credential",
         password: user.passwordHash,
       })),
     });
 
     console.log(`Migrated ${count} credentials.`);
+
+    // No rows inserted while candidates remain means the batch cannot progress.
+    if (count === 0) {
+      return logAndRespond(
+        `Stopped migrating credentials: ${users.length} users matched but no accounts were created.`,
+        true,
+      );
+    }
   }

Confirm the second argument of logAndRespond marks an error response before you apply it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/cron/better-auth/migrate-credentials/route.ts around
lines 33 - 48, In the migration loop after prisma.account.createMany, add the
same count === 0 progress guard used by migrate-users and migrate-accounts: call
logAndRespond with an error response before continuing or scheduling another
batch. Confirm and use the existing second-argument convention for marking
errors, while preserving normal logging and completion behavior when count is
positive.


const qstashResponse = await qstash.publishJSON({
method: "POST",
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/better-auth/migrate-credentials`,
delay: "10s",
retries: 0,
flowControl: {
key: "better-auth-migrate-credentials",
parallelism: 1,
},
});

return logAndRespond(
`Scheduled next batch of credentials to migrate ${qstashResponse.messageId}`,
);
});
50 changes: 50 additions & 0 deletions apps/web/app/(ee)/api/cron/better-auth/migrate-users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { qstash } from "@/lib/cron";
import { withCron } from "@/lib/cron/with-cron";
import { prisma } from "@/lib/prisma";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { logAndRespond } from "../../utils";

export const dynamic = "force-dynamic";
export const maxDuration = 300;

const BATCH_SIZE = 1000;
const ITERATIONS = 10;

// POST /api/cron/better-auth/migrate-users
export const POST = withCron(async () => {
Comment on lines +7 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No migration route declares maxDuration. All three routes run up to 10 sequential batches of 1000 records in a single request but export only dynamic = "force-dynamic". If the platform default timeout ends a request, retries: 0 prevents a QStash retry, and the migration chain stops without publishing the follow-up message.

  • apps/web/app/(ee)/api/cron/better-auth/migrate-users/route.ts#L7-L13: add export const maxDuration = 300; next to the dynamic export.
  • apps/web/app/(ee)/api/cron/better-auth/migrate-accounts/route.ts#L7-L13: add the same maxDuration export; the raw UPDATE ... LIMIT 1000 loop carries the same timeout risk.
  • apps/web/app/(ee)/api/cron/better-auth/migrate-credentials/route.ts#L7-L13: add the same maxDuration export; this route runs two queries per iteration, so it is the slowest of the three.

Match the value to the plan limit for this deployment.

📍 Affects 3 files
  • apps/web/app/(ee)/api/cron/better-auth/migrate-users/route.ts#L7-L13 (this comment)
  • apps/web/app/(ee)/api/cron/better-auth/migrate-accounts/route.ts#L7-L13
  • apps/web/app/(ee)/api/cron/better-auth/migrate-credentials/route.ts#L7-L13
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/cron/better-auth/migrate-users/route.ts around lines 7
- 13, Add the deployment plan's 300-second maxDuration export beside the
existing dynamic export in
apps/web/app/(ee)/api/cron/better-auth/migrate-users/route.ts (lines 7-13),
migrate-accounts/route.ts (lines 7-13), and migrate-credentials/route.ts (lines
7-13); no other changes are required.

for (let i = 0; i < ITERATIONS; i++) {
const { count } = await prisma.user.updateMany({
where: {
emailVerified: {
not: null,
},
emailVerifiedBa: false,
},
data: {
emailVerifiedBa: true,
},
limit: BATCH_SIZE,
});

console.log(`Migrated ${count} users.`);

if (count === 0) {
return logAndRespond("Finished migrating users.");
}
}

const qstashResponse = await qstash.publishJSON({
method: "POST",
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/better-auth/migrate-users`,
delay: "10s",
retries: 0,
flowControl: {
key: "better-auth-migrate-users",
parallelism: 1,
},
});

return logAndRespond(
`Scheduled next batch of users to migrate ${qstashResponse.messageId}`,
);
});
16 changes: 15 additions & 1 deletion apps/web/app/api/auth/reset-password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export async function POST(req: NextRequest) {
email: identifier,
},
select: {
id: true,
emailVerified: true,
passwordHash: true,
},
Expand All @@ -72,6 +73,8 @@ export async function POST(req: NextRequest) {
}
}

const newPasswordHash = await hashPassword(password);

await prisma.$transaction([
// Delete the token
prisma.passwordResetToken.deleteMany({
Expand All @@ -86,14 +89,25 @@ export async function POST(req: NextRequest) {
email: identifier,
},
data: {
passwordHash: await hashPassword(password),
passwordHash: newPasswordHash,
lockedAt: null, // Unlock the account after a successful password reset
...(!user.emailVerified && {
emailVerified: new Date(),
emailVerifiedBa: true,
}),
},
}),

// Dual write: keep Better Auth credential Account.password in sync
prisma.account.updateMany({
where: {
userId: user.id,
providerId: "credential",
},
data: {
password: newPasswordHash,
},
}),
]);

// Send the email to inform the user that their password has been reset
Expand Down
17 changes: 15 additions & 2 deletions apps/web/app/api/user/password/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,26 @@ export const PATCH = withSession(async ({ req, session }) => {
});
}

await Promise.all([
const newPasswordHash = await hashPassword(newPassword);

await prisma.$transaction([
prisma.user.update({
where: {
id: session.user.id,
},
data: {
passwordHash: await hashPassword(newPassword),
passwordHash: newPasswordHash,
},
}),

// Dual write: keep Better Auth credential Account.password in sync
prisma.account.updateMany({
where: {
userId: session.user.id,
providerId: "credential",
},
data: {
password: newPasswordHash,
},
}),

Expand Down
Loading
Loading