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:
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
34
packages/db/src/helpers/with-transaction.ts
Normal file
34
packages/db/src/helpers/with-transaction.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user