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"); expect(org?.display_name).toBe("New Organization");
// Verify owner membership // Verify owner membership
const membership = await db if (org) {
.selectFrom("org_members") const membership = await db
.where("org_id", "=", org?.id) .selectFrom("org_members")
.where("user_id", "=", owner.id) .where("org_id", "=", org.id)
.selectAll() .where("user_id", "=", owner.id)
.executeTakeFirst(); .selectAll()
.executeTakeFirst();
expect(membership).toBeDefined(); expect(membership).toBeDefined();
expect(membership?.role).toBe("owner"); expect(membership?.role).toBe("owner");
}
}); });
test("normalizes owner email to lowercase", async () => { 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 exists, create password reset token and send email
if (user) { 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 // Generate secure base58 token
const token = generateSecureBase58Token(); const token = generateSecureBase58Token();
// Create password reset record with 1 hour expiry // Create password reset record with 1 hour expiry
const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET); const expiresAt = generateExpiry(TOKEN_DURATIONS.PASSWORD_RESET);
await context.db // Delete old tokens and insert new one in transaction
.insertInto("password_resets") await context.db.transaction().execute(async (trx) => {
.values({ // Delete any existing password reset tokens for this user (security measure)
user_id: user.id, await trx
token, .deleteFrom("password_resets")
expires_at: expiresAt, .where("user_id", "=", user.id)
}) .execute();
.execute();
await trx
.insertInto("password_resets")
.values({
user_id: user.id,
token,
expires_at: expiresAt,
})
.execute();
});
// Send password reset email (stubbed) // Send password reset email (stubbed)
await sendPasswordResetEmail(user.email, token); await sendPasswordResetEmail(user.email, token);

View File

@@ -89,36 +89,39 @@ export const loginIfRequestIsCompleted =
const geo = getGeoInfo(context.reqHeaders, context.clientIP); const geo = getGeoInfo(context.reqHeaders, context.clientIP);
const userAgent = getUserAgent(context.reqHeaders); const userAgent = getUserAgent(context.reqHeaders);
// Upsert user device // Create session in transaction (atomic: device upsert + session + login_request delete)
const deviceId = await upsertUserDevice( const { session, deviceTrusted } = await context.db
context.db, .transaction()
userId, .execute(async (trx) => {
deviceFingerprint, // Upsert user device
geo, const deviceId = await upsertUserDevice(
userAgent, trx,
); userId,
deviceFingerprint,
geo,
userAgent,
);
// Check if device is already trusted // Check if device is already trusted
const deviceTrusted = await isDeviceTrusted( const trusted = await isDeviceTrusted(trx, userId, deviceFingerprint);
context.db,
userId,
deviceFingerprint,
);
// Create session with trusted mode = true (email-confirmed login) // Create session with trusted mode = true (email-confirmed login)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId, deviceId,
trustedMode: true, trustedMode: true,
geo, geo,
userAgent, userAgent,
}); });
// Delete the login request (it's been consumed) // Delete the login request (it's been consumed)
await context.db await trx
.deleteFrom("login_requests") .deleteFrom("login_requests")
.where("id", "=", loginRequest.id) .where("id", "=", loginRequest.id)
.execute(); .execute();
return { session: newSession, deviceTrusted: trusted };
});
// Set session cookie // Set session cookie
setCookie( 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) // Generate verification token
const session = await createSession(context.db, { const verificationToken = generateSecureBase58Token();
userId, const verificationExpiresAt = generateExpiry(
deviceId: null, TOKEN_DURATIONS.EMAIL_VERIFICATION,
trustedMode: false, );
geo,
userAgent, // 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 // Set session cookie
@@ -286,20 +307,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 (stubbed) // Send verification email (stubbed)
await sendVerificationEmail(email, verificationToken); await sendVerificationEmail(email, verificationToken);

View File

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

View File

@@ -33,3 +33,7 @@ export {
parseToken, parseToken,
TOKEN_PREFIX, TOKEN_PREFIX,
} from "./helpers/token.js"; } from "./helpers/token.js";
/**
* Export model operations
*/
export * from "./models/index.js";

View File

@@ -0,0 +1,7 @@
/**
* Database model operations
* Reusable database functions organized by table
*/
export * from "./sessions.js";
export * from "./user-devices.js";

View File

@@ -0,0 +1,53 @@
/**
* Database operations for sessions table
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
import type { DeviceGeoInfo } from "./user-devices.js";
/** Options for inserting a session */
export interface InsertSessionOptions {
userId: number;
deviceId: number | null;
tokenHash: string;
trustedMode: boolean;
geo: DeviceGeoInfo;
userAgent: string;
expiresAt: Date;
}
/** Result of session insertion */
export interface InsertSessionResult {
sessionId: number;
}
/**
* Insert a new session record
* Note: Token generation and hashing should be done by the caller
*/
export async function insertSession(
db: Kysely<Database> | Transaction<Database>,
options: InsertSessionOptions,
): Promise<InsertSessionResult> {
const result = await db
.insertInto("sessions")
.values({
user_id: options.userId,
device_id: options.deviceId,
token_hash: options.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: options.expiresAt,
})
.returning(["id"])
.executeTakeFirstOrThrow();
return {
sessionId: Number(result.id),
};
}

View File

@@ -0,0 +1,71 @@
/**
* Database operations for user_devices table
*/
import type { Database } from "@reviq/db-schema";
import type { Kysely, Transaction } from "kysely";
/** Geo information for device tracking */
export interface DeviceGeoInfo {
ip: string | null;
city: string | null;
region: string | null;
country: string | null;
}
/**
* Upsert a user device record
* Creates new device if not exists, updates last_used_at if exists
* @returns The device ID
*/
export async function upsertUserDevice(
db: Kysely<Database> | Transaction<Database>,
userId: number,
deviceFingerprint: string,
geo: DeviceGeoInfo,
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);
}
/**
* Check if a device is trusted for a user
*/
export async function isDeviceTrusted(
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;
}