Match user and session schema changes

This commit is contained in:
2026-01-24 15:48:22 -06:00
parent 474420ac1e
commit 579a19669e
2 changed files with 180 additions and 52 deletions

View File

@@ -33,32 +33,52 @@ const connectionConfig = {
// Generated<T> marks columns with database defaults (optional on insert)
interface UsersTable {
id: string;
email: string;
password_hash: string;
display_name: string | null;
status: Generated<string>;
roles: Generated<string[]>;
permissions: Generated<string[]>;
email_verified: Generated<boolean>;
display_name: string | null;
created_at: Generated<Date>;
updated_at: Generated<Date>;
}
interface UserEmailsTable {
id: string;
user_id: string;
email: string;
normalized_email: string;
is_primary: Generated<boolean>;
is_verified: Generated<boolean>;
created_at: Generated<Date>;
verified_at: Date | null;
revoked_at: Date | null;
}
interface UserCredentialsTable {
id: string;
user_id: string;
credential_type: Generated<string>;
password_hash: string | null;
created_at: Generated<Date>;
updated_at: Generated<Date>;
}
interface SessionsTable {
token_id: string;
id: Generated<string>;
token_hash: string;
user_id: string;
user_email_id: string | null;
token_type: string;
auth_method: string;
created_at: Generated<Date>;
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<boolean | null>;
}
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<void> {
await db
.updateTable("sessions")
.set({ last_used_at: new Date() })
.where("token_id", "=", tokenId)
.execute();
async updateLastUsed(_tokenId: TokenId): Promise<void> {
// 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<void> {
// 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<number> {
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<User | null> {
// 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<User | null> {
// 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<User> {
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<string | null> {
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<void> {
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<void> {
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<UsersTable>): 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,
});

View File

@@ -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,