Match user and session schema changes
This commit is contained in:
@@ -33,32 +33,52 @@ const connectionConfig = {
|
|||||||
// Generated<T> marks columns with database defaults (optional on insert)
|
// Generated<T> marks columns with database defaults (optional on insert)
|
||||||
interface UsersTable {
|
interface UsersTable {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
|
||||||
password_hash: string;
|
|
||||||
display_name: string | null;
|
|
||||||
status: Generated<string>;
|
status: Generated<string>;
|
||||||
roles: Generated<string[]>;
|
display_name: string | null;
|
||||||
permissions: Generated<string[]>;
|
created_at: Generated<Date>;
|
||||||
email_verified: Generated<boolean>;
|
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>;
|
created_at: Generated<Date>;
|
||||||
updated_at: Generated<Date>;
|
updated_at: Generated<Date>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionsTable {
|
interface SessionsTable {
|
||||||
token_id: string;
|
id: Generated<string>;
|
||||||
|
token_hash: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
user_email_id: string | null;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
auth_method: string;
|
auth_method: string;
|
||||||
created_at: Generated<Date>;
|
created_at: Generated<Date>;
|
||||||
expires_at: Date;
|
expires_at: Date;
|
||||||
last_used_at: Date | null;
|
revoked_at: Date | null;
|
||||||
user_agent: string | null;
|
|
||||||
ip_address: string | null;
|
ip_address: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
is_used: Generated<boolean | null>;
|
is_used: Generated<boolean | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Database {
|
interface Database {
|
||||||
users: UsersTable;
|
users: UsersTable;
|
||||||
|
user_emails: UserEmailsTable;
|
||||||
|
user_credentials: UserCredentialsTable;
|
||||||
sessions: SessionsTable;
|
sessions: SessionsTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +225,12 @@ class PostgresAuthStore implements AuthStore {
|
|||||||
data: CreateSessionData,
|
data: CreateSessionData,
|
||||||
): Promise<{ token: string; session: SessionData }> {
|
): Promise<{ token: string; session: SessionData }> {
|
||||||
const token = generateToken();
|
const token = generateToken();
|
||||||
const tokenId = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
const row = await db
|
const row = await db
|
||||||
.insertInto("sessions")
|
.insertInto("sessions")
|
||||||
.values({
|
.values({
|
||||||
token_id: tokenId,
|
token_hash: tokenHash,
|
||||||
user_id: data.userId,
|
user_id: data.userId,
|
||||||
token_type: data.tokenType,
|
token_type: data.tokenType,
|
||||||
auth_method: data.authMethod,
|
auth_method: data.authMethod,
|
||||||
@@ -222,13 +242,12 @@ class PostgresAuthStore implements AuthStore {
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
const session: SessionData = {
|
const session: SessionData = {
|
||||||
tokenId: row.token_id,
|
tokenId: row.token_hash,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
tokenType: row.token_type as SessionData["tokenType"],
|
tokenType: row.token_type as SessionData["tokenType"],
|
||||||
authMethod: row.auth_method as SessionData["authMethod"],
|
authMethod: row.auth_method as SessionData["authMethod"],
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
expiresAt: row.expires_at,
|
expiresAt: row.expires_at,
|
||||||
lastUsedAt: row.last_used_at ?? undefined,
|
|
||||||
userAgent: row.user_agent ?? undefined,
|
userAgent: row.user_agent ?? undefined,
|
||||||
ipAddress: row.ip_address ?? undefined,
|
ipAddress: row.ip_address ?? undefined,
|
||||||
isUsed: row.is_used ?? undefined,
|
isUsed: row.is_used ?? undefined,
|
||||||
@@ -241,8 +260,9 @@ class PostgresAuthStore implements AuthStore {
|
|||||||
const row = await db
|
const row = await db
|
||||||
.selectFrom("sessions")
|
.selectFrom("sessions")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("token_id", "=", tokenId)
|
.where("token_hash", "=", tokenId)
|
||||||
.where("expires_at", ">", new Date())
|
.where("expires_at", ">", new Date())
|
||||||
|
.where("revoked_at", "is", null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -250,50 +270,62 @@ class PostgresAuthStore implements AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tokenId: row.token_id,
|
tokenId: row.token_hash,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
tokenType: row.token_type as SessionData["tokenType"],
|
tokenType: row.token_type as SessionData["tokenType"],
|
||||||
authMethod: row.auth_method as SessionData["authMethod"],
|
authMethod: row.auth_method as SessionData["authMethod"],
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
expiresAt: row.expires_at,
|
expiresAt: row.expires_at,
|
||||||
lastUsedAt: row.last_used_at ?? undefined,
|
|
||||||
userAgent: row.user_agent ?? undefined,
|
userAgent: row.user_agent ?? undefined,
|
||||||
ipAddress: row.ip_address ?? undefined,
|
ipAddress: row.ip_address ?? undefined,
|
||||||
isUsed: row.is_used ?? undefined,
|
isUsed: row.is_used ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLastUsed(tokenId: TokenId): Promise<void> {
|
async updateLastUsed(_tokenId: TokenId): Promise<void> {
|
||||||
await db
|
// The new schema doesn't have last_used_at column
|
||||||
.updateTable("sessions")
|
// This is now a no-op; session activity tracking could be added later
|
||||||
.set({ last_used_at: new Date() })
|
|
||||||
.where("token_id", "=", tokenId)
|
|
||||||
.execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSession(tokenId: TokenId): Promise<void> {
|
async deleteSession(tokenId: TokenId): Promise<void> {
|
||||||
|
// Soft delete by setting revoked_at
|
||||||
await db
|
await db
|
||||||
.deleteFrom("sessions")
|
.updateTable("sessions")
|
||||||
.where("token_id", "=", tokenId)
|
.set({ revoked_at: new Date() })
|
||||||
|
.where("token_hash", "=", tokenId)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUserSessions(userId: UserId): Promise<number> {
|
async deleteUserSessions(userId: UserId): Promise<number> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.deleteFrom("sessions")
|
.updateTable("sessions")
|
||||||
|
.set({ revoked_at: new Date() })
|
||||||
.where("user_id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
|
.where("revoked_at", "is", null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return Number(result.numDeletedRows);
|
return Number(result.numUpdatedRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
// User operations
|
// User operations
|
||||||
|
|
||||||
async getUserByEmail(email: string): Promise<User | null> {
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
// Find user through user_emails table
|
||||||
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
|
||||||
const row = await db
|
const row = await db
|
||||||
.selectFrom("users")
|
.selectFrom("user_emails")
|
||||||
.selectAll()
|
.innerJoin("users", "users.id", "user_emails.user_id")
|
||||||
.where(sql`LOWER(email)`, "=", email.toLowerCase())
|
.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();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -303,10 +335,24 @@ class PostgresAuthStore implements AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUserById(userId: UserId): Promise<User | null> {
|
async getUserById(userId: UserId): Promise<User | null> {
|
||||||
|
// Get user with their primary email
|
||||||
const row = await db
|
const row = await db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.selectAll()
|
.leftJoin("user_emails", (join) =>
|
||||||
.where("id", "=", userId)
|
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();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -316,68 +362,149 @@ class PostgresAuthStore implements AuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createUser(data: CreateUserData): Promise<User> {
|
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 now = new Date();
|
||||||
|
const normalizedEmail = data.email.toLowerCase().trim();
|
||||||
|
|
||||||
const row = await db
|
// Create user record
|
||||||
|
await db
|
||||||
.insertInto("users")
|
.insertInto("users")
|
||||||
.values({
|
.values({
|
||||||
id,
|
id: userId,
|
||||||
email: data.email,
|
|
||||||
password_hash: data.passwordHash,
|
|
||||||
display_name: data.displayName ?? null,
|
display_name: data.displayName ?? null,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
roles: [],
|
|
||||||
permissions: [],
|
|
||||||
email_verified: false,
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
.returningAll()
|
.execute();
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
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> {
|
async getUserPasswordHash(userId: UserId): Promise<string | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.selectFrom("users")
|
.selectFrom("user_credentials")
|
||||||
.select("password_hash")
|
.select("password_hash")
|
||||||
.where("id", "=", userId)
|
.where("user_id", "=", userId)
|
||||||
|
.where("credential_type", "=", "password")
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return row?.password_hash ?? null;
|
return row?.password_hash ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setUserPassword(userId: UserId, passwordHash: string): Promise<void> {
|
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
|
await db
|
||||||
.updateTable("users")
|
.updateTable("users")
|
||||||
.set({ password_hash: passwordHash, updated_at: new Date() })
|
.set({ updated_at: now })
|
||||||
.where("id", "=", userId)
|
.where("id", "=", userId)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserEmailVerified(userId: UserId): Promise<void> {
|
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
|
await db
|
||||||
.updateTable("users")
|
.updateTable("users")
|
||||||
.set({
|
.set({
|
||||||
email_verified: true,
|
|
||||||
status: "active",
|
status: "active",
|
||||||
updated_at: new Date(),
|
updated_at: now,
|
||||||
})
|
})
|
||||||
.where("id", "=", userId)
|
.where("id", "=", userId)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to convert database row to User object
|
// 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({
|
return new AuthenticatedUser({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
email: row.email,
|
email: row.email ?? "unknown@example.com",
|
||||||
displayName: row.display_name ?? undefined,
|
displayName: row.display_name ?? undefined,
|
||||||
status: row.status as "active" | "suspended" | "pending",
|
status: row.status as "active" | "suspended" | "pending",
|
||||||
roles: row.roles,
|
roles: [], // TODO: query from RBAC tables
|
||||||
permissions: row.permissions,
|
permissions: [], // TODO: query from RBAC tables
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
-- Create sessions table for auth tokens
|
-- Create sessions table for auth tokens
|
||||||
|
|
||||||
CREATE TABLE sessions (
|
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_id UUID NOT NULL REFERENCES users(id),
|
||||||
user_email_id UUID REFERENCES user_emails(id),
|
user_email_id UUID REFERENCES user_emails(id),
|
||||||
token_type TEXT NOT NULL,
|
token_type TEXT NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user