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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -33,3 +33,7 @@ export {
|
||||
parseToken,
|
||||
TOKEN_PREFIX,
|
||||
} from "./helpers/token.js";
|
||||
/**
|
||||
* Export model operations
|
||||
*/
|
||||
export * from "./models/index.js";
|
||||
|
||||
7
packages/db/src/models/index.ts
Normal file
7
packages/db/src/models/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Database model operations
|
||||
* Reusable database functions organized by table
|
||||
*/
|
||||
|
||||
export * from "./sessions.js";
|
||||
export * from "./user-devices.js";
|
||||
53
packages/db/src/models/sessions.ts
Normal file
53
packages/db/src/models/sessions.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
71
packages/db/src/models/user-devices.ts
Normal file
71
packages/db/src/models/user-devices.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user