Merge branch 'transactions-in-procedure'

This commit is contained in:
igm
2026-01-12 15:53:41 +08:00
9 changed files with 241 additions and 110 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
if (org) {
const membership = await db const membership = await db
.selectFrom("org_members") .selectFrom("org_members")
.where("org_id", "=", org?.id) .where("org_id", "=", org.id)
.where("user_id", "=", owner.id) .where("user_id", "=", owner.id)
.selectAll() .selectAll()
.executeTakeFirst(); .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,19 +30,21 @@ 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
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") .insertInto("password_resets")
.values({ .values({
user_id: user.id, user_id: user.id,
@@ -50,6 +52,7 @@ export const forgotPassword = os.auth.forgotPassword.handler(
expires_at: expiresAt, expires_at: expiresAt,
}) })
.execute(); .execute();
});
// Send password reset email // Send password reset email
await sendPasswordResetEmail({ await sendPasswordResetEmail({

View File

@@ -89,9 +89,13 @@ 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);
// Create session in transaction (atomic: device upsert + session + login_request delete)
const { session, deviceTrusted } = await context.db
.transaction()
.execute(async (trx) => {
// Upsert user device // Upsert user device
const deviceId = await upsertUserDevice( const deviceId = await upsertUserDevice(
context.db, trx,
userId, userId,
deviceFingerprint, deviceFingerprint,
geo, geo,
@@ -99,14 +103,10 @@ export const loginIfRequestIsCompleted =
); );
// 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,
@@ -115,11 +115,14 @@ export const loginIfRequestIsCompleted =
}); });
// 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(
context.resHeaders, context.resHeaders,

View File

@@ -269,8 +269,16 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
}); });
} }
// 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) // Create session (7 days, trusted mode false initially, no device)
const session = await createSession(context.db, { const newSession = await createSession(trx, {
userId, userId,
deviceId: null, deviceId: null,
trustedMode: false, trustedMode: false,
@@ -278,6 +286,19 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
userAgent, 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
setCookie( setCookie(
context.resHeaders, context.resHeaders,

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;
}