From 579a19669e42c9711327e22fdc6914e4f91c8017 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 24 Jan 2026 15:48:22 -0600 Subject: [PATCH] Match user and session schema changes --- express/database.ts | 229 ++++++++++++++---- express/migrations/2026-01-01_02-sessions.sql | 3 +- 2 files changed, 180 insertions(+), 52 deletions(-) diff --git a/express/database.ts b/express/database.ts index 999cdc4..163f579 100644 --- a/express/database.ts +++ b/express/database.ts @@ -33,32 +33,52 @@ const connectionConfig = { // Generated marks columns with database defaults (optional on insert) interface UsersTable { id: string; - email: string; - password_hash: string; - display_name: string | null; status: Generated; - roles: Generated; - permissions: Generated; - email_verified: Generated; + display_name: string | null; + created_at: Generated; + updated_at: Generated; +} + +interface UserEmailsTable { + id: string; + user_id: string; + email: string; + normalized_email: string; + is_primary: Generated; + is_verified: Generated; + created_at: Generated; + verified_at: Date | null; + revoked_at: Date | null; +} + +interface UserCredentialsTable { + id: string; + user_id: string; + credential_type: Generated; + password_hash: string | null; created_at: Generated; updated_at: Generated; } interface SessionsTable { - token_id: string; + id: Generated; + token_hash: string; user_id: string; + user_email_id: string | null; token_type: string; auth_method: string; created_at: Generated; expires_at: Date; - last_used_at: Date | null; - user_agent: string | null; + revoked_at: Date | null; ip_address: string | null; + user_agent: string | null; is_used: Generated; } interface Database { users: UsersTable; + user_emails: UserEmailsTable; + user_credentials: UserCredentialsTable; sessions: SessionsTable; } @@ -205,12 +225,12 @@ class PostgresAuthStore implements AuthStore { data: CreateSessionData, ): Promise<{ token: string; session: SessionData }> { const token = generateToken(); - const tokenId = hashToken(token); + const tokenHash = hashToken(token); const row = await db .insertInto("sessions") .values({ - token_id: tokenId, + token_hash: tokenHash, user_id: data.userId, token_type: data.tokenType, auth_method: data.authMethod, @@ -222,13 +242,12 @@ class PostgresAuthStore implements AuthStore { .executeTakeFirstOrThrow(); const session: SessionData = { - tokenId: row.token_id, + tokenId: row.token_hash, userId: row.user_id, tokenType: row.token_type as SessionData["tokenType"], authMethod: row.auth_method as SessionData["authMethod"], createdAt: row.created_at, expiresAt: row.expires_at, - lastUsedAt: row.last_used_at ?? undefined, userAgent: row.user_agent ?? undefined, ipAddress: row.ip_address ?? undefined, isUsed: row.is_used ?? undefined, @@ -241,8 +260,9 @@ class PostgresAuthStore implements AuthStore { const row = await db .selectFrom("sessions") .selectAll() - .where("token_id", "=", tokenId) + .where("token_hash", "=", tokenId) .where("expires_at", ">", new Date()) + .where("revoked_at", "is", null) .executeTakeFirst(); if (!row) { @@ -250,50 +270,62 @@ class PostgresAuthStore implements AuthStore { } return { - tokenId: row.token_id, + tokenId: row.token_hash, userId: row.user_id, tokenType: row.token_type as SessionData["tokenType"], authMethod: row.auth_method as SessionData["authMethod"], createdAt: row.created_at, expiresAt: row.expires_at, - lastUsedAt: row.last_used_at ?? undefined, userAgent: row.user_agent ?? undefined, ipAddress: row.ip_address ?? undefined, isUsed: row.is_used ?? undefined, }; } - async updateLastUsed(tokenId: TokenId): Promise { - await db - .updateTable("sessions") - .set({ last_used_at: new Date() }) - .where("token_id", "=", tokenId) - .execute(); + async updateLastUsed(_tokenId: TokenId): Promise { + // The new schema doesn't have last_used_at column + // This is now a no-op; session activity tracking could be added later } async deleteSession(tokenId: TokenId): Promise { + // Soft delete by setting revoked_at await db - .deleteFrom("sessions") - .where("token_id", "=", tokenId) + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("token_hash", "=", tokenId) .execute(); } async deleteUserSessions(userId: UserId): Promise { const result = await db - .deleteFrom("sessions") + .updateTable("sessions") + .set({ revoked_at: new Date() }) .where("user_id", "=", userId) + .where("revoked_at", "is", null) .executeTakeFirst(); - return Number(result.numDeletedRows); + return Number(result.numUpdatedRows); } // User operations async getUserByEmail(email: string): Promise { + // Find user through user_emails table + const normalizedEmail = email.toLowerCase().trim(); + const row = await db - .selectFrom("users") - .selectAll() - .where(sql`LOWER(email)`, "=", email.toLowerCase()) + .selectFrom("user_emails") + .innerJoin("users", "users.id", "user_emails.user_id") + .select([ + "users.id", + "users.status", + "users.display_name", + "users.created_at", + "users.updated_at", + "user_emails.email", + ]) + .where("user_emails.normalized_email", "=", normalizedEmail) + .where("user_emails.revoked_at", "is", null) .executeTakeFirst(); if (!row) { @@ -303,10 +335,24 @@ class PostgresAuthStore implements AuthStore { } async getUserById(userId: UserId): Promise { + // Get user with their primary email const row = await db .selectFrom("users") - .selectAll() - .where("id", "=", userId) + .leftJoin("user_emails", (join) => + join + .onRef("user_emails.user_id", "=", "users.id") + .on("user_emails.is_primary", "=", true) + .on("user_emails.revoked_at", "is", null), + ) + .select([ + "users.id", + "users.status", + "users.display_name", + "users.created_at", + "users.updated_at", + "user_emails.email", + ]) + .where("users.id", "=", userId) .executeTakeFirst(); if (!row) { @@ -316,68 +362,149 @@ class PostgresAuthStore implements AuthStore { } async createUser(data: CreateUserData): Promise { - const id = crypto.randomUUID(); + const userId = crypto.randomUUID(); + const emailId = crypto.randomUUID(); + const credentialId = crypto.randomUUID(); const now = new Date(); + const normalizedEmail = data.email.toLowerCase().trim(); - const row = await db + // Create user record + await db .insertInto("users") .values({ - id, - email: data.email, - password_hash: data.passwordHash, + id: userId, display_name: data.displayName ?? null, status: "pending", - roles: [], - permissions: [], - email_verified: false, created_at: now, updated_at: now, }) - .returningAll() - .executeTakeFirstOrThrow(); + .execute(); - return this.rowToUser(row); + // Create user_email record + await db + .insertInto("user_emails") + .values({ + id: emailId, + user_id: userId, + email: data.email, + normalized_email: normalizedEmail, + is_primary: true, + is_verified: false, + created_at: now, + }) + .execute(); + + // Create user_credential record + await db + .insertInto("user_credentials") + .values({ + id: credentialId, + user_id: userId, + credential_type: "password", + password_hash: data.passwordHash, + created_at: now, + updated_at: now, + }) + .execute(); + + return new AuthenticatedUser({ + id: userId, + email: data.email, + displayName: data.displayName, + status: "pending", + roles: [], + permissions: [], + createdAt: now, + updatedAt: now, + }); } async getUserPasswordHash(userId: UserId): Promise { const row = await db - .selectFrom("users") + .selectFrom("user_credentials") .select("password_hash") - .where("id", "=", userId) + .where("user_id", "=", userId) + .where("credential_type", "=", "password") .executeTakeFirst(); return row?.password_hash ?? null; } async setUserPassword(userId: UserId, passwordHash: string): Promise { + const now = new Date(); + + // Try to update existing credential + const result = await db + .updateTable("user_credentials") + .set({ password_hash: passwordHash, updated_at: now }) + .where("user_id", "=", userId) + .where("credential_type", "=", "password") + .executeTakeFirst(); + + // If no existing credential, create one + if (Number(result.numUpdatedRows) === 0) { + await db + .insertInto("user_credentials") + .values({ + id: crypto.randomUUID(), + user_id: userId, + credential_type: "password", + password_hash: passwordHash, + created_at: now, + updated_at: now, + }) + .execute(); + } + + // Update user's updated_at await db .updateTable("users") - .set({ password_hash: passwordHash, updated_at: new Date() }) + .set({ updated_at: now }) .where("id", "=", userId) .execute(); } async updateUserEmailVerified(userId: UserId): Promise { + const now = new Date(); + + // Update user_emails to mark as verified + await db + .updateTable("user_emails") + .set({ + is_verified: true, + verified_at: now, + }) + .where("user_id", "=", userId) + .where("is_primary", "=", true) + .execute(); + + // Update user status to active await db .updateTable("users") .set({ - email_verified: true, status: "active", - updated_at: new Date(), + updated_at: now, }) .where("id", "=", userId) .execute(); } // Helper to convert database row to User object - private rowToUser(row: Selectable): User { + private rowToUser(row: { + id: string; + status: string; + display_name: string | null; + created_at: Date; + updated_at: Date; + email: string | null; + }): User { return new AuthenticatedUser({ id: row.id, - email: row.email, + email: row.email ?? "unknown@example.com", displayName: row.display_name ?? undefined, status: row.status as "active" | "suspended" | "pending", - roles: row.roles, - permissions: row.permissions, + roles: [], // TODO: query from RBAC tables + permissions: [], // TODO: query from RBAC tables createdAt: row.created_at, updatedAt: row.updated_at, }); diff --git a/express/migrations/2026-01-01_02-sessions.sql b/express/migrations/2026-01-01_02-sessions.sql index 2ad9b33..e2911cc 100644 --- a/express/migrations/2026-01-01_02-sessions.sql +++ b/express/migrations/2026-01-01_02-sessions.sql @@ -2,7 +2,8 @@ -- Create sessions table for auth tokens CREATE TABLE sessions ( - id UUID PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT UNIQUE NOT NULL, user_id UUID NOT NULL REFERENCES users(id), user_email_id UUID REFERENCES user_emails(id), token_type TEXT NOT NULL,