diff --git a/backend/auth/auth-service.ts b/backend/auth/auth-service.ts index 5872c673..da4746ad 100644 --- a/backend/auth/auth-service.ts +++ b/backend/auth/auth-service.ts @@ -4,104 +4,132 @@ * Core authentication logic: user creation, session management, invite handling. */ -import { authQueries, projectQueries, settingsQueries } from '$backend/database/queries'; -import type { Project } from '$shared/types/database/schema'; -import { generateSessionToken, generatePAT, generateInviteToken, hashToken, getTokenType } from './tokens'; -import { generateColorFromString, getInitials } from '$backend/utils/user-helpers'; -import { debug } from '$shared/utils/logger'; +import { getDatabase } from "$backend/database"; +import { + authQueries, + projectQueries, + settingsQueries, +} from "$backend/database/queries"; +import type { Project } from "$shared/types/database/schema"; +import { + generateSessionToken, + generatePAT, + generateInviteToken, + hashToken, + getTokenType, +} from "./tokens"; +import { + generateColorFromString, + getInitials, +} from "$backend/utils/user-helpers"; +import { debug } from "$shared/utils/logger"; /** Default session lifetime in days */ const DEFAULT_SESSION_DAYS = 30; export interface AuthUser { - id: string; - name: string; - color: string; - avatar: string; - role: 'admin' | 'member'; - createdAt: string; + id: string; + name: string; + color: string; + avatar: string; + role: "admin" | "member"; + createdAt: string; } export interface AuthResult { - user: AuthUser; - sessionToken: string; - expiresAt: string; + user: AuthUser; + sessionToken: string; + expiresAt: string; } export interface SetupResult extends AuthResult { - personalAccessToken: string; + personalAccessToken: string; } -function toAuthUser(dbUser: { id: string; name: string; color: string; avatar: string; role: 'admin' | 'member'; created_at: string }): AuthUser { - return { - id: dbUser.id, - name: dbUser.name, - color: dbUser.color, - avatar: dbUser.avatar, - role: dbUser.role, - createdAt: dbUser.created_at - }; +function toAuthUser(dbUser: { + id: string; + name: string; + color: string; + avatar: string; + role: "admin" | "member"; + created_at: string; +}): AuthUser { + return { + id: dbUser.id, + name: dbUser.name, + color: dbUser.color, + avatar: dbUser.avatar, + role: dbUser.role, + createdAt: dbUser.created_at, + }; } -function createSessionForUser(userId: string, sessionDays?: number): { sessionToken: string; expiresAt: string; tokenHash: string } { - const days = sessionDays ?? DEFAULT_SESSION_DAYS; - const sessionToken = generateSessionToken(); - const tokenHash = hashToken(sessionToken); - const now = new Date().toISOString(); - const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); - - authQueries.createSession({ - id: `session-${crypto.randomUUID()}`, - user_id: userId, - token_hash: tokenHash, - expires_at: expiresAt, - created_at: now, - last_active_at: now - }); - - return { sessionToken, expiresAt, tokenHash }; +function createSessionForUser( + userId: string, + sessionDays?: number, +): { sessionToken: string; expiresAt: string; tokenHash: string } { + const days = sessionDays ?? DEFAULT_SESSION_DAYS; + const sessionToken = generateSessionToken(); + const tokenHash = hashToken(sessionToken); + const now = new Date().toISOString(); + const expiresAt = new Date( + Date.now() + days * 24 * 60 * 60 * 1000, + ).toISOString(); + + authQueries.createSession({ + id: `session-${crypto.randomUUID()}`, + user_id: userId, + token_hash: tokenHash, + expires_at: expiresAt, + created_at: now, + last_active_at: now, + }); + + return { sessionToken, expiresAt, tokenHash }; } /** * Check if the system needs initial setup (no users exist) */ export function needsSetup(): boolean { - return authQueries.countUsers() === 0; + return authQueries.countUsers() === 0; } /** * Get parsed system settings from DB (cached per call). */ function getSystemSettingsParsed(): Record { - try { - const setting = settingsQueries.get('system:settings'); - if (setting?.value) { - return typeof setting.value === 'string' ? JSON.parse(setting.value) : setting.value; - } - } catch { - // Settings may not exist yet - } - return {}; + try { + const setting = settingsQueries.get("system:settings"); + if (setting?.value) { + return typeof setting.value === "string" + ? JSON.parse(setting.value) + : setting.value; + } + } catch { + // Settings may not exist yet + } + return {}; } /** * Get the current auth mode from system settings. * Returns 'required' by default (before setup is complete). */ -export function getAuthMode(): 'none' | 'required' { - const parsed = getSystemSettingsParsed(); - if (parsed.authMode === 'none' || parsed.authMode === 'required') { - return parsed.authMode as 'none' | 'required'; - } - return 'required'; +export function getAuthMode(): "none" | "required" { + const parsed = getSystemSettingsParsed(); + if (parsed.authMode === "none" || parsed.authMode === "required") { + return parsed.authMode as "none" | "required"; + } + return "required"; } /** * Check if the initial onboarding wizard has been completed. */ export function isOnboardingComplete(): boolean { - const parsed = getSystemSettingsParsed(); - return parsed.onboardingComplete === true; + const parsed = getSystemSettingsParsed(); + return parsed.onboardingComplete === true; } /** @@ -110,43 +138,49 @@ export function isOnboardingComplete(): boolean { * If users already exist, returns the first admin. */ export function createOrGetNoAuthAdmin(): AuthResult { - // If users already exist, return the first admin - const existingUsers = authQueries.getAllUsers(); - const existingAdmin = existingUsers.find(u => u.role === 'admin'); - if (existingAdmin) { - const { sessionToken, expiresAt } = createSessionForUser(existingAdmin.id); - debug.log('auth', `No-auth mode: reusing existing admin: ${existingAdmin.name} (${existingAdmin.id})`); - return { - user: toAuthUser(existingAdmin), - sessionToken, - expiresAt - }; - } - - // Create default admin - const userId = `user-${crypto.randomUUID()}`; - const now = new Date().toISOString(); - const defaultName = 'Admin'; - - const dbUser = authQueries.createUser({ - id: userId, - name: defaultName, - color: generateColorFromString(defaultName), - avatar: getInitials(defaultName), - role: 'admin', - personal_access_token_hash: '', // No PAT for no-auth mode - created_at: now - }); - - const { sessionToken, expiresAt } = createSessionForUser(userId); - - debug.log('auth', `No-auth mode: created default admin: ${defaultName} (${userId})`); - - return { - user: toAuthUser(dbUser), - sessionToken, - expiresAt - }; + // If users already exist, return the first admin + const existingUsers = authQueries.getAllUsers(); + const existingAdmin = existingUsers.find((u) => u.role === "admin"); + if (existingAdmin) { + const { sessionToken, expiresAt } = createSessionForUser(existingAdmin.id); + debug.log( + "auth", + `No-auth mode: reusing existing admin: ${existingAdmin.name} (${existingAdmin.id})`, + ); + return { + user: toAuthUser(existingAdmin), + sessionToken, + expiresAt, + }; + } + + // Create default admin + const userId = `user-${crypto.randomUUID()}`; + const now = new Date().toISOString(); + const defaultName = "Admin"; + + const dbUser = authQueries.createUser({ + id: userId, + name: defaultName, + color: generateColorFromString(defaultName), + avatar: getInitials(defaultName), + role: "admin", + personal_access_token_hash: "", // No PAT for no-auth mode + created_at: now, + }); + + const { sessionToken, expiresAt } = createSessionForUser(userId); + + debug.log( + "auth", + `No-auth mode: created default admin: ${defaultName} (${userId})`, + ); + + return { + user: toAuthUser(dbUser), + sessionToken, + expiresAt, + }; } /** @@ -154,334 +188,372 @@ export function createOrGetNoAuthAdmin(): AuthResult { * Only works when no users exist. */ export function createAdmin(name: string): SetupResult { - if (!needsSetup()) { - throw new Error('Setup already completed. Admin account exists.'); - } - - const trimmedName = name.trim(); - if (trimmedName.length === 0) { - throw new Error('Name cannot be empty'); - } - - const userId = `user-${crypto.randomUUID()}`; - const now = new Date().toISOString(); - const pat = generatePAT(); - const patHash = hashToken(pat); - - const dbUser = authQueries.createUser({ - id: userId, - name: trimmedName, - color: generateColorFromString(trimmedName), - avatar: getInitials(trimmedName), - role: 'admin', - personal_access_token_hash: patHash, - created_at: now - }); - - const { sessionToken, expiresAt } = createSessionForUser(userId); - - debug.log('auth', `Admin account created: ${trimmedName} (${userId})`); - - return { - user: toAuthUser(dbUser), - sessionToken, - expiresAt, - personalAccessToken: pat - }; + if (!needsSetup()) { + throw new Error("Setup already completed. Admin account exists."); + } + + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + throw new Error("Name cannot be empty"); + } + + const userId = `user-${crypto.randomUUID()}`; + const now = new Date().toISOString(); + const pat = generatePAT(); + const patHash = hashToken(pat); + + const dbUser = authQueries.createUser({ + id: userId, + name: trimmedName, + color: generateColorFromString(trimmedName), + avatar: getInitials(trimmedName), + role: "admin", + personal_access_token_hash: patHash, + created_at: now, + }); + + const { sessionToken, expiresAt } = createSessionForUser(userId); + + debug.log("auth", `Admin account created: ${trimmedName} (${userId})`); + + return { + user: toAuthUser(dbUser), + sessionToken, + expiresAt, + personalAccessToken: pat, + }; } /** * Create a user from an invite token */ -export function createUserFromInvite(rawInviteToken: string, name: string): SetupResult { - const trimmedName = name.trim(); - if (trimmedName.length === 0) { - throw new Error('Name cannot be empty'); - } - - const inviteHash = hashToken(rawInviteToken); - const invite = authQueries.getInviteByTokenHash(inviteHash); - - if (!invite) { - throw new Error('Invalid invite token'); - } - - // Check expiry - if (invite.expires_at && new Date(invite.expires_at) < new Date()) { - throw new Error('Invite token has expired'); - } - - // Check max uses - if (invite.max_uses > 0 && invite.use_count >= invite.max_uses) { - throw new Error('Invite token has reached maximum uses'); - } - - // Only allow creating member role from invite (single admin policy) - const role: 'admin' | 'member' = 'member'; - - const userId = `user-${crypto.randomUUID()}`; - const now = new Date().toISOString(); - const pat = generatePAT(); - const patHash = hashToken(pat); - - const dbUser = authQueries.createUser({ - id: userId, - name: trimmedName, - color: generateColorFromString(trimmedName), - avatar: getInitials(trimmedName), - role, - personal_access_token_hash: patHash, - created_at: now - }); - - // Increment invite use count - authQueries.incrementUseCount(invite.id); - - const { sessionToken, expiresAt } = createSessionForUser(userId); - - debug.log('auth', `User created from invite: ${trimmedName} (${userId}), role: ${role}`); - - return { - user: toAuthUser(dbUser), - sessionToken, - expiresAt, - personalAccessToken: pat - }; +export function createUserFromInvite( + rawInviteToken: string, + name: string, +): SetupResult { + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + throw new Error("Name cannot be empty"); + } + + const inviteHash = hashToken(rawInviteToken); + const db = getDatabase(); + let inTransaction = false; + + try { + db.exec("BEGIN IMMEDIATE"); + inTransaction = true; + + const invite = authQueries.getInviteByTokenHash(inviteHash); + if (!invite) { + throw new Error("Invalid invite token"); + } + + // Check expiry + if (invite.expires_at && new Date(invite.expires_at) < new Date()) { + throw new Error("Invite token has expired"); + } + + // Check max uses + if (invite.max_uses > 0 && invite.use_count >= invite.max_uses) { + throw new Error("Invite token has reached maximum uses"); + } + + // Consume invite atomically under transaction lock. + if (!authQueries.incrementUseCount(invite.id)) { + const refreshed = authQueries.getInviteByTokenHash(inviteHash); + if (!refreshed) { + throw new Error("Invalid invite token"); + } + if (refreshed.expires_at && new Date(refreshed.expires_at) < new Date()) { + throw new Error("Invite token has expired"); + } + throw new Error("Invite token has reached maximum uses"); + } + + // Only allow creating member role from invite (single admin policy) + const role: "admin" | "member" = "member"; + const userId = `user-${crypto.randomUUID()}`; + const now = new Date().toISOString(); + const pat = generatePAT(); + const patHash = hashToken(pat); + + const dbUser = authQueries.createUser({ + id: userId, + name: trimmedName, + color: generateColorFromString(trimmedName), + avatar: getInitials(trimmedName), + role, + personal_access_token_hash: patHash, + created_at: now, + }); + + const { sessionToken, expiresAt } = createSessionForUser(userId); + + db.exec("COMMIT"); + inTransaction = false; + + debug.log( + "auth", + `User created from invite: ${trimmedName} (${userId}), role: ${role}`, + ); + + return { + user: toAuthUser(dbUser), + sessionToken, + expiresAt, + personalAccessToken: pat, + }; + } catch (error) { + if (inTransaction) { + db.exec("ROLLBACK"); + } + throw error; + } } /** * Login with a token (PAT or session token) */ -export function loginWithToken(token: string): AuthResult & { tokenHash: string } { - const tokenType = getTokenType(token); - const tokenHash = hashToken(token); - - if (tokenType === 'pat') { - // PAT login — find user by PAT hash, create new session - const user = authQueries.getUserByPatHash(tokenHash); - if (!user) { - throw new Error('Invalid access token'); - } - - const session = createSessionForUser(user.id); - debug.log('auth', `PAT login: ${user.name} (${user.id})`); - - return { - user: toAuthUser(user), - sessionToken: session.sessionToken, - expiresAt: session.expiresAt, - tokenHash: session.tokenHash - }; - } - - if (tokenType === 'session') { - // Session token login — validate existing session - const session = authQueries.getSessionByTokenHash(tokenHash); - if (!session) { - throw new Error('Invalid session token'); - } - - // Check expiry - if (new Date(session.expires_at) < new Date()) { - authQueries.deleteSession(session.id); - throw new Error('Session expired'); - } - - // Update last active - authQueries.updateLastActive(session.id); - - const user = authQueries.getUserById(session.user_id); - if (!user) { - authQueries.deleteSession(session.id); - throw new Error('User not found'); - } - - debug.log('auth', `Session login: ${user.name} (${user.id})`); - - return { - user: toAuthUser(user), - sessionToken: token, - expiresAt: session.expires_at, - tokenHash - }; - } - - throw new Error('Invalid token format'); +export function loginWithToken( + token: string, +): AuthResult & { tokenHash: string } { + const tokenType = getTokenType(token); + const tokenHash = hashToken(token); + + if (tokenType === "pat") { + // PAT login — find user by PAT hash, create new session + const user = authQueries.getUserByPatHash(tokenHash); + if (!user) { + throw new Error("Invalid access token"); + } + + const session = createSessionForUser(user.id); + debug.log("auth", `PAT login: ${user.name} (${user.id})`); + + return { + user: toAuthUser(user), + sessionToken: session.sessionToken, + expiresAt: session.expiresAt, + tokenHash: session.tokenHash, + }; + } + + if (tokenType === "session") { + // Session token login — validate existing session + const session = authQueries.getSessionByTokenHash(tokenHash); + if (!session) { + throw new Error("Invalid session token"); + } + + // Check expiry + if (new Date(session.expires_at) < new Date()) { + authQueries.deleteSession(session.id); + throw new Error("Session expired"); + } + + // Update last active + authQueries.updateLastActive(session.id); + + const user = authQueries.getUserById(session.user_id); + if (!user) { + authQueries.deleteSession(session.id); + throw new Error("User not found"); + } + + debug.log("auth", `Session login: ${user.name} (${user.id})`); + + return { + user: toAuthUser(user), + sessionToken: token, + expiresAt: session.expires_at, + tokenHash, + }; + } + + throw new Error("Invalid token format"); } /** * Logout — delete session by token hash */ export function logout(tokenHash: string): void { - authQueries.deleteSessionByTokenHash(tokenHash); - debug.log('auth', 'Session deleted'); + authQueries.deleteSessionByTokenHash(tokenHash); + debug.log("auth", "Session deleted"); } /** * Get user by ID */ export function getUserById(id: string): AuthUser | null { - const user = authQueries.getUserById(id); - return user ? toAuthUser(user) : null; + const user = authQueries.getUserById(id); + return user ? toAuthUser(user) : null; } /** * List all users */ export function listUsers(): AuthUser[] { - return authQueries.getAllUsers().map(toAuthUser); + return authQueries.getAllUsers().map(toAuthUser); } /** * Remove a user (prevents removing the last admin) */ export function removeUser(userId: string): void { - const user = authQueries.getUserById(userId); - if (!user) { - throw new Error('User not found'); - } + const user = authQueries.getUserById(userId); + if (!user) { + throw new Error("User not found"); + } - if (user.role === 'admin' && authQueries.countAdmins() <= 1) { - throw new Error('Cannot remove the last admin'); - } + if (user.role === "admin" && authQueries.countAdmins() <= 1) { + throw new Error("Cannot remove the last admin"); + } - // Delete all sessions for this user - authQueries.deleteSessionsByUserId(userId); - // Delete the user (cascade will handle invite_tokens.created_by) - authQueries.deleteUser(userId); + // Delete all sessions for this user + authQueries.deleteSessionsByUserId(userId); + // Delete the user (cascade will handle invite_tokens.created_by) + authQueries.deleteUser(userId); - debug.log('auth', `User removed: ${user.name} (${userId})`); + debug.log("auth", `User removed: ${user.name} (${userId})`); } /** * Create an invite token */ export function createInvite( - createdBy: string, - options: { label?: string; maxUses?: number; expiresInMinutes?: number } -): { inviteToken: string; invite: ReturnType } { - const rawToken = generateInviteToken(); - const tokenHash = hashToken(rawToken); - const now = new Date().toISOString(); - - const expiresAt = options.expiresInMinutes - ? new Date(Date.now() + options.expiresInMinutes * 60 * 1000).toISOString() - : null; - - const invite = authQueries.createInvite({ - id: `invite-${crypto.randomUUID()}`, - token_hash: tokenHash, - role: 'member', - label: options.label ?? null, - created_by: createdBy, - max_uses: options.maxUses ?? 1, - use_count: 0, - expires_at: expiresAt, - created_at: now - }); - - debug.log('auth', `Invite created by ${createdBy}: ${invite.id}`); - - return { inviteToken: rawToken, invite }; + createdBy: string, + options: { label?: string; maxUses?: number; expiresInMinutes?: number }, +): { + inviteToken: string; + invite: ReturnType; +} { + const rawToken = generateInviteToken(); + const tokenHash = hashToken(rawToken); + const now = new Date().toISOString(); + + const expiresAt = options.expiresInMinutes + ? new Date(Date.now() + options.expiresInMinutes * 60 * 1000).toISOString() + : null; + + const invite = authQueries.createInvite({ + id: `invite-${crypto.randomUUID()}`, + token_hash: tokenHash, + role: "member", + label: options.label ?? null, + created_by: createdBy, + max_uses: options.maxUses ?? 1, + use_count: 0, + expires_at: expiresAt, + created_at: now, + }); + + debug.log("auth", `Invite created by ${createdBy}: ${invite.id}`); + + return { inviteToken: rawToken, invite }; } /** * Validate an invite token (without using it) */ -export function validateInviteToken(rawToken: string): { valid: boolean; role?: string; error?: string } { - const tokenHash = hashToken(rawToken); - const invite = authQueries.getInviteByTokenHash(tokenHash); - - if (!invite) { - return { valid: false, error: 'Invalid invite token' }; - } - - if (invite.expires_at && new Date(invite.expires_at) < new Date()) { - return { valid: false, error: 'Invite has expired' }; - } - - if (invite.max_uses > 0 && invite.use_count >= invite.max_uses) { - return { valid: false, error: 'Invite has reached maximum uses' }; - } - - return { valid: true, role: invite.role }; +export function validateInviteToken(rawToken: string): { + valid: boolean; + role?: string; + error?: string; +} { + const tokenHash = hashToken(rawToken); + const invite = authQueries.getInviteByTokenHash(tokenHash); + + if (!invite) { + return { valid: false, error: "Invalid invite token" }; + } + + if (invite.expires_at && new Date(invite.expires_at) < new Date()) { + return { valid: false, error: "Invite has expired" }; + } + + if (invite.max_uses > 0 && invite.use_count >= invite.max_uses) { + return { valid: false, error: "Invite has reached maximum uses" }; + } + + return { valid: true, role: invite.role }; } /** * List all invites */ export function listInvites() { - return authQueries.getAllInvites(); + return authQueries.getAllInvites(); } /** * Revoke an invite */ export function revokeInvite(id: string): void { - authQueries.revokeInvite(id); - debug.log('auth', `Invite revoked: ${id}`); + authQueries.revokeInvite(id); + debug.log("auth", `Invite revoked: ${id}`); } /** * Regenerate Personal Access Token for a user */ export function regeneratePAT(userId: string): string { - const user = authQueries.getUserById(userId); - if (!user) { - throw new Error('User not found'); - } + const user = authQueries.getUserById(userId); + if (!user) { + throw new Error("User not found"); + } - const pat = generatePAT(); - const patHash = hashToken(pat); + const pat = generatePAT(); + const patHash = hashToken(pat); - authQueries.updateUser(userId, { personal_access_token_hash: patHash }); + authQueries.updateUser(userId, { personal_access_token_hash: patHash }); - debug.log('auth', `PAT regenerated for user: ${userId}`); + debug.log("auth", `PAT regenerated for user: ${userId}`); - return pat; + return pat; } /** * Update user display name */ export function updateUserName(userId: string, newName: string): AuthUser { - const trimmedName = newName.trim(); - if (trimmedName.length === 0) { - throw new Error('Name cannot be empty'); - } - - authQueries.updateUser(userId, { - name: trimmedName, - color: generateColorFromString(trimmedName), - avatar: getInitials(trimmedName) - }); - - const updated = authQueries.getUserById(userId); - if (!updated) { - throw new Error('User not found after update'); - } - - return toAuthUser(updated); + const trimmedName = newName.trim(); + if (trimmedName.length === 0) { + throw new Error("Name cannot be empty"); + } + + authQueries.updateUser(userId, { + name: trimmedName, + color: generateColorFromString(trimmedName), + avatar: getInitials(trimmedName), + }); + + const updated = authQueries.getUserById(userId); + if (!updated) { + throw new Error("User not found after update"); + } + + return toAuthUser(updated); } /** * Logout all sessions (all users) */ export function logoutAllSessions(): number { - const count = authQueries.deleteAllSessions(); - debug.log('auth', `All sessions deleted: ${count}`); - return count; + const count = authQueries.deleteAllSessions(); + debug.log("auth", `All sessions deleted: ${count}`); + return count; } /** * List projects assigned to a specific user (admin view) */ export function listUserProjects(userId: string): Project[] { - const user = authQueries.getUserById(userId); - if (!user) { - throw new Error('User not found'); - } - return projectQueries.getAllForUser(userId); + const user = authQueries.getUserById(userId); + if (!user) { + throw new Error("User not found"); + } + return projectQueries.getAllForUser(userId); } /** @@ -489,51 +561,57 @@ export function listUserProjects(userId: string): Project[] { * they create, but this is used to grant a member access to a project. * Returns true if a new assignment was added, false if user already had access. */ -export function assignProjectToUser(userId: string, projectId: string): boolean { - const user = authQueries.getUserById(userId); - if (!user) { - throw new Error('Access denied'); - } - const project = projectQueries.getById(projectId); - if (!project) { - throw new Error('Access denied'); - } - if (projectQueries.userHasProject(userId, projectId)) { - return false; - } - projectQueries.addUserProject(userId, projectId); - debug.log('auth', `Project ${projectId} assigned to user ${userId}`); - return true; +export function assignProjectToUser( + userId: string, + projectId: string, +): boolean { + const user = authQueries.getUserById(userId); + if (!user) { + throw new Error("Access denied"); + } + const project = projectQueries.getById(projectId); + if (!project) { + throw new Error("Access denied"); + } + if (projectQueries.userHasProject(userId, projectId)) { + return false; + } + projectQueries.addUserProject(userId, projectId); + debug.log("auth", `Project ${projectId} assigned to user ${userId}`); + return true; } /** * Revoke a user's access to a project. Refuses to remove the last admin * association to avoid orphaning the project. */ -export function unassignProjectFromUser(userId: string, projectId: string): boolean { - const user = authQueries.getUserById(userId); - if (!user) { - throw new Error('Access denied'); - } - const project = projectQueries.getById(projectId); - if (!project) { - throw new Error('Access denied'); - } - if (!projectQueries.userHasProject(userId, projectId)) { - return false; - } - projectQueries.removeUserProject(userId, projectId); - debug.log('auth', `Project ${projectId} unassigned from user ${userId}`); - return true; +export function unassignProjectFromUser( + userId: string, + projectId: string, +): boolean { + const user = authQueries.getUserById(userId); + if (!user) { + throw new Error("Access denied"); + } + const project = projectQueries.getById(projectId); + if (!project) { + throw new Error("Access denied"); + } + if (!projectQueries.userHasProject(userId, projectId)) { + return false; + } + projectQueries.removeUserProject(userId, projectId); + debug.log("auth", `Project ${projectId} unassigned from user ${userId}`); + return true; } /** * Cleanup expired sessions */ export function cleanupExpiredSessions(): number { - const count = authQueries.deleteExpiredSessions(); - if (count > 0) { - debug.log('auth', `Cleaned up ${count} expired sessions`); - } - return count; + const count = authQueries.deleteExpiredSessions(); + if (count > 0) { + debug.log("auth", `Cleaned up ${count} expired sessions`); + } + return count; } diff --git a/backend/database/queries/auth-invite-token.test.ts b/backend/database/queries/auth-invite-token.test.ts new file mode 100644 index 00000000..9300d40f --- /dev/null +++ b/backend/database/queries/auth-invite-token.test.ts @@ -0,0 +1,59 @@ +import { beforeAll, afterAll, describe, expect, test } from 'bun:test'; +import { initializeDatabase, closeDatabase, getDatabase } from '$backend/database'; +import { authQueries } from '$backend/database/queries'; + +function createUserId(): string { + return `user-${crypto.randomUUID()}`; +} + +function createInviteId(): string { + return `invite-${crypto.randomUUID()}`; +} + +describe('authQueries.incrementUseCount', () => { + beforeAll(async () => { + await initializeDatabase(); + }); + + afterAll(() => { + closeDatabase(); + }); + + test('does not increment beyond max_uses for limited invites', () => { + const now = new Date().toISOString(); + const userId = createUserId(); + const inviteId = createInviteId(); + const tokenHash = `hash-${crypto.randomUUID()}`; + + authQueries.createUser({ + id: userId, + name: 'Invite Limit Tester', + color: '#2563EB', + avatar: 'ILT', + role: 'admin', + personal_access_token_hash: null, + created_at: now + }); + + authQueries.createInvite({ + id: inviteId, + token_hash: tokenHash, + role: 'member', + label: 'atomic-check', + created_by: userId, + max_uses: 1, + use_count: 0, + expires_at: null, + created_at: now + }); + + authQueries.incrementUseCount(inviteId); + authQueries.incrementUseCount(inviteId); + + const row = getDatabase().prepare('SELECT use_count FROM invite_tokens WHERE id = ?').get(inviteId) as { use_count: number }; + expect(row.use_count).toBe(1); + + getDatabase().prepare('DELETE FROM invite_tokens WHERE id = ?').run(inviteId); + authQueries.deleteUser(userId); + }); +}); diff --git a/backend/database/queries/auth-queries.ts b/backend/database/queries/auth-queries.ts index 7a03bf46..d79598e9 100644 --- a/backend/database/queries/auth-queries.ts +++ b/backend/database/queries/auth-queries.ts @@ -1,201 +1,269 @@ -import { getDatabase } from '../index'; +import { getDatabase } from "../index"; export interface DBUser { - id: string; - name: string; - color: string; - avatar: string; - role: 'admin' | 'member'; - personal_access_token_hash: string | null; - created_at: string; - updated_at: string; + id: string; + name: string; + color: string; + avatar: string; + role: "admin" | "member"; + personal_access_token_hash: string | null; + created_at: string; + updated_at: string; } export interface DBAuthSession { - id: string; - user_id: string; - token_hash: string; - expires_at: string; - created_at: string; - last_active_at: string; + id: string; + user_id: string; + token_hash: string; + expires_at: string; + created_at: string; + last_active_at: string; } export interface DBInviteToken { - id: string; - token_hash: string; - role: 'admin' | 'member'; - label: string | null; - created_by: string; - max_uses: number; - use_count: number; - expires_at: string | null; - created_at: string; + id: string; + token_hash: string; + role: "admin" | "member"; + label: string | null; + created_by: string; + max_uses: number; + use_count: number; + expires_at: string | null; + created_at: string; } export const authQueries = { - // ===================== Users ===================== + // ===================== Users ===================== - createUser(user: Omit & { updated_at?: string }): DBUser { - const db = getDatabase(); - const now = new Date().toISOString(); - db.prepare(` + createUser( + user: Omit & { updated_at?: string }, + ): DBUser { + const db = getDatabase(); + const now = new Date().toISOString(); + db.prepare( + ` INSERT INTO users (id, name, color, avatar, role, personal_access_token_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - user.id, - user.name, - user.color, - user.avatar, - user.role, - user.personal_access_token_hash, - user.created_at, - user.updated_at ?? now - ); - return db.prepare('SELECT * FROM users WHERE id = ?').get(user.id) as DBUser; - }, - - getUserById(id: string): DBUser | null { - const db = getDatabase(); - return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DBUser | null; - }, - - getUserByPatHash(hash: string): DBUser | null { - const db = getDatabase(); - return db.prepare('SELECT * FROM users WHERE personal_access_token_hash = ?').get(hash) as DBUser | null; - }, - - getAllUsers(): DBUser[] { - const db = getDatabase(); - return db.prepare('SELECT * FROM users ORDER BY created_at ASC').all() as DBUser[]; - }, - - countUsers(): number { - const db = getDatabase(); - const result = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }; - return result.count; - }, - - countAdmins(): number { - const db = getDatabase(); - const result = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }; - return result.count; - }, - - updateUser(id: string, fields: Partial>): void { - const db = getDatabase(); - const now = new Date().toISOString(); - const sets: string[] = ['updated_at = ?']; - const values: any[] = [now]; - - if (fields.name !== undefined) { sets.push('name = ?'); values.push(fields.name); } - if (fields.color !== undefined) { sets.push('color = ?'); values.push(fields.color); } - if (fields.avatar !== undefined) { sets.push('avatar = ?'); values.push(fields.avatar); } - if (fields.personal_access_token_hash !== undefined) { sets.push('personal_access_token_hash = ?'); values.push(fields.personal_access_token_hash); } - - values.push(id); - db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...values); - }, - - deleteUser(id: string): void { - const db = getDatabase(); - db.prepare('DELETE FROM users WHERE id = ?').run(id); - }, - - // ===================== Auth Sessions ===================== - - createSession(session: DBAuthSession): DBAuthSession { - const db = getDatabase(); - db.prepare(` + `, + ).run( + user.id, + user.name, + user.color, + user.avatar, + user.role, + user.personal_access_token_hash, + user.created_at, + user.updated_at ?? now, + ); + return db + .prepare("SELECT * FROM users WHERE id = ?") + .get(user.id) as DBUser; + }, + + getUserById(id: string): DBUser | null { + const db = getDatabase(); + return db + .prepare("SELECT * FROM users WHERE id = ?") + .get(id) as DBUser | null; + }, + + getUserByPatHash(hash: string): DBUser | null { + const db = getDatabase(); + return db + .prepare("SELECT * FROM users WHERE personal_access_token_hash = ?") + .get(hash) as DBUser | null; + }, + + getAllUsers(): DBUser[] { + const db = getDatabase(); + return db + .prepare("SELECT * FROM users ORDER BY created_at ASC") + .all() as DBUser[]; + }, + + countUsers(): number { + const db = getDatabase(); + const result = db.prepare("SELECT COUNT(*) as count FROM users").get() as { + count: number; + }; + return result.count; + }, + + countAdmins(): number { + const db = getDatabase(); + const result = db + .prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'") + .get() as { count: number }; + return result.count; + }, + + updateUser( + id: string, + fields: Partial< + Pick + >, + ): void { + const db = getDatabase(); + const now = new Date().toISOString(); + const sets: string[] = ["updated_at = ?"]; + const values: any[] = [now]; + + if (fields.name !== undefined) { + sets.push("name = ?"); + values.push(fields.name); + } + if (fields.color !== undefined) { + sets.push("color = ?"); + values.push(fields.color); + } + if (fields.avatar !== undefined) { + sets.push("avatar = ?"); + values.push(fields.avatar); + } + if (fields.personal_access_token_hash !== undefined) { + sets.push("personal_access_token_hash = ?"); + values.push(fields.personal_access_token_hash); + } + + values.push(id); + db.prepare(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`).run( + ...values, + ); + }, + + deleteUser(id: string): void { + const db = getDatabase(); + db.prepare("DELETE FROM users WHERE id = ?").run(id); + }, + + // ===================== Auth Sessions ===================== + + createSession(session: DBAuthSession): DBAuthSession { + const db = getDatabase(); + db.prepare( + ` INSERT INTO auth_sessions (id, user_id, token_hash, expires_at, created_at, last_active_at) VALUES (?, ?, ?, ?, ?, ?) - `).run( - session.id, - session.user_id, - session.token_hash, - session.expires_at, - session.created_at, - session.last_active_at - ); - return db.prepare('SELECT * FROM auth_sessions WHERE id = ?').get(session.id) as DBAuthSession; - }, - - getSessionByTokenHash(hash: string): DBAuthSession | null { - const db = getDatabase(); - return db.prepare('SELECT * FROM auth_sessions WHERE token_hash = ?').get(hash) as DBAuthSession | null; - }, - - updateLastActive(id: string): void { - const db = getDatabase(); - const now = new Date().toISOString(); - db.prepare('UPDATE auth_sessions SET last_active_at = ? WHERE id = ?').run(now, id); - }, - - deleteSession(id: string): void { - const db = getDatabase(); - db.prepare('DELETE FROM auth_sessions WHERE id = ?').run(id); - }, - - deleteSessionByTokenHash(hash: string): void { - const db = getDatabase(); - db.prepare('DELETE FROM auth_sessions WHERE token_hash = ?').run(hash); - }, - - deleteSessionsByUserId(userId: string): void { - const db = getDatabase(); - db.prepare('DELETE FROM auth_sessions WHERE user_id = ?').run(userId); - }, - - deleteExpiredSessions(): number { - const db = getDatabase(); - const now = new Date().toISOString(); - const result = db.prepare('DELETE FROM auth_sessions WHERE expires_at < ?').run(now) as { changes: number }; - return result.changes; - }, - - // ===================== Invite Tokens ===================== - - createInvite(invite: DBInviteToken): DBInviteToken { - const db = getDatabase(); - db.prepare(` + `, + ).run( + session.id, + session.user_id, + session.token_hash, + session.expires_at, + session.created_at, + session.last_active_at, + ); + return db + .prepare("SELECT * FROM auth_sessions WHERE id = ?") + .get(session.id) as DBAuthSession; + }, + + getSessionByTokenHash(hash: string): DBAuthSession | null { + const db = getDatabase(); + return db + .prepare("SELECT * FROM auth_sessions WHERE token_hash = ?") + .get(hash) as DBAuthSession | null; + }, + + updateLastActive(id: string): void { + const db = getDatabase(); + const now = new Date().toISOString(); + db.prepare("UPDATE auth_sessions SET last_active_at = ? WHERE id = ?").run( + now, + id, + ); + }, + + deleteSession(id: string): void { + const db = getDatabase(); + db.prepare("DELETE FROM auth_sessions WHERE id = ?").run(id); + }, + + deleteSessionByTokenHash(hash: string): void { + const db = getDatabase(); + db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(hash); + }, + + deleteSessionsByUserId(userId: string): void { + const db = getDatabase(); + db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(userId); + }, + + deleteExpiredSessions(): number { + const db = getDatabase(); + const now = new Date().toISOString(); + const result = db + .prepare("DELETE FROM auth_sessions WHERE expires_at < ?") + .run(now) as { changes: number }; + return result.changes; + }, + + // ===================== Invite Tokens ===================== + + createInvite(invite: DBInviteToken): DBInviteToken { + const db = getDatabase(); + db.prepare( + ` INSERT INTO invite_tokens (id, token_hash, role, label, created_by, max_uses, use_count, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - invite.id, - invite.token_hash, - invite.role, - invite.label, - invite.created_by, - invite.max_uses, - invite.use_count, - invite.expires_at, - invite.created_at - ); - return db.prepare('SELECT * FROM invite_tokens WHERE id = ?').get(invite.id) as DBInviteToken; - }, - - getInviteByTokenHash(hash: string): DBInviteToken | null { - const db = getDatabase(); - return db.prepare('SELECT * FROM invite_tokens WHERE token_hash = ?').get(hash) as DBInviteToken | null; - }, - - incrementUseCount(id: string): void { - const db = getDatabase(); - db.prepare('UPDATE invite_tokens SET use_count = use_count + 1 WHERE id = ?').run(id); - }, - - getAllInvites(): DBInviteToken[] { - const db = getDatabase(); - return db.prepare('SELECT * FROM invite_tokens ORDER BY created_at DESC').all() as DBInviteToken[]; - }, - - revokeInvite(id: string): void { - const db = getDatabase(); - db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(id); - }, - - deleteAllSessions(): number { - const db = getDatabase(); - const result = db.prepare('DELETE FROM auth_sessions').run() as { changes: number }; - return result.changes; - } + `, + ).run( + invite.id, + invite.token_hash, + invite.role, + invite.label, + invite.created_by, + invite.max_uses, + invite.use_count, + invite.expires_at, + invite.created_at, + ); + return db + .prepare("SELECT * FROM invite_tokens WHERE id = ?") + .get(invite.id) as DBInviteToken; + }, + + getInviteByTokenHash(hash: string): DBInviteToken | null { + const db = getDatabase(); + return db + .prepare("SELECT * FROM invite_tokens WHERE token_hash = ?") + .get(hash) as DBInviteToken | null; + }, + + incrementUseCount(id: string): boolean { + const db = getDatabase(); + const now = new Date().toISOString(); + const result = db + .prepare( + ` + UPDATE invite_tokens + SET use_count = use_count + 1 + WHERE id = ? + AND (max_uses = 0 OR use_count < max_uses) + AND (expires_at IS NULL OR expires_at >= ?) + `, + ) + .run(id, now) as { changes: number }; + return result.changes > 0; + }, + + getAllInvites(): DBInviteToken[] { + const db = getDatabase(); + return db + .prepare("SELECT * FROM invite_tokens ORDER BY created_at DESC") + .all() as DBInviteToken[]; + }, + + revokeInvite(id: string): void { + const db = getDatabase(); + db.prepare("DELETE FROM invite_tokens WHERE id = ?").run(id); + }, + + deleteAllSessions(): number { + const db = getDatabase(); + const result = db.prepare("DELETE FROM auth_sessions").run() as { + changes: number; + }; + return result.changes; + }, };