Add transactions to auth procedures and extract DB models

- Wrap multiple DB operations in transactions for atomicity:
  - login-if-completed: device upsert + session + login_request deletion
  - forgot-password: delete old tokens + insert new token
  - signup: session + email_verification creation

- Extract reusable DB model operations to packages/db/src/models/:
  - sessions.ts: insertSession()
  - user-devices.ts: upsertUserDevice(), isDeviceTrusted()

- Update session.ts to use new model functions from @reviq/db
- Fix type narrowing in admin.test.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-12 15:52:05 +08:00
parent 5a2e0297e5
commit b085a315be
9 changed files with 241 additions and 124 deletions

View File

@@ -1095,15 +1095,17 @@ describeE2E("admin", () => {
expect(org?.display_name).toBe("New Organization");
// Verify owner membership
const membership = await db
.selectFrom("org_members")
.where("org_id", "=", org?.id)
.where("user_id", "=", owner.id)
.selectAll()
.executeTakeFirst();
if (org) {
const membership = await db
.selectFrom("org_members")
.where("org_id", "=", org.id)
.where("user_id", "=", owner.id)
.selectAll()
.executeTakeFirst();
expect(membership).toBeDefined();
expect(membership?.role).toBe("owner");
expect(membership).toBeDefined();
expect(membership?.role).toBe("owner");
}
});
test("normalizes owner email to lowercase", async () => {

View File

@@ -30,26 +30,29 @@ export const forgotPassword = os.auth.forgotPassword.handler(
// If user exists, create password reset token and send email
if (user) {
// Delete any existing password reset tokens for this user (security measure)
await context.db
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
// Generate secure base58 token
const token = generateSecureBase58Token();
// Create password reset record with 1 hour expiry
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
await context.db
.insertInto("password_resets")
.values({
user_id: user.id,
token,
expires_at: expiresAt,
})
.execute();
// Delete old tokens and insert new one in transaction
await context.db.transaction().execute(async (trx) => {
// Delete any existing password reset tokens for this user (security measure)
await trx
.deleteFrom("password_resets")
.where("user_id", "=", user.id)
.execute();
await trx
.insertInto("password_resets")
.values({
user_id: user.id,
token,
expires_at: expiresAt,
})
.execute();
});
// Send password reset email (stubbed)
await sendPasswordResetEmail(user.email, token);

View File

@@ -89,36 +89,39 @@ export const loginIfRequestIsCompleted =
const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders);
// Upsert user device
const deviceId = await upsertUserDevice(
context.db,
userId,
deviceFingerprint,
geo,
userAgent,
);
// Create session in transaction (atomic: device upsert + session + login_request delete)
const { session, deviceTrusted } = await context.db
.transaction()
.execute(async (trx) => {
// Upsert user device
const deviceId = await upsertUserDevice(
trx,
userId,
deviceFingerprint,
geo,
userAgent,
);
// Check if device is already trusted
const deviceTrusted = await isDeviceTrusted(
context.db,
userId,
deviceFingerprint,
);
// Check if device is already trusted
const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
// Create session with trusted mode = true (email-confirmed login)
const session = await createSession(context.db, {
userId,
deviceId,
trustedMode: true,
geo,
userAgent,
});
// Create session with trusted mode = true (email-confirmed login)
const newSession = await createSession(trx, {
userId,
deviceId,
trustedMode: true,
geo,
userAgent,
});
// Delete the login request (it's been consumed)
await context.db
.deleteFrom("login_requests")
.where("id", "=", loginRequest.id)
.execute();
// Delete the login request (it's been consumed)
await trx
.deleteFrom("login_requests")
.where("id", "=", loginRequest.id)
.execute();
return { session: newSession, deviceTrusted: trusted };
});
// Set session cookie
setCookie(

View File

@@ -269,13 +269,34 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
});
}
// Create session (7 days, trusted mode false initially, no device)
const session = await createSession(context.db, {
userId,
deviceId: null,
trustedMode: false,
geo,
userAgent,
// Generate verification token
const verificationToken = generateSecureBase58Token();
const verificationExpiresAt = generateExpiry(
TOKEN_DURATIONS.EMAIL_VERIFICATION,
);
// Create session and email verification in transaction
const session = await context.db.transaction().execute(async (trx) => {
// Create session (7 days, trusted mode false initially, no device)
const newSession = await createSession(trx, {
userId,
deviceId: null,
trustedMode: false,
geo,
userAgent,
});
// Store verification token (store raw token, not hash - it's already high-entropy)
await trx
.insertInto("email_verifications")
.values({
user_id: userId,
token: verificationToken,
expires_at: verificationExpiresAt,
})
.execute();
return newSession;
});
// Set session cookie
@@ -286,20 +307,6 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
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 (stubbed)
await sendVerificationEmail(email, verificationToken);

View File

@@ -1,6 +1,11 @@
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import type { Kysely, Transaction } from "kysely";
import type { GeoInfo } from "./geo.js";
import {
isDeviceTrusted as dbIsDeviceTrusted,
upsertUserDevice as dbUpsertUserDevice,
insertSession,
} from "@reviq/db";
import { COOKIE_DURATIONS } from "./cookies.js";
import { generateExpiry, generateSessionToken, hashToken } from "./crypto.js";
@@ -23,33 +28,26 @@ export interface SessionResult {
* Returns the raw token (to be sent in cookie) and session details
*/
export async function createSession(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
options: CreateSessionOptions,
): Promise<SessionResult> {
const token = generateSessionToken();
const tokenHash = await hashToken(token);
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
const result = await db
.insertInto("sessions")
.values({
user_id: options.userId,
device_id: options.deviceId,
token_hash: tokenHash,
trusted_mode: options.trustedMode,
ip_address: options.geo.ip,
city: options.geo.city,
region: options.geo.region,
country: options.geo.country,
user_agent: options.userAgent,
expires_at: expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
const result = await insertSession(db, {
userId: options.userId,
deviceId: options.deviceId,
tokenHash,
trustedMode: options.trustedMode,
geo: options.geo,
userAgent: options.userAgent,
expiresAt,
});
return {
token,
sessionId: Number(result.id),
sessionId: result.sessionId,
expiresAt,
};
}
@@ -60,53 +58,22 @@ export async function createSession(
* Returns the device ID
*/
export async function upsertUserDevice(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
geo: GeoInfo,
userAgent: string,
): Promise<number> {
const result = await db
.insertInto("user_devices")
.values({
user_id: userId,
device_fingerprint: deviceFingerprint,
user_agent: userAgent,
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
})
.onConflict((oc) =>
oc.columns(["user_id", "device_fingerprint"]).doUpdateSet({
ip_address: geo.ip,
city: geo.city,
region: geo.region,
country: geo.country,
user_agent: userAgent,
last_used_at: new Date(),
}),
)
.returning(["id"])
.executeTakeFirstOrThrow();
return Number(result.id);
return dbUpsertUserDevice(db, userId, deviceFingerprint, geo, userAgent);
}
/**
* Check if a device is trusted for a user
*/
export async function isDeviceTrusted(
db: Kysely<Database>,
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
): Promise<boolean> {
const device = await db
.selectFrom("user_devices")
.select(["is_trusted"])
.where("user_id", "=", userId)
.where("device_fingerprint", "=", deviceFingerprint)
.executeTakeFirst();
return device?.is_trusted ?? false;
return dbIsDeviceTrusted(db, userId, deviceFingerprint);
}