Fix merge conflicts and add withTransaction helper

- Add withTransaction helper that gracefully handles nested transactions
  (reuses existing transaction in tests, starts new one otherwise)
- Update auth procedures to use withTransaction instead of direct .transaction()
- Add email config to all e2e test contexts (required by merged code)
- Remove duplicate verification token code from signup procedure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 17:07:14 +08:00
parent 8e65c2e698
commit e43c006bb1
10 changed files with 82 additions and 20 deletions

View File

@@ -39,6 +39,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
@@ -75,6 +76,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -50,6 +50,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
@@ -100,6 +101,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -32,6 +32,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
@@ -82,6 +83,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -23,6 +23,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js"; import { hashToken } from "../../utils/crypto.js";
@@ -58,6 +59,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -23,6 +23,7 @@ import {
uniqueTestId, uniqueTestId,
withTestTransaction, withTestTransaction,
} from "@reviq/test-helpers"; } from "@reviq/test-helpers";
import { createLoggingEmailClient } from "@reviq/emails";
import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
@@ -51,6 +52,11 @@ function createAPIContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }
@@ -133,6 +139,11 @@ function createLoginRequestContext(
rpName: TEST_RP.rpName, rpName: TEST_RP.rpName,
reqHeaders, reqHeaders,
resHeaders: new Headers(), resHeaders: new Headers(),
email: {
client: createLoggingEmailClient(),
fromAddress: "test@example.com",
baseUrl: TEST_RP.origin,
},
}; };
} }

View File

@@ -6,6 +6,7 @@
* This prevents attackers from determining which emails are registered * This prevents attackers from determining which emails are registered
*/ */
import { withTransaction } from "@reviq/db";
import { sendPasswordResetEmail } from "@reviq/emails"; import { sendPasswordResetEmail } from "@reviq/emails";
import { TOKEN_DURATIONS } from "../../utils/cookies.js"; import { TOKEN_DURATIONS } from "../../utils/cookies.js";
import { import {
@@ -37,7 +38,7 @@ export const forgotPassword = os.auth.forgotPassword.handler(
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
// Delete old tokens and insert new one in transaction // Delete old tokens and insert new one in transaction
await context.db.transaction().execute(async (trx) => { await withTransaction(context.db, async (trx) => {
// Delete any existing password reset tokens for this user (security measure) // Delete any existing password reset tokens for this user (security measure)
await trx await trx
.deleteFrom("password_resets") .deleteFrom("password_resets")

View File

@@ -16,6 +16,7 @@
* e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' } * e. Return { status: 'completed', redirectTo: '/dashboard' or '/auth/trust-device' }
*/ */
import { withTransaction } from "@reviq/db";
import { import {
COOKIE_NAMES, COOKIE_NAMES,
COOKIE_OPTIONS, COOKIE_OPTIONS,
@@ -90,9 +91,9 @@ export const loginIfRequestIsCompleted =
const userAgent = getUserAgent(context.reqHeaders); const userAgent = getUserAgent(context.reqHeaders);
// Create session in transaction (atomic: device upsert + session + login_request delete) // Create session in transaction (atomic: device upsert + session + login_request delete)
const { session, deviceTrusted } = await context.db const { session, deviceTrusted } = await withTransaction(
.transaction() context.db,
.execute(async (trx) => { async (trx) => {
// Upsert user device // Upsert user device
const deviceId = await upsertUserDevice( const deviceId = await upsertUserDevice(
trx, trx,

View File

@@ -10,6 +10,7 @@ import type {
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
import type { RPInfo } from "../../utils/webauthn.js"; import type { RPInfo } from "../../utils/webauthn.js";
import { ORPCError } from "@orpc/server"; import { ORPCError } from "@orpc/server";
import { withTransaction } from "@reviq/db";
import { sendVerificationEmail } from "@reviq/emails"; import { sendVerificationEmail } from "@reviq/emails";
import { verifyRegistrationResponse } from "@simplewebauthn/server"; import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { import {
@@ -159,7 +160,7 @@ export async function signupWithPasskey(
// Create user and passkey in a transaction (handle race condition if concurrent signup) // Create user and passkey in a transaction (handle race condition if concurrent signup)
try { try {
const result = await db.transaction().execute(async (trx) => { const result = await withTransaction(db, async (trx) => {
// Create user // Create user
const user = await trx const user = await trx
.insertInto("users") .insertInto("users")
@@ -276,7 +277,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
); );
// Create session and email verification in transaction // Create session and email verification in transaction
const session = await context.db.transaction().execute(async (trx) => { const session = await withTransaction(context.db, async (trx) => {
// Create session (7 days, trusted mode false initially, no device) // Create session (7 days, trusted mode false initially, no device)
const newSession = await createSession(trx, { const newSession = await createSession(trx, {
userId, userId,
@@ -307,20 +308,6 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
COOKIE_OPTIONS.session, COOKIE_OPTIONS.session,
); );
// Generate verification token
const verificationToken = generateSecureBase58Token();
const expiresAt = generateExpiry(TOKEN_DURATIONS.EMAIL_VERIFICATION);
// Store verification token (store raw token, not hash - it's already high-entropy)
await context.db
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken,
expires_at: expiresAt,
})
.execute();
// Send verification email // Send verification email
await sendVerificationEmail({ await sendVerificationEmail({
client: context.email.client, client: context.email.client,

View File

@@ -0,0 +1,34 @@
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
/**
* Type for a database connection that could be either a Kysely instance or a Transaction
*/
export type DbConnection = Kysely<Database> | Transaction<Database>;
/**
* Execute a callback within a transaction, handling nested transaction scenarios.
*
* If the provided db is already a transaction, the callback is executed directly
* without starting a new transaction (since Kysely doesn't support nested transactions).
*
* If the provided db is a regular Kysely instance, a new transaction is started.
*
* @param db - Database connection (Kysely instance or Transaction)
* @param callback - Function to execute within the transaction
* @returns The result of the callback
*/
export async function withTransaction<T>(
db: DbConnection,
callback: (trx: Transaction<Database>) => Promise<T>,
): Promise<T> {
// Check if db is already a transaction
// Kysely Transaction objects have isTransaction = true
if ("isTransaction" in db && db.isTransaction) {
// Already in a transaction, execute callback directly
return callback(db as Transaction<Database>);
}
// Not in a transaction, start one
return (db as Kysely<Database>).transaction().execute(callback);
}

View File

@@ -33,6 +33,10 @@ export {
parseToken, parseToken,
TOKEN_PREFIX, TOKEN_PREFIX,
} from "./helpers/token.js"; } from "./helpers/token.js";
export {
type DbConnection,
withTransaction,
} from "./helpers/with-transaction.js";
/** /**
* Export model operations * Export model operations
*/ */