Add user-scoped Hermes profile access
This commit is contained in:
@@ -104,6 +104,38 @@ export const MODEL_CONTEXT_SCHEMA: Record<string, string> = {
|
||||
|
||||
export const MODEL_CONTEXT_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_context_provider_model ON model_context(provider, model)'
|
||||
|
||||
// ============================================================================
|
||||
// Users and Profile Access
|
||||
// ============================================================================
|
||||
|
||||
export const USERS_TABLE = 'users'
|
||||
|
||||
export const USERS_SCHEMA: Record<string, string> = {
|
||||
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
||||
username: 'TEXT NOT NULL UNIQUE',
|
||||
password_hash: 'TEXT NOT NULL',
|
||||
role: "TEXT NOT NULL DEFAULT 'admin'",
|
||||
status: "TEXT NOT NULL DEFAULT 'active'",
|
||||
created_at: 'INTEGER NOT NULL',
|
||||
updated_at: 'INTEGER NOT NULL',
|
||||
last_login_at: 'INTEGER',
|
||||
}
|
||||
|
||||
export const USER_PROFILES_TABLE = 'user_profiles'
|
||||
|
||||
export const USER_PROFILES_SCHEMA: Record<string, string> = {
|
||||
user_id: 'INTEGER NOT NULL',
|
||||
profile_name: "TEXT NOT NULL DEFAULT 'default'",
|
||||
is_default: 'INTEGER NOT NULL DEFAULT 0',
|
||||
created_at: 'INTEGER NOT NULL',
|
||||
}
|
||||
|
||||
export const USER_PROFILES_INDEXES = {
|
||||
idx_user_profiles_user: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_user ON user_profiles(user_id)',
|
||||
idx_user_profiles_profile: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_profile ON user_profiles(profile_name)',
|
||||
idx_user_profiles_default: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_default ON user_profiles(user_id) WHERE is_default = 1',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Group Chat (services/hermes/group-chat/index.ts)
|
||||
// ============================================================================
|
||||
@@ -329,6 +361,13 @@ export function initAllHermesTables(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// Users and profile access
|
||||
syncTable(USERS_TABLE, USERS_SCHEMA)
|
||||
syncTable(USER_PROFILES_TABLE, USER_PROFILES_SCHEMA, {
|
||||
primaryKey: 'user_id, profile_name',
|
||||
indexes: USER_PROFILES_INDEXES,
|
||||
})
|
||||
|
||||
// Group chat - basic tables
|
||||
syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA)
|
||||
syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA)
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'
|
||||
import { getDb } from '../index'
|
||||
import { USER_PROFILES_TABLE, USERS_TABLE } from './schemas'
|
||||
|
||||
export type UserRole = 'super_admin' | 'admin'
|
||||
export type UserStatus = 'active' | 'disabled'
|
||||
export type UserId = number | string
|
||||
|
||||
export interface UserRecord {
|
||||
id: number
|
||||
username: string
|
||||
password_hash: string
|
||||
role: UserRole
|
||||
status: UserStatus
|
||||
created_at: number
|
||||
updated_at: number
|
||||
last_login_at: number | null
|
||||
}
|
||||
|
||||
export interface UserProfileRecord {
|
||||
user_id: number
|
||||
profile_name: string
|
||||
is_default: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number
|
||||
username: string
|
||||
role: UserRole
|
||||
status: UserStatus
|
||||
profiles: string[]
|
||||
default_profile: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
last_login_at: number | null
|
||||
}
|
||||
|
||||
export const DEFAULT_USERNAME = 'admin'
|
||||
export const DEFAULT_PASSWORD = '123456'
|
||||
export const DEFAULT_PROFILE_NAME = 'default'
|
||||
|
||||
const SCRYPT_KEY_LEN = 64
|
||||
|
||||
function normalizeUserId(id: UserId): number | null {
|
||||
const userId = typeof id === 'number' ? id : Number(id)
|
||||
return Number.isInteger(userId) && userId > 0 ? userId : null
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
const hash = scryptSync(password, salt, SCRYPT_KEY_LEN).toString('hex')
|
||||
return `scrypt:${salt}:${hash}`
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, passwordHash: string): boolean {
|
||||
const [scheme, salt, expectedHex] = passwordHash.split(':')
|
||||
if (scheme !== 'scrypt' || !salt || !expectedHex) return false
|
||||
try {
|
||||
const expected = Buffer.from(expectedHex, 'hex')
|
||||
const actual = scryptSync(password, salt, expected.length)
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function findUserById(id: UserId): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const userId = normalizeUserId(id)
|
||||
if (!userId) return null
|
||||
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE id = ?`).get(userId) as UserRecord | undefined
|
||||
return row || null
|
||||
}
|
||||
|
||||
export function findUserByUsername(username: string): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE username = ?`).get(username) as UserRecord | undefined
|
||||
return row || null
|
||||
}
|
||||
|
||||
export function findFirstUser(): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} ORDER BY id ASC LIMIT 1`).get() as UserRecord | undefined
|
||||
return row || null
|
||||
}
|
||||
|
||||
export function listUsers(): UserSummary[] {
|
||||
const db = getDb()
|
||||
if (!db) return []
|
||||
const users = db.prepare(
|
||||
`SELECT id, username, role, status, created_at, updated_at, last_login_at FROM ${USERS_TABLE} ORDER BY id ASC`
|
||||
).all() as Array<Omit<UserSummary, 'profiles' | 'default_profile'>>
|
||||
return users.map(user => {
|
||||
const profiles = listUserProfiles(user.id)
|
||||
return {
|
||||
...user,
|
||||
profiles: profiles.map(profile => profile.profile_name),
|
||||
default_profile: profiles.find(profile => profile.is_default === 1)?.profile_name || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function listUserProfiles(userId: UserId): UserProfileRecord[] {
|
||||
const db = getDb()
|
||||
if (!db) return []
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return []
|
||||
return db.prepare(
|
||||
`SELECT * FROM ${USER_PROFILES_TABLE} WHERE user_id = ? ORDER BY is_default DESC, profile_name ASC`
|
||||
).all(id) as unknown as UserProfileRecord[]
|
||||
}
|
||||
|
||||
export function userCanAccessProfile(userId: UserId, profileName: string): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
const row = db.prepare(
|
||||
`SELECT 1 FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND profile_name = ?`
|
||||
).get(id, profileName)
|
||||
return !!row
|
||||
}
|
||||
|
||||
export function getDefaultProfileForUser(userId: UserId): string {
|
||||
const db = getDb()
|
||||
if (!db) return DEFAULT_PROFILE_NAME
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return DEFAULT_PROFILE_NAME
|
||||
const row = db.prepare(
|
||||
`SELECT profile_name FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND is_default = 1 LIMIT 1`
|
||||
).get(id) as { profile_name?: string } | undefined
|
||||
return row?.profile_name || DEFAULT_PROFILE_NAME
|
||||
}
|
||||
|
||||
export function countUsers(): number {
|
||||
const db = getDb()
|
||||
if (!db) return 0
|
||||
const row = db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE}`).get() as { count?: number } | undefined
|
||||
return Number(row?.count || 0)
|
||||
}
|
||||
|
||||
export function countActiveSuperAdmins(excludeUserId?: UserId): number {
|
||||
const db = getDb()
|
||||
if (!db) return 0
|
||||
const exclude = excludeUserId == null ? null : normalizeUserId(excludeUserId)
|
||||
const row = exclude
|
||||
? db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active' AND id != ?`).get(exclude)
|
||||
: db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active'`).get()
|
||||
return Number((row as { count?: number } | undefined)?.count || 0)
|
||||
}
|
||||
|
||||
export function touchUserLogin(userId: UserId, at = Date.now()): void {
|
||||
const db = getDb()
|
||||
if (!db) return
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return
|
||||
db.prepare(`UPDATE ${USERS_TABLE} SET last_login_at = ?, updated_at = ? WHERE id = ?`).run(at, at, id)
|
||||
}
|
||||
|
||||
export function updateUserPassword(userId: UserId, password: string): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
const result = db.prepare(`UPDATE ${USERS_TABLE} SET password_hash = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(hashPassword(password), Date.now(), id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function updateUsername(userId: UserId, username: string): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
const result = db.prepare(`UPDATE ${USERS_TABLE} SET username = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(username, Date.now(), id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function createUser(input: {
|
||||
username: string
|
||||
password: string
|
||||
role?: UserRole
|
||||
status?: UserStatus
|
||||
profiles?: string[]
|
||||
defaultProfile?: string | null
|
||||
}): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const now = Date.now()
|
||||
const role = input.role || 'admin'
|
||||
const status = input.status || 'active'
|
||||
db.prepare(
|
||||
`INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(input.username, hashPassword(input.password), role, status, now, now)
|
||||
|
||||
const user = findUserByUsername(input.username)
|
||||
if (user) replaceUserProfiles(user.id, input.profiles || [], input.defaultProfile)
|
||||
return user
|
||||
}
|
||||
|
||||
export function updateUser(input: {
|
||||
userId: UserId
|
||||
username?: string
|
||||
role?: UserRole
|
||||
status?: UserStatus
|
||||
password?: string
|
||||
profiles?: string[]
|
||||
defaultProfile?: string | null
|
||||
}): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
const id = normalizeUserId(input.userId)
|
||||
if (!id) return null
|
||||
|
||||
const current = findUserById(id)
|
||||
if (!current) return null
|
||||
|
||||
const nextUsername = input.username ?? current.username
|
||||
const nextRole = input.role ?? current.role
|
||||
const nextStatus = input.status ?? current.status
|
||||
const nextPasswordHash = input.password ? hashPassword(input.password) : current.password_hash
|
||||
const now = Date.now()
|
||||
|
||||
db.prepare(
|
||||
`UPDATE ${USERS_TABLE}
|
||||
SET username = ?, password_hash = ?, role = ?, status = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(nextUsername, nextPasswordHash, nextRole, nextStatus, now, id)
|
||||
|
||||
if (input.profiles) replaceUserProfiles(id, input.profiles, input.defaultProfile)
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
export function deleteUser(userId: UserId): boolean {
|
||||
const db = getDb()
|
||||
if (!db) return false
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return false
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id)
|
||||
const result = db.prepare(`DELETE FROM ${USERS_TABLE} WHERE id = ?`).run(id)
|
||||
db.exec('COMMIT')
|
||||
return result.changes > 0
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceUserProfiles(userId: UserId, profiles: string[], defaultProfile?: string | null): void {
|
||||
const db = getDb()
|
||||
if (!db) return
|
||||
const id = normalizeUserId(userId)
|
||||
if (!id) return
|
||||
|
||||
const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))]
|
||||
const defaultName = defaultProfile && uniqueProfiles.includes(defaultProfile) ? defaultProfile : uniqueProfiles[0] || null
|
||||
const now = Date.now()
|
||||
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id)
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO ${USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
uniqueProfiles.forEach(profile => {
|
||||
stmt.run(id, profile, profile === defaultName ? 1 : 0, now)
|
||||
})
|
||||
db.exec('COMMIT')
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultSuperAdmin(): UserRecord | null {
|
||||
const db = getDb()
|
||||
if (!db) return null
|
||||
|
||||
const now = Date.now()
|
||||
db.prepare(
|
||||
`INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(DEFAULT_USERNAME, hashPassword(DEFAULT_PASSWORD), 'super_admin', 'active', now, now)
|
||||
|
||||
return findUserByUsername(DEFAULT_USERNAME)
|
||||
}
|
||||
|
||||
export function bootstrapDefaultSuperAdmin(username: string, password: string): UserRecord | null {
|
||||
if (countUsers() > 0) return null
|
||||
if (username !== DEFAULT_USERNAME || password !== DEFAULT_PASSWORD) return null
|
||||
return createDefaultSuperAdmin()
|
||||
}
|
||||
Reference in New Issue
Block a user