refactor(auth): migrate session management to Convex
- commit
- cb3eadb4b4597068dca294cc745b5d16d36fed31
- author
- bnema · · 2026-01-18 18:34 UTC
- parent
- 47beb8f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +43 -51
M src/hooks.server.ts +5 -6
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | import type { Handle } from '@sveltejs/kit'; | |
| 2 | - | import db from '$lib/db/db.server'; | |
| 2 | + | import { users } from '$lib/db/convex.server'; | |
| 3 | 3 | import { handleRateLimit } from '$lib/api/rate-limiter'; | |
| 4 | - | import { users } from '$lib/db/schema'; | |
| 5 | 4 | import { createLogger } from '$lib/utils/logger.server'; | |
| 6 | 5 | import { ensureDefaultChatRoom } from '$lib/utils/chat.server'; | |
| 7 | 6 | import { validateSessionToken } from '$lib/api/session.server'; | |
| @@ -24,11 +23,11 @@ function addSecurityHeaders(response: Response): Response { | |||
| 24 | 23 | }); | |
| 25 | 24 | } | |
| 26 | 25 | ||
| 27 | - | // Set all users to offline on server start | |
| 26 | + | // Set all users to offline on server start (using Convex) | |
| 28 | 27 | async function setAllUsersOffline() { | |
| 29 | 28 | try { | |
| 30 | - | await db.update(users).set({ status: 'offline' }); | |
| 31 | - | log.info('All users set to offline on server start'); | |
| 29 | + | const result = await users.setAllOffline(); | |
| 30 | + | log.info('All users set to offline on server start', { updated: result.updated }); | |
| 32 | 31 | } catch (error: unknown) { | |
| 33 | 32 | log.error('Failed to set users offline on server start:', { error }); | |
| 34 | 33 | } | |
| @@ -38,7 +37,7 @@ async function setAllUsersOffline() { | |||
| 38 | 37 | Promise.resolve() | |
| 39 | 38 | .then(async () => { | |
| 40 | 39 | log.info('Initializing server...'); | |
| 41 | - | await ensureDefaultChatRoom(db); | |
| 40 | + | await ensureDefaultChatRoom(); | |
| 42 | 41 | await setAllUsersOffline(); | |
| 43 | 42 | log.info('Server initialized successfully'); | |
| 44 | 43 | }) | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | import type { Handle } from '@sveltejs/kit'; | 1 | import type { Handle } from '@sveltejs/kit'; | ||
| 2 | - | import db from '$lib/db/db.server'; | 2 | + | import { users } from '$lib/db/convex.server'; |
| 3 | import { handleRateLimit } from '$lib/api/rate-limiter'; | 3 | import { handleRateLimit } from '$lib/api/rate-limiter'; | ||
| 4 | - | import { users } from '$lib/db/schema'; | |||
| 5 | import { createLogger } from '$lib/utils/logger.server'; | 4 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 6 | import { ensureDefaultChatRoom } from '$lib/utils/chat.server'; | 5 | import { ensureDefaultChatRoom } from '$lib/utils/chat.server'; | ||
| 7 | import { validateSessionToken } from '$lib/api/session.server'; | 6 | import { validateSessionToken } from '$lib/api/session.server'; | ||
| @@ -24,11 +23,11 @@ function addSecurityHeaders(response: Response): Response { | |||||
| 24 | }); | 23 | }); | ||
| 25 | } | 24 | } | ||
| 26 | 25 | ||||
| 27 | - | // Set all users to offline on server start | 26 | + | // Set all users to offline on server start (using Convex) |
| 28 | async function setAllUsersOffline() { | 27 | async function setAllUsersOffline() { | ||
| 29 | try { | 28 | try { | ||
| 30 | - | await db.update(users).set({ status: 'offline' }); | 29 | + | const result = await users.setAllOffline(); |
| 31 | - | log.info('All users set to offline on server start'); | 30 | + | log.info('All users set to offline on server start', { updated: result.updated }); |
| 32 | } catch (error: unknown) { | 31 | } catch (error: unknown) { | ||
| 33 | log.error('Failed to set users offline on server start:', { error }); | 32 | log.error('Failed to set users offline on server start:', { error }); | ||
| 34 | } | 33 | } | ||
| @@ -38,7 +37,7 @@ async function setAllUsersOffline() { | |||||
| 38 | Promise.resolve() | 37 | Promise.resolve() | ||
| 39 | .then(async () => { | 38 | .then(async () => { | ||
| 40 | log.info('Initializing server...'); | 39 | log.info('Initializing server...'); | ||
| 41 | - | await ensureDefaultChatRoom(db); | 40 | + | await ensureDefaultChatRoom(); |
| 42 | await setAllUsersOffline(); | 41 | await setAllUsersOffline(); | ||
| 43 | log.info('Server initialized successfully'); | 42 | log.info('Server initialized successfully'); | ||
| 44 | }) | 43 | }) | ||
M src/lib/api/session.server.ts +38 -45
| @@ -1,6 +1,4 @@ | |||
| 1 | - | import db from '../db/db.server'; | |
| 2 | - | import { sessions, users } from '../db/schema'; | |
| 3 | - | import { eq } from 'drizzle-orm'; | |
| 1 | + | import { sessions } from '../db/convex.server'; | |
| 4 | 2 | import type { Session, User } from '../types/chat'; | |
| 5 | 3 | import { createLogger } from '../utils/logger.server'; | |
| 6 | 4 | ||
| @@ -17,7 +15,6 @@ export async function sha256(message: string): Promise<string> { | |||
| 17 | 15 | } | |
| 18 | 16 | ||
| 19 | 17 | // Helper: Custom Base32 encoder using the standard alphabet. | |
| 20 | - | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | |
| 21 | 18 | const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | |
| 22 | 19 | function base32Encode(data: Uint8Array): string { | |
| 23 | 20 | let bits = 0; | |
| @@ -52,65 +49,61 @@ export function generateSessionToken(): string { | |||
| 52 | 49 | // The session ID is the SHA-256 hash of the token. | |
| 53 | 50 | export async function createSession(token: string, userId: string): Promise<Session> { | |
| 54 | 51 | log.debug('Creating session', { userId }); | |
| 55 | - | const sessionId = await sha256(token); | |
| 52 | + | const tokenHash = await sha256(token); | |
| 56 | 53 | const createdAt = Date.now(); | |
| 57 | 54 | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry | |
| 58 | - | await db.insert(sessions).values({ | |
| 59 | - | id: sessionId, | |
| 60 | - | userId, | |
| 61 | - | createdAt, | |
| 62 | - | expiresAt | |
| 63 | - | }); | |
| 64 | - | log.debug('Session created', { sessionId }); | |
| 65 | - | return { id: sessionId, userId, createdAt, expiresAt }; | |
| 55 | + | ||
| 56 | + | await sessions.create(tokenHash, userId, expiresAt); | |
| 57 | + | ||
| 58 | + | log.debug('Session created', { tokenHash }); | |
| 59 | + | return { id: tokenHash, userId, createdAt, expiresAt }; | |
| 66 | 60 | } | |
| 67 | 61 | ||
| 68 | 62 | /** | |
| 69 | 63 | * Type distributed through our API. | |
| 70 | 64 | * Returns an object with both session and user if valid, or nulls if not. | |
| 71 | - | * Note: This returns the full User type since it's used server-side in hooks. | |
| 72 | 65 | */ | |
| 73 | 66 | export type SessionValidationResult = | |
| 74 | 67 | | { session: Session; user: User } | |
| 75 | 68 | | { session: null; user: null }; | |
| 76 | 69 | ||
| 77 | - | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | |
| 70 | + | // Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user. | |
| 78 | 71 | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | |
| 79 | 72 | log.debug('Validating session token'); | |
| 80 | - | const sessionId = await sha256(token); | |
| 81 | - | const session = await db.query.sessions.findFirst({ | |
| 82 | - | where: eq(sessions.id, sessionId) | |
| 83 | - | }); | |
| 84 | - | if (!session) { | |
| 85 | - | log.debug('Session not found', { sessionId }); | |
| 86 | - | return { session: null, user: null }; | |
| 87 | - | } | |
| 88 | - | const now = Date.now(); | |
| 89 | - | if (session.expiresAt < now) { | |
| 90 | - | log.debug('Session expired', { sessionId }); | |
| 91 | - | return { session: null, user: null }; | |
| 92 | - | } | |
| 93 | - | const user = await db.query.users.findFirst({ | |
| 94 | - | where: eq(users.id, session.userId) | |
| 95 | - | }); | |
| 96 | - | if (!user) { | |
| 97 | - | log.debug('User not found for session', { sessionId }); | |
| 73 | + | const tokenHash = await sha256(token); | |
| 74 | + | ||
| 75 | + | const result = await sessions.validate(tokenHash); | |
| 76 | + | ||
| 77 | + | if (!result.session || !result.user) { | |
| 78 | + | log.debug('Session not found or invalid', { tokenHash }); | |
| 98 | 79 | return { session: null, user: null }; | |
| 99 | 80 | } | |
| 100 | - | log.debug('Session validated', { sessionId, userId: user.id }); | |
| 81 | + | ||
| 82 | + | // Map Convex types to our local types - using native Convex IDs | |
| 83 | + | const session: Session = { | |
| 84 | + | id: result.session.tokenHash, | |
| 85 | + | userId: result.user._id, | |
| 86 | + | createdAt: result.session.createdAt, | |
| 87 | + | expiresAt: result.session.expiresAt | |
| 88 | + | }; | |
| 89 | + | ||
| 90 | + | const user: User = { | |
| 91 | + | id: result.user._id, | |
| 92 | + | password: result.user.password, | |
| 93 | + | nickname: result.user.nickname, | |
| 94 | + | status: result.user.status as User['status'], | |
| 95 | + | avatarUrl: result.user.avatarUrl, | |
| 96 | + | createdAt: result.user.createdAt, | |
| 97 | + | lastSeen: result.user.lastSeen | |
| 98 | + | }; | |
| 99 | + | ||
| 100 | + | log.debug('Session validated', { tokenHash, userId: user.id }); | |
| 101 | 101 | return { session, user }; | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | 104 | // Invalidate a specific session by deleting it from the database. | |
| 105 | - | export async function invalidateSession(sessionId: string): Promise<void> { | |
| 106 | - | log.debug('Invalidating session', { sessionId }); | |
| 107 | - | await db.delete(sessions).where(eq(sessions.id, sessionId)); | |
| 108 | - | log.debug('Session invalidated', { sessionId }); | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // Invalidate all sessions for a given user. | |
| 112 | - | export async function invalidateAllSessions(userId: string): Promise<void> { | |
| 113 | - | log.debug('Invalidating all sessions for user', { userId }); | |
| 114 | - | await db.delete(sessions).where(eq(sessions.userId, userId)); | |
| 115 | - | log.debug('All sessions invalidated', { userId }); | |
| 105 | + | export async function invalidateSession(tokenHash: string): Promise<void> { | |
| 106 | + | log.debug('Invalidating session', { tokenHash }); | |
| 107 | + | await sessions.remove(tokenHash); | |
| 108 | + | log.debug('Session invalidated', { tokenHash }); | |
| 116 | 109 | } | |
| @@ -1,6 +1,4 @@ | |||||
| 1 | - | import db from '../db/db.server'; | 1 | + | import { sessions } from '../db/convex.server'; |
| 2 | - | import { sessions, users } from '../db/schema'; | |||
| 3 | - | import { eq } from 'drizzle-orm'; | |||
| 4 | import type { Session, User } from '../types/chat'; | 2 | import type { Session, User } from '../types/chat'; | ||
| 5 | import { createLogger } from '../utils/logger.server'; | 3 | import { createLogger } from '../utils/logger.server'; | ||
| 6 | 4 | ||||
| @@ -17,7 +15,6 @@ export async function sha256(message: string): Promise<string> { | |||||
| 17 | } | 15 | } | ||
| 18 | 16 | ||||
| 19 | // Helper: Custom Base32 encoder using the standard alphabet. | 17 | // Helper: Custom Base32 encoder using the standard alphabet. | ||
| 20 | - | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | |||
| 21 | const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | 18 | const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | ||
| 22 | function base32Encode(data: Uint8Array): string { | 19 | function base32Encode(data: Uint8Array): string { | ||
| 23 | let bits = 0; | 20 | let bits = 0; | ||
| @@ -52,65 +49,61 @@ export function generateSessionToken(): string { | |||||
| 52 | // The session ID is the SHA-256 hash of the token. | 49 | // The session ID is the SHA-256 hash of the token. | ||
| 53 | export async function createSession(token: string, userId: string): Promise<Session> { | 50 | export async function createSession(token: string, userId: string): Promise<Session> { | ||
| 54 | log.debug('Creating session', { userId }); | 51 | log.debug('Creating session', { userId }); | ||
| 55 | - | const sessionId = await sha256(token); | 52 | + | const tokenHash = await sha256(token); |
| 56 | const createdAt = Date.now(); | 53 | const createdAt = Date.now(); | ||
| 57 | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry | 54 | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry | ||
| 58 | - | await db.insert(sessions).values({ | 55 | + | |
| 59 | - | id: sessionId, | 56 | + | await sessions.create(tokenHash, userId, expiresAt); |
| 60 | - | userId, | 57 | + | |
| 61 | - | createdAt, | 58 | + | log.debug('Session created', { tokenHash }); |
| 62 | - | expiresAt | 59 | + | return { id: tokenHash, userId, createdAt, expiresAt }; |
| 63 | - | }); | |||
| 64 | - | log.debug('Session created', { sessionId }); | |||
| 65 | - | return { id: sessionId, userId, createdAt, expiresAt }; | |||
| 66 | } | 60 | } | ||
| 67 | 61 | ||||
| 68 | /** | 62 | /** | ||
| 69 | * Type distributed through our API. | 63 | * Type distributed through our API. | ||
| 70 | * Returns an object with both session and user if valid, or nulls if not. | 64 | * Returns an object with both session and user if valid, or nulls if not. | ||
| 71 | - | * Note: This returns the full User type since it's used server-side in hooks. | |||
| 72 | */ | 65 | */ | ||
| 73 | export type SessionValidationResult = | 66 | export type SessionValidationResult = | ||
| 74 | | { session: Session; user: User } | 67 | | { session: Session; user: User } | ||
| 75 | | { session: null; user: null }; | 68 | | { session: null; user: null }; | ||
| 76 | 69 | ||||
| 77 | - | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | 70 | + | // Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user. |
| 78 | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | 71 | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | ||
| 79 | log.debug('Validating session token'); | 72 | log.debug('Validating session token'); | ||
| 80 | - | const sessionId = await sha256(token); | 73 | + | const tokenHash = await sha256(token); |
| 81 | - | const session = await db.query.sessions.findFirst({ | 74 | + | |
| 82 | - | where: eq(sessions.id, sessionId) | 75 | + | const result = await sessions.validate(tokenHash); |
| 83 | - | }); | 76 | + | |
| 84 | - | if (!session) { | 77 | + | if (!result.session || !result.user) { |
| 85 | - | log.debug('Session not found', { sessionId }); | 78 | + | log.debug('Session not found or invalid', { tokenHash }); |
| 86 | - | return { session: null, user: null }; | |||
| 87 | - | } | |||
| 88 | - | const now = Date.now(); | |||
| 89 | - | if (session.expiresAt < now) { | |||
| 90 | - | log.debug('Session expired', { sessionId }); | |||
| 91 | - | return { session: null, user: null }; | |||
| 92 | - | } | |||
| 93 | - | const user = await db.query.users.findFirst({ | |||
| 94 | - | where: eq(users.id, session.userId) | |||
| 95 | - | }); | |||
| 96 | - | if (!user) { | |||
| 97 | - | log.debug('User not found for session', { sessionId }); | |||
| 98 | return { session: null, user: null }; | 79 | return { session: null, user: null }; | ||
| 99 | } | 80 | } | ||
| 100 | - | log.debug('Session validated', { sessionId, userId: user.id }); | 81 | + | |
| 82 | + | // Map Convex types to our local types - using native Convex IDs | |||
| 83 | + | const session: Session = { | |||
| 84 | + | id: result.session.tokenHash, | |||
| 85 | + | userId: result.user._id, | |||
| 86 | + | createdAt: result.session.createdAt, | |||
| 87 | + | expiresAt: result.session.expiresAt | |||
| 88 | + | }; | |||
| 89 | + | ||||
| 90 | + | const user: User = { | |||
| 91 | + | id: result.user._id, | |||
| 92 | + | password: result.user.password, | |||
| 93 | + | nickname: result.user.nickname, | |||
| 94 | + | status: result.user.status as User['status'], | |||
| 95 | + | avatarUrl: result.user.avatarUrl, | |||
| 96 | + | createdAt: result.user.createdAt, | |||
| 97 | + | lastSeen: result.user.lastSeen | |||
| 98 | + | }; | |||
| 99 | + | ||||
| 100 | + | log.debug('Session validated', { tokenHash, userId: user.id }); | |||
| 101 | return { session, user }; | 101 | return { session, user }; | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | // Invalidate a specific session by deleting it from the database. | 104 | // Invalidate a specific session by deleting it from the database. | ||
| 105 | - | export async function invalidateSession(sessionId: string): Promise<void> { | 105 | + | export async function invalidateSession(tokenHash: string): Promise<void> { |
| 106 | - | log.debug('Invalidating session', { sessionId }); | 106 | + | log.debug('Invalidating session', { tokenHash }); |
| 107 | - | await db.delete(sessions).where(eq(sessions.id, sessionId)); | 107 | + | await sessions.remove(tokenHash); |
| 108 | - | log.debug('Session invalidated', { sessionId }); | 108 | + | log.debug('Session invalidated', { tokenHash }); |
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // Invalidate all sessions for a given user. | |||
| 112 | - | export async function invalidateAllSessions(userId: string): Promise<void> { | |||
| 113 | - | log.debug('Invalidating all sessions for user', { userId }); | |||
| 114 | - | await db.delete(sessions).where(eq(sessions.userId, userId)); | |||
| 115 | - | log.debug('All sessions invalidated', { userId }); | |||
| 116 | } | 109 | } | ||