// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). // users repository domain. Facade: src/db/repository.ts (Repository class). import Database from 'better-sqlite3'; import { scryptSync, randomBytes, timingSafeEqual } from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { buildVisibilityWhere } from '../../bridge/visibility.js'; import { utc } from './shared.js'; export interface User { id: string; email: string; name: string | null; avatarUrl: string | null; role: 'admin' | 'user'; status: 'active' | 'pending' | 'disabled'; defaultVisibility: 'private' | 'org' | 'public'; defaultVisibilityOrgId: string | null; createdAt: string; updatedAt: string; } export interface CreateUserParams { email: string; name: string; role: 'admin' | 'user'; status: 'active' | 'pending' | 'disabled'; avatarUrl?: string; } export interface FindOrCreateByOAuthParams { provider: string; providerId: string; email: string; name: string; avatarUrl?: string; } export interface CreateLocalUserParams { email: string; password: string; role: 'admin' | 'user'; status: 'active' | 'pending' | 'disabled'; name?: string; } export interface LocalOrg { id: string; name: string; createdBy: string | null; createdAt: string; } export interface LocalOrgMember { userId: string; role: string; } export interface GiteaOrgInput { orgId: string; orgName: string; } export interface GiteaOrg extends GiteaOrgInput { fetchedAt: string; } export interface UserRow { id: string; email: string; name: string | null; avatar_url: string | null; role: string; status: string; default_visibility: string | null; default_visibility_org_id: string | null; created_at: string; updated_at: string; } export function rowToUser(row: UserRow): User { return { id: row.id, email: row.email, name: row.name, avatarUrl: row.avatar_url, role: row.role as 'admin' | 'user', status: row.status as 'active' | 'pending' | 'disabled', defaultVisibility: (row.default_visibility ?? 'private') as User['defaultVisibility'], defaultVisibilityOrgId: row.default_visibility_org_id, createdAt: utc(row.created_at), updatedAt: utc(row.updated_at), }; } export function createUser(db: Database.Database, params: CreateUserParams): User { const id = uuidv4(); const now = new Date().toISOString(); db .prepare( `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) VALUES (@id, @email, @name, @avatarUrl, @role, @status, @now, @now)` ) .run({ id, email: params.email, name: params.name, avatarUrl: params.avatarUrl ?? null, role: params.role, status: params.status, now, }); const user = getUserById(db, id); if (!user) throw new Error(`createUser: failed to retrieve created user ${id}`); return user; } /** * Ensure the synthetic 'local' user row exists. No-auth single-user * deployments own per-user rows under the id 'local' (tasks, jobs, SSH * connections, DEKs, …). Many of those tables FK to users(id) with * foreign_keys ON, so the row must exist or the inserts fail — e.g. * ssh_user_deks → SSH connection creation returned create_failed. * Idempotent (INSERT OR IGNORE), so it is safe to call on every startup. * role='admin' mirrors the synthetic 'local' user the HTTP layer injects * for task-visibility routes in no-auth mode. */ export function ensureLocalUser(db: Database.Database): void { const now = new Date().toISOString(); db .prepare( `INSERT OR IGNORE INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) VALUES ('local', 'local@localhost', 'local', NULL, 'admin', 'active', @now, @now)` ) .run({ now }); } // ── Local auth (email + password) ───────────────────────────────────── /** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */ export function setLocalPassword(db: Database.Database, userId: string, plainPassword: string): void { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(plainPassword, salt, 64).toString('hex'); const now = new Date().toISOString(); db .prepare( `INSERT INTO local_credentials (user_id, password_hash, salt, updated_at) VALUES (@userId, @hash, @salt, @now) ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`, ) .run({ userId, hash, salt, now }); } /** Constant-time verify. False when the user has no local credential. */ export function verifyLocalPassword(db: Database.Database, userId: string, plainPassword: string): boolean { const row = db .prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?') .get(userId) as { password_hash: string; salt: string } | undefined; if (!row) return false; const expected = Buffer.from(row.password_hash, 'hex'); const actual = scryptSync(plainPassword, row.salt, expected.length); return expected.length === actual.length && timingSafeEqual(expected, actual); } export function hasLocalCredential(db: Database.Database, userId: string): boolean { return !!db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId); } /** * Create a brand-new local account (self-signup or admin-created). The email * MUST be unused: attaching a password to an existing account would be an * account-takeover vector, so we reject instead of linking. Linking a local * credential to an existing OAuth account is a separate, authenticated action * (not v1 signup). */ export function createLocalUser(db: Database.Database, params: CreateLocalUserParams): User { if (getUserByEmail(db, params.email)) { throw new Error(`createLocalUser: a user with email ${params.email} already exists`); } const user = createUser(db, { email: params.email, name: params.name ?? params.email, role: params.role, status: params.status, }); db .prepare( `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) VALUES (@id, @userId, 'local', @providerId, @now)`, ) .run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() }); setLocalPassword(db, user.id, params.password); return user; } /** * Idempotently seed the shared system admin under the fixed id `local` — the * same owner the no-auth path synthesizes. This makes all pre-existing * `local`-owned data belong to the logged-in admin once local auth is turned * on, and lets an existing no-auth deployment gain a login mid-stream. * Re-running updates the password and keeps role=admin/status=active. */ export function upsertLocalSystemAdmin(db: Database.Database, params: { email: string; password: string; name?: string }): User { const LOCAL_ID = 'local'; const now = new Date().toISOString(); const existing = getUserById(db, LOCAL_ID); if (!existing) { db .prepare( `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`, ) .run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now }); } else { db .prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`) .run({ id: LOCAL_ID, email: params.email, now }); } db .prepare( `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) VALUES (@id, @userId, 'local', @providerId, @now)`, ) .run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now }); setLocalPassword(db, LOCAL_ID, params.password); const user = getUserById(db, LOCAL_ID); if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin'); return user; } // ── Local organizations ─────────────────────────────────────────────── export function rowToLocalOrg(r: { id: string; name: string; created_by: string | null; created_at: string }): LocalOrg { return { id: r.id, name: r.name, createdBy: r.created_by, createdAt: r.created_at }; } /** Create a local org. id is prefixed `lorg:` so it never collides with a * Gitea numeric org id (both live in visibility_scope_org_id). */ export function createLocalOrg(db: Database.Database, name: string, createdBy: string | null): LocalOrg { const id = `lorg:${uuidv4()}`; const now = new Date().toISOString(); db .prepare(`INSERT INTO local_orgs (id, name, created_by, created_at) VALUES (@id, @name, @createdBy, @now)`) .run({ id, name, createdBy, now }); return { id, name, createdBy, createdAt: now }; } export function getLocalOrg(db: Database.Database, id: string): LocalOrg | null { const r = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs WHERE id = ?').get(id) as | { id: string; name: string; created_by: string | null; created_at: string } | undefined; return r ? rowToLocalOrg(r) : null; } export function listLocalOrgs(db: Database.Database): LocalOrg[] { const rows = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs ORDER BY name COLLATE NOCASE').all() as Array<{ id: string; name: string; created_by: string | null; created_at: string }>; return rows.map(r => rowToLocalOrg(r)); } export function renameLocalOrg(db: Database.Database, id: string, name: string): void { db.prepare('UPDATE local_orgs SET name = ? WHERE id = ?').run(name, id); } /** Tables carrying `visibility_scope_org_id` (org-scoped resources). */ const ORG_SCOPED_TABLES = ['local_tasks', 'scheduled_tasks', 'jobs']; /** * Delete a local org. Members cascade via FK. Resources scoped to this org * (visibility='org', visibility_scope_org_id=id) would become invisible to * everyone once the org is gone — so first downgrade them to 'private' * (owner + admin can still see them; no data loss). Atomic. */ export function deleteLocalOrg(db: Database.Database, id: string): void { const tx = db.transaction((orgId: string) => { for (const table of ORG_SCOPED_TABLES) { db .prepare(`UPDATE ${table} SET visibility = 'private', visibility_scope_org_id = NULL WHERE visibility_scope_org_id = ?`) .run(orgId); } db.prepare('DELETE FROM local_orgs WHERE id = ?').run(orgId); }); tx(id); } /** Add or update a member (idempotent — re-add updates the role). */ export function addOrgMember(db: Database.Database, orgId: string, userId: string, role: string = 'member'): void { const now = new Date().toISOString(); db .prepare( `INSERT INTO local_org_members (org_id, user_id, role, added_at) VALUES (@orgId, @userId, @role, @now) ON CONFLICT(org_id, user_id) DO UPDATE SET role=@role`, ) .run({ orgId, userId, role, now }); } export function removeOrgMember(db: Database.Database, orgId: string, userId: string): void { db.prepare('DELETE FROM local_org_members WHERE org_id = ? AND user_id = ?').run(orgId, userId); } export function listOrgMembers(db: Database.Database, orgId: string): LocalOrgMember[] { const rows = db .prepare('SELECT user_id, role FROM local_org_members WHERE org_id = ? ORDER BY added_at') .all(orgId) as Array<{ user_id: string; role: string }>; return rows.map(r => ({ userId: r.user_id, role: r.role })); } /** Orgs a user belongs to — merged into session.orgIds so the existing * provider-agnostic 'org' visibility (buildVisibilityWhere) covers them. */ export function listUserLocalOrgs(db: Database.Database, userId: string): Array<{ orgId: string; name: string }> { const rows = db .prepare( `SELECT o.id AS org_id, o.name AS name FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE`, ) .all(userId) as Array<{ org_id: string; name: string }>; return rows.map(r => ({ orgId: r.org_id, name: r.name })); } export function getUserById(db: Database.Database, id: string): User | null { const row = db .prepare('SELECT * FROM users WHERE id = ?') .get(id) as UserRow | undefined; return row ? rowToUser(row) : null; } export function getUserByEmail(db: Database.Database, email: string): User | null { const row = db .prepare('SELECT * FROM users WHERE email = ?') .get(email) as UserRow | undefined; return row ? rowToUser(row) : null; } export function findOrCreateUserByOAuth(db: Database.Database, params: FindOrCreateByOAuthParams): User { // 1. Check if oauth_account already exists const existing = db .prepare('SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_id = ?') .get(params.provider, params.providerId) as { user_id: string } | undefined; if (existing) { const user = getUserById(db, existing.user_id); if (!user) throw new Error(`findOrCreateUserByOAuth: user ${existing.user_id} not found`); // Sync mutable profile fields from the provider on every re-login so // existing users whose name was missing on first login pick it up once // their Gitea profile is populated. Email upgrade only applies when the // dummy @gitea.local placeholder is being replaced. const patch: { email?: string; name?: string; avatarUrl?: string | null } = {}; if (user.email.endsWith('@gitea.local') && !params.email.endsWith('@gitea.local')) { patch.email = params.email; } if (params.name && params.name !== user.name) patch.name = params.name; if (params.avatarUrl !== undefined && params.avatarUrl !== user.avatarUrl) { patch.avatarUrl = params.avatarUrl; } if (Object.keys(patch).length > 0) { updateUser(db, user.id, patch); const refreshed = getUserById(db, user.id); if (refreshed) return refreshed; } return user; } // 2. Check if user exists by email let user = getUserByEmail(db, params.email); if (!user) { // 3. Create new user with status=pending user = createUser(db, { email: params.email, name: params.name, role: 'user', status: 'pending', avatarUrl: params.avatarUrl, }); } // 4. Link oauth_account to user const oauthId = uuidv4(); const now = new Date().toISOString(); db .prepare( `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) VALUES (@id, @userId, @provider, @providerId, @now)` ) .run({ id: oauthId, userId: user.id, provider: params.provider, providerId: params.providerId, now, }); return user; } export function listUsers(db: Database.Database): User[] { const rows = db .prepare('SELECT * FROM users ORDER BY created_at ASC') .all() as UserRow[]; return rows.map(row => rowToUser(row)); } /** * Active users who share at least one organization with the given org id * set (Gitea org cache `user_gitea_orgs` OR local org membership * `local_org_members`). Used by the member-invite picker so a requester only * sees collaborators inside their own org(s) — exposing the full user list is * a privacy leak. Empty `orgIds` → empty result. Deduplicated by user id. */ export function listActiveUsersInOrgs(db: Database.Database, orgIds: string[]): User[] { if (orgIds.length === 0) return []; const placeholders = orgIds.map(() => '?').join(', '); const rows = db .prepare( `SELECT u.* FROM users u WHERE u.status = 'active' AND u.id IN ( SELECT user_id FROM user_gitea_orgs WHERE org_id IN (${placeholders}) UNION SELECT user_id FROM local_org_members WHERE org_id IN (${placeholders}) ) ORDER BY u.created_at ASC`, ) .all(...orgIds, ...orgIds) as UserRow[]; return rows.map(row => rowToUser(row)); } export function updateUser(db: Database.Database, id: string, updates: { status?: 'active' | 'pending' | 'disabled'; role?: 'admin' | 'user'; email?: string; name?: string; avatarUrl?: string | null; defaultVisibility?: 'private' | 'org' | 'public'; defaultVisibilityOrgId?: string | null; }): void { const setClauses: string[] = ["updated_at = datetime('now')"]; const params: Record = { id }; if (updates.status !== undefined) { setClauses.push('status = @status'); params['status'] = updates.status; } if (updates.role !== undefined) { setClauses.push('role = @role'); params['role'] = updates.role; } if (updates.email !== undefined) { setClauses.push('email = @email'); params['email'] = updates.email; } if (updates.name !== undefined) { setClauses.push('name = @name'); params['name'] = updates.name; } if (updates.avatarUrl !== undefined) { setClauses.push('avatar_url = @avatar_url'); params['avatar_url'] = updates.avatarUrl; } if (updates.defaultVisibility !== undefined) { setClauses.push('default_visibility = @default_visibility'); params['default_visibility'] = updates.defaultVisibility; } if (updates.defaultVisibilityOrgId !== undefined) { setClauses.push('default_visibility_org_id = @default_visibility_org_id'); params['default_visibility_org_id'] = updates.defaultVisibilityOrgId; } if (setClauses.length === 1) return; db .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = @id`) .run(params); } export function deleteUser(db: Database.Database, id: string): void { // Never delete the shared `local` system/admin user: it is the no-auth // fallback owner and owns all single-user-mode data. Deleting it would // break no-auth mode and orphan every `local`-owned task/job/folder. if (id === 'local') { throw new Error('cannot delete the local/system user'); } db.prepare('DELETE FROM users WHERE id = ?').run(id); } export function deleteSessionsByUserId(db: Database.Database, userId: string): void { // Sessions store passport user info as JSON in sess column // Delete sessions where sess contains the user id const rows = db .prepare('SELECT sid, sess FROM sessions') .all() as Array<{ sid: string; sess: string }>; const toDelete: string[] = []; for (const row of rows) { try { const sess = JSON.parse(row.sess) as Record; const passport = sess['passport'] as Record | undefined; if (passport && passport['user'] === userId) { toDelete.push(row.sid); } } catch { // ignore parse errors } } if (toDelete.length > 0) { const placeholders = toDelete.map(() => '?').join(', '); db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...toDelete); } } export function replaceUserGiteaOrgs(db: Database.Database, userId: string, orgs: GiteaOrgInput[]): void { const tx = db.transaction((uid: string, items: GiteaOrgInput[]) => { db.prepare('DELETE FROM user_gitea_orgs WHERE user_id = ?').run(uid); const insert = db.prepare( 'INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)' ); for (const o of items) insert.run(uid, o.orgId, o.orgName); }); tx(userId, orgs); } export function listUserGiteaOrgs(db: Database.Database, userId: string): GiteaOrg[] { const rows = db .prepare('SELECT org_id, org_name, fetched_at FROM user_gitea_orgs WHERE user_id = ? ORDER BY org_name ASC') .all(userId) as Array<{ org_id: string; org_name: string; fetched_at: string }>; return rows.map(r => ({ orgId: r.org_id, orgName: r.org_name, fetchedAt: r.fetched_at })); }