Merge branch 'transactions-in-procedure'
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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