2026-03-05 00:04:26 -06:00
import { Database } from "bun:sqlite" ;
import { resolve } from "path" ;
import { logger } from "./logger" ;
import { ensureDataDir } from "./paths" ;
import { getConfig } from "./config" ;
const dataDir = ensureDataDir ();
const dbPath = resolve ( dataDir , "overlord.db" );
const db = new Database ( dbPath );
export type UserRole = "admin" | "operator" | "viewer" ;
2026-03-06 20:50:02 -06:00
export type ClientAccessScope = "none" | "allowlist" | "denylist" | "all" ;
export type ClientAccessRuleKind = "allow" | "deny" ;
2026-05-05 15:44:26 -05:00
export type PluginAccessScope = "none" | "allowlist" | "all" ;
2026-03-05 00:04:26 -06:00
export interface User {
id : number ;
username : string ;
password_hash : string ;
role : UserRole ;
created_at : number ;
last_login : number | null ;
created_by : string | null ;
must_change_password : number ;
2026-03-06 20:50:02 -06:00
client_scope : ClientAccessScope ;
2026-05-05 15:44:26 -05:00
plugin_scope : PluginAccessScope ;
2026-03-12 21:26:53 -05:00
can_build : number ;
2026-04-08 10:33:22 -05:00
can_upload_files : number ;
2026-03-12 21:26:53 -05:00
telegram_chat_id : string | null ;
2026-03-05 00:04:26 -06:00
}
export interface UserInfo {
id : number ;
username : string ;
role : UserRole ;
created_at : number ;
last_login : number | null ;
created_by : string | null ;
2026-03-06 20:50:02 -06:00
client_scope : ClientAccessScope ;
2026-05-05 15:44:26 -05:00
plugin_scope : PluginAccessScope ;
2026-03-12 21:26:53 -05:00
can_build : number ;
2026-04-08 10:33:22 -05:00
can_upload_files : number ;
2026-03-12 21:26:53 -05:00
telegram_chat_id : string | null ;
2026-03-06 20:50:02 -06:00
}
export interface UserClientAccessRule {
userId : number ;
clientId : string ;
access : ClientAccessRuleKind ;
2026-03-05 00:04:26 -06:00
}
2026-05-05 15:44:26 -05:00
export interface UserPluginAccessRule {
userId : number ;
pluginId : string ;
}
2026-03-22 23:21:27 -05:00
type UserAccessCacheEntry = {
scope : ClientAccessScope ;
allow : Set < string >;
deny : Set < string >;
};
const userAccessCache = new Map < number , UserAccessCacheEntry >();
let notificationDeliveryCache : UserDeliveryRow [] | null = null ;
2026-03-05 00:04:26 -06:00
db . exec ( `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'operator', 'viewer')),
created_at INTEGER NOT NULL,
last_login INTEGER,
created_by TEXT,
2026-03-06 20:50:02 -06:00
must_change_password INTEGER DEFAULT 0,
client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))
2026-03-05 00:04:26 -06:00
)
` );
2026-03-06 20:50:02 -06:00
db . exec ( `
CREATE TABLE IF NOT EXISTS user_client_access_rules (
user_id INTEGER NOT NULL,
client_id TEXT NOT NULL,
access TEXT NOT NULL CHECK(access IN ('allow', 'deny')),
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, client_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
` );
db . exec (
`CREATE INDEX IF NOT EXISTS idx_user_client_access_rules_user ON user_client_access_rules(user_id)` ,
);
2026-04-13 17:15:06 -05:00
db . exec ( `
CREATE TABLE IF NOT EXISTS user_feature_permissions (
user_id INTEGER NOT NULL,
feature TEXT NOT NULL,
allowed INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, feature),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
` );
2026-05-05 15:44:26 -05:00
db . exec ( `
CREATE TABLE IF NOT EXISTS user_plugin_access_rules (
user_id INTEGER NOT NULL,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, plugin_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
` );
db . exec (
`CREATE INDEX IF NOT EXISTS idx_user_plugin_access_rules_user ON user_plugin_access_rules(user_id)` ,
);
2026-03-05 00:04:26 -06:00
try {
db . exec (
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0` ,
);
logger . info ( "[users] Added must_change_password column to existing database" );
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
2026-03-06 20:50:02 -06:00
try {
db . exec (
`ALTER TABLE users ADD COLUMN client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))` ,
);
logger . info ( "[users] Added client_scope column to existing database" );
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
try {
db . exec ( `UPDATE users SET client_scope='all' WHERE role='admin'` );
} catch ( err : any ) {
logger . error ( "[users] Failed to normalize admin client_scope:" , err );
}
2026-03-12 21:26:53 -05:00
try {
db . exec (
`ALTER TABLE users ADD COLUMN can_build INTEGER NOT NULL DEFAULT 0` ,
);
logger . info ( "[users] Added can_build column to existing database" );
try {
db . exec ( `UPDATE users SET can_build=1 WHERE role='admin' OR role='operator'` );
} catch ( err : any ) {
logger . error ( "[users] Failed to backfill admin/operator can_build:" , err );
}
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
try {
db . exec (
`ALTER TABLE users ADD COLUMN telegram_chat_id TEXT DEFAULT NULL` ,
);
logger . info ( "[users] Added telegram_chat_id column to existing database" );
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
2026-03-13 23:17:24 -05:00
const notificationColumns : Array < { sql : string ; label : string } > = [
{ sql : `ALTER TABLE users ADD COLUMN webhook_enabled INTEGER DEFAULT 0` , label : "webhook_enabled" },
{ sql : `ALTER TABLE users ADD COLUMN webhook_url TEXT DEFAULT NULL` , label : "webhook_url" },
{ sql : `ALTER TABLE users ADD COLUMN webhook_template TEXT DEFAULT NULL` , label : "webhook_template" },
{ sql : `ALTER TABLE users ADD COLUMN telegram_enabled INTEGER DEFAULT 0` , label : "telegram_enabled" },
{ sql : `ALTER TABLE users ADD COLUMN telegram_bot_token TEXT DEFAULT NULL` , label : "telegram_bot_token" },
{ sql : `ALTER TABLE users ADD COLUMN telegram_template TEXT DEFAULT NULL` , label : "telegram_template" },
];
for ( const col of notificationColumns ) {
try {
db . exec ( col . sql );
logger . info ( `[users] Added ${ col . label } column to existing database` );
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
}
2026-04-08 10:33:22 -05:00
try {
db . exec (
`ALTER TABLE users ADD COLUMN can_upload_files INTEGER NOT NULL DEFAULT 0` ,
);
logger . info ( "[users] Added can_upload_files column to existing database" );
try {
db . exec ( `UPDATE users SET can_upload_files=1 WHERE role='admin'` );
} catch ( err : any ) {
logger . error ( "[users] Failed to backfill admin can_upload_files:" , err );
}
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
2026-04-13 14:11:49 -05:00
const clientEventColumns : Array < { sql : string ; label : string } > = [
{ sql : `ALTER TABLE users ADD COLUMN client_event_webhook INTEGER DEFAULT 1` , label : "client_event_webhook" },
{ sql : `ALTER TABLE users ADD COLUMN client_event_telegram INTEGER DEFAULT 1` , label : "client_event_telegram" },
{ sql : `ALTER TABLE users ADD COLUMN client_event_push INTEGER DEFAULT 1` , label : "client_event_push" },
];
for ( const col of clientEventColumns ) {
try {
db . exec ( col . sql );
logger . info ( `[users] Added ${ col . label } column to existing database` );
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
}
2026-04-14 21:56:18 -05:00
try {
db . exec ( `ALTER TABLE users ADD COLUMN chat_write INTEGER DEFAULT NULL` );
logger . info ( "[users] Added chat_write column to existing database" );
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
2026-04-19 18:24:50 -05:00
try {
db . exec ( `ALTER TABLE users ADD COLUMN registered_via TEXT DEFAULT NULL` );
logger . info ( "[users] Added registered_via column to existing database" );
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
2026-05-05 15:44:26 -05:00
try {
db . exec (
`ALTER TABLE users ADD COLUMN plugin_scope TEXT NOT NULL DEFAULT 'none' CHECK(plugin_scope IN ('none', 'allowlist', 'all'))` ,
);
logger . info ( "[users] Added plugin_scope column to existing database" );
try {
db . exec ( `UPDATE users SET plugin_scope='all' WHERE role='admin'` );
} catch ( err : any ) {
logger . error ( "[users] Failed to backfill admin plugin_scope:" , err );
}
} catch ( err : any ) {
if ( ! err . message ? . includes ( "duplicate column name" )) {
logger . error ( "[users] Migration error:" , err );
}
}
2026-04-19 18:24:50 -05:00
db . exec ( `
CREATE TABLE IF NOT EXISTS registration_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
label TEXT,
created_by INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
used_by INTEGER,
used_at INTEGER,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
)
` );
db . exec ( `CREATE INDEX IF NOT EXISTS idx_registration_keys_key ON registration_keys("key")` );
db . exec ( `
CREATE TABLE IF NOT EXISTS pending_registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
requested_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'denied')),
reviewed_by INTEGER,
reviewed_at INTEGER,
key_used INTEGER,
FOREIGN KEY (key_used) REFERENCES registration_keys(id)
)
` );
db . exec ( `CREATE INDEX IF NOT EXISTS idx_pending_registrations_status ON pending_registrations(status)` );
db . exec ( `CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_registrations_username_pending ON pending_registrations(username) WHERE status = 'pending'` );
2026-03-05 00:04:26 -06:00
const userCount = db . prepare ( "SELECT COUNT(*) as count FROM users" ). get () as {
count : number ;
};
if ( userCount . count === 0 ) {
const config = getConfig ();
const initialUsername = ( config . auth . username || "admin" ). trim () || "admin" ;
const initialPassword = config . auth . password ;
2026-05-08 14:30:39 -05:00
const mustChangePassword = config . auth . passwordIsUserSupplied ? 0 : 1 ;
2026-03-05 00:04:26 -06:00
logger . info ( "[users] No users found, creating default admin account" );
const defaultPassword = await Bun . password . hash ( initialPassword , {
algorithm : "bcrypt" ,
cost : 10 ,
});
db . prepare (
2026-05-05 15:44:26 -05:00
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ,
2026-05-08 14:30:39 -05:00
). run ( initialUsername , defaultPassword , "admin" , Date . now (), "system" , mustChangePassword , "all" , "all" , 1 , 1 );
2026-03-05 00:04:26 -06:00
const createdUser = db
. prepare ( "SELECT * FROM users WHERE username = ?" )
. get ( initialUsername ) as User | undefined ;
logger . info (
"[users] Default admin created with must_change_password =" ,
createdUser ? . must_change_password ,
);
logger . info ( `[users] Initial admin account created (username: ${ initialUsername } )` );
2026-05-08 14:30:39 -05:00
if ( mustChangePassword ) {
logger . warn (
"[users] SECURITY WARNING: A default admin account has been created with the fallback password. Sign in and rotate the password immediately. Bootstrap credentials default to admin/admin unless overridden by configuration; the password is not logged." ,
);
} else {
logger . info (
"[users] Initial admin account uses the operator-supplied password; first-login rotation is not required." ,
);
}
2026-03-05 00:04:26 -06:00
}
export function getUserById ( id : number ) : User | null {
const user = db . prepare ( "SELECT * FROM users WHERE id = ?" ). get ( id ) as
| User
| undefined ;
return user || null ;
}
export function getUserByUsername ( username : string ) : User | null {
const user = db
. prepare ( "SELECT * FROM users WHERE username = ?" )
. get ( username ) as User | undefined ;
return user || null ;
}
export function listUsers () : UserInfo [] {
const users = db
. prepare (
2026-05-05 15:44:26 -05:00
"SELECT id, username, role, created_at, last_login, created_by, client_scope, plugin_scope, can_build, can_upload_files, telegram_chat_id FROM users ORDER BY created_at DESC" ,
2026-03-05 00:04:26 -06:00
)
. all () as UserInfo [];
return users ;
}
2026-03-06 20:50:02 -06:00
export function getUserClientAccessScope ( userId : number ) : ClientAccessScope {
2026-03-22 23:21:27 -05:00
return getUserAccessCacheEntry ( userId ). scope ;
2026-03-06 20:50:02 -06:00
}
export function listUserClientAccessRules ( userId : number ) : UserClientAccessRule [] {
return db
. prepare (
"SELECT user_id as userId, client_id as clientId, access FROM user_client_access_rules WHERE user_id = ? ORDER BY client_id ASC" ,
)
. all ( userId ) as UserClientAccessRule [];
}
export function listUserClientRuleIdsByAccess (
userId : number ,
access : ClientAccessRuleKind ,
) : string [] {
2026-03-22 23:21:27 -05:00
const entry = getUserAccessCacheEntry ( userId );
2026-04-13 22:45:12 -05:00
const values = access === "allow" ? entry.allow : entry.deny ;
2026-03-22 23:21:27 -05:00
return Array . from ( values ). sort (( left , right ) => left . localeCompare ( right ));
}
function invalidateUserAccessCache ( userId? : number ) : void {
if ( userId === undefined ) {
userAccessCache . clear ();
return ;
}
userAccessCache . delete ( userId );
}
function invalidateNotificationDeliveryCache () : void {
notificationDeliveryCache = null ;
}
function getUserAccessCacheEntry ( userId : number ) : UserAccessCacheEntry {
const cached = userAccessCache . get ( userId );
if ( cached ) {
return cached ;
}
const row = db
. prepare ( "SELECT client_scope FROM users WHERE id = ?" )
. get ( userId ) as { client_scope? : ClientAccessScope } | undefined ;
const rules = db
2026-03-06 20:50:02 -06:00
. prepare (
2026-03-22 23:21:27 -05:00
"SELECT client_id as clientId, access FROM user_client_access_rules WHERE user_id = ?" ,
2026-03-06 20:50:02 -06:00
)
2026-03-22 23:21:27 -05:00
. all ( userId ) as Array < { clientId : string ; access : ClientAccessRuleKind } > ;
const entry : UserAccessCacheEntry = {
scope : row?.client_scope || "none" ,
allow : new Set < string >(),
deny : new Set < string >(),
};
for ( const rule of rules ) {
if ( rule . access === "allow" ) {
entry . allow . add ( rule . clientId );
} else if ( rule . access === "deny" ) {
entry . deny . add ( rule . clientId );
}
}
userAccessCache . set ( userId , entry );
return entry ;
2026-03-06 20:50:02 -06:00
}
export function setUserClientAccessScope (
userId : number ,
scope : ClientAccessScope ,
) : { success : boolean ; error? : string } {
if ( ! [ "none" , "allowlist" , "denylist" , "all" ]. includes ( scope )) {
return { success : false , error : "Invalid client access scope" };
}
try {
db . prepare ( "UPDATE users SET client_scope = ? WHERE id = ?" ). run ( scope , userId );
2026-03-22 23:21:27 -05:00
invalidateUserAccessCache ( userId );
2026-03-06 20:50:02 -06:00
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserClientAccessScope error:" , err );
return { success : false , error : err.message || "Failed to update client access scope" };
}
}
export function setUserClientAccessRule (
userId : number ,
clientId : string ,
access : ClientAccessRuleKind ,
) : { success : boolean ; error? : string } {
const normalizedClientId = ( clientId || "" ). trim ();
if ( ! normalizedClientId ) {
return { success : false , error : "clientId is required" };
}
if ( ! [ "allow" , "deny" ]. includes ( access )) {
return { success : false , error : "Invalid client access rule" };
}
try {
db . prepare (
"INSERT OR REPLACE INTO user_client_access_rules (user_id, client_id, access, created_at) VALUES (?, ?, ?, ?)" ,
). run ( userId , normalizedClientId , access , Date . now ());
2026-03-22 23:21:27 -05:00
invalidateUserAccessCache ( userId );
2026-03-06 20:50:02 -06:00
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserClientAccessRule error:" , err );
return { success : false , error : err.message || "Failed to update client access rule" };
}
}
export function removeUserClientAccessRule (
userId : number ,
clientId : string ,
) : { success : boolean ; error? : string } {
try {
db . prepare ( "DELETE FROM user_client_access_rules WHERE user_id = ? AND client_id = ?" ). run (
userId ,
clientId ,
);
2026-03-22 23:21:27 -05:00
invalidateUserAccessCache ( userId );
2026-03-06 20:50:02 -06:00
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] removeUserClientAccessRule error:" , err );
return { success : false , error : err.message || "Failed to remove client access rule" };
}
}
export function canUserAccessClient (
userId : number ,
role : UserRole ,
clientId : string ,
) : boolean {
if ( role === "admin" ) return true ;
2026-03-22 23:21:27 -05:00
const access = getUserAccessCacheEntry ( userId );
const scope = access . scope ;
2026-03-06 20:50:02 -06:00
if ( scope === "none" ) return false ;
if ( scope === "all" ) return true ;
if ( scope === "allowlist" ) {
2026-03-22 23:21:27 -05:00
return access . allow . has ( clientId );
2026-03-06 20:50:02 -06:00
}
if ( scope === "denylist" ) {
2026-03-22 23:21:27 -05:00
return ! access . deny . has ( clientId );
2026-03-06 20:50:02 -06:00
}
return false ;
}
2026-05-05 15:44:26 -05:00
type PluginAccessCacheEntry = {
scope : PluginAccessScope ;
allowed : Set < string >;
};
const pluginAccessCache = new Map < number , PluginAccessCacheEntry >();
function invalidatePluginAccessCache ( userId? : number ) : void {
if ( userId === undefined ) {
pluginAccessCache . clear ();
return ;
}
pluginAccessCache . delete ( userId );
}
function getPluginAccessCacheEntry ( userId : number ) : PluginAccessCacheEntry {
const cached = pluginAccessCache . get ( userId );
if ( cached ) return cached ;
const row = db
. prepare ( "SELECT plugin_scope FROM users WHERE id = ?" )
. get ( userId ) as { plugin_scope? : PluginAccessScope } | undefined ;
const rules = db
. prepare ( "SELECT plugin_id as pluginId FROM user_plugin_access_rules WHERE user_id = ?" )
. all ( userId ) as Array < { pluginId : string } > ;
const entry : PluginAccessCacheEntry = {
scope : row?.plugin_scope || "none" ,
allowed : new Set < string >(),
};
for ( const rule of rules ) {
entry . allowed . add ( rule . pluginId );
}
pluginAccessCache . set ( userId , entry );
return entry ;
}
export function getUserPluginAccessScope ( userId : number ) : PluginAccessScope {
return getPluginAccessCacheEntry ( userId ). scope ;
}
export function listUserPluginAccessRules ( userId : number ) : UserPluginAccessRule [] {
return db
. prepare (
"SELECT user_id as userId, plugin_id as pluginId FROM user_plugin_access_rules WHERE user_id = ? ORDER BY plugin_id ASC" ,
)
. all ( userId ) as UserPluginAccessRule [];
}
export function canUserAccessPlugin (
userId : number ,
role : UserRole ,
pluginId : string ,
) : boolean {
if ( role === "admin" ) return true ;
const access = getPluginAccessCacheEntry ( userId );
if ( access . scope === "none" ) return false ;
if ( access . scope === "all" ) return true ;
if ( access . scope === "allowlist" ) return access . allowed . has ( pluginId );
return false ;
}
export function setUserPluginAccessScope (
userId : number ,
scope : PluginAccessScope ,
) : { success : boolean ; error? : string } {
if ( ! [ "none" , "allowlist" , "all" ]. includes ( scope )) {
return { success : false , error : "Invalid plugin access scope" };
}
try {
db . prepare ( "UPDATE users SET plugin_scope = ? WHERE id = ?" ). run ( scope , userId );
invalidatePluginAccessCache ( userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserPluginAccessScope error:" , err );
return { success : false , error : err.message || "Failed to update plugin access scope" };
}
}
export function setUserPluginAccessRule (
userId : number ,
pluginId : string ,
) : { success : boolean ; error? : string } {
const normalizedPluginId = ( pluginId || "" ). trim ();
if ( ! normalizedPluginId ) {
return { success : false , error : "pluginId is required" };
}
try {
db . prepare (
"INSERT OR REPLACE INTO user_plugin_access_rules (user_id, plugin_id, created_at) VALUES (?, ?, ?)" ,
). run ( userId , normalizedPluginId , Date . now ());
invalidatePluginAccessCache ( userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserPluginAccessRule error:" , err );
return { success : false , error : err.message || "Failed to update plugin access rule" };
}
}
export function removeUserPluginAccessRule (
userId : number ,
pluginId : string ,
) : { success : boolean ; error? : string } {
try {
db . prepare ( "DELETE FROM user_plugin_access_rules WHERE user_id = ? AND plugin_id = ?" ). run (
userId ,
pluginId ,
);
invalidatePluginAccessCache ( userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] removeUserPluginAccessRule error:" , err );
return { success : false , error : err.message || "Failed to remove plugin access rule" };
}
}
export function setUserPluginAccessRulesBulk (
userId : number ,
pluginIds : string [],
) : { success : boolean ; error? : string } {
try {
const tx = db . transaction (() => {
db . prepare ( "DELETE FROM user_plugin_access_rules WHERE user_id = ?" ). run ( userId );
const stmt = db . prepare (
"INSERT INTO user_plugin_access_rules (user_id, plugin_id, created_at) VALUES (?, ?, ?)" ,
);
const now = Date . now ();
for ( const pid of pluginIds ) {
const normalized = ( pid || "" ). trim ();
if ( normalized ) stmt . run ( userId , normalized , now );
}
});
tx ();
invalidatePluginAccessCache ( userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserPluginAccessRulesBulk error:" , err );
return { success : false , error : err.message || "Failed to update plugin access rules" };
}
}
2026-04-13 17:15:06 -05:00
export type FeatureName =
| "console"
| "remote_desktop"
| "hvnc"
| "webcam"
| "file_browser"
| "processes"
| "keylogger"
| "voice" ;
export const ALL_FEATURES : FeatureName [] = [
"console" ,
"remote_desktop" ,
"hvnc" ,
"webcam" ,
"file_browser" ,
"processes" ,
"keylogger" ,
"voice" ,
];
const featurePermCache = new Map < number , Map < string , boolean >>();
function getFeaturePermCacheEntry ( userId : number ) : Map < string , boolean > {
const cached = featurePermCache . get ( userId );
if ( cached ) return cached ;
const rows = db
. prepare ( "SELECT feature, allowed FROM user_feature_permissions WHERE user_id = ?" )
. all ( userId ) as Array < { feature : string ; allowed : number } > ;
const map = new Map < string , boolean >();
for ( const row of rows ) {
map . set ( row . feature , row . allowed === 1 );
}
featurePermCache . set ( userId , map );
return map ;
}
function invalidateFeaturePermCache ( userId : number ) : void {
featurePermCache . delete ( userId );
}
export function canUserAccessFeature (
userId : number ,
role : UserRole ,
feature : FeatureName ,
) : boolean {
if ( role === "admin" ) return true ;
if ( role === "viewer" ) return false ;
const perms = getFeaturePermCacheEntry ( userId );
const entry = perms . get ( feature );
if ( entry === undefined ) return true ;
return entry ;
}
export function getUserFeaturePermissions (
userId : number ,
) : Record < FeatureName , boolean > {
const user = getUserById ( userId );
if ( ! user ) {
const result = {} as Record < FeatureName , boolean >;
for ( const f of ALL_FEATURES ) result [ f ] = false ;
return result ;
}
const result = {} as Record < FeatureName , boolean >;
for ( const f of ALL_FEATURES ) {
result [ f ] = canUserAccessFeature ( userId , user . role , f );
}
return result ;
}
export function setUserFeaturePermission (
userId : number ,
feature : FeatureName ,
allowed : boolean ,
) : { success : boolean ; error? : string } {
if ( ! ALL_FEATURES . includes ( feature )) {
return { success : false , error : `Invalid feature: ${ feature } ` };
}
try {
db . prepare (
"INSERT OR REPLACE INTO user_feature_permissions (user_id, feature, allowed) VALUES (?, ?, ?)" ,
). run ( userId , feature , allowed ? 1 : 0 );
invalidateFeaturePermCache ( userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserFeaturePermission error:" , err );
return { success : false , error : err.message || "Failed to update feature permission" };
}
}
export function setUserFeaturePermissions (
userId : number ,
permissions : Partial < Record < FeatureName , boolean >>,
) : { success : boolean ; error? : string } {
const stmt = db . prepare (
"INSERT OR REPLACE INTO user_feature_permissions (user_id, feature, allowed) VALUES (?, ?, ?)" ,
);
try {
const tx = db . transaction (() => {
for ( const [ feature , allowed ] of Object . entries ( permissions )) {
if ( ! ALL_FEATURES . includes ( feature as FeatureName )) continue ;
stmt . run ( userId , feature , allowed ? 1 : 0 );
}
});
tx ();
invalidateFeaturePermCache ( userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserFeaturePermissions error:" , err );
return { success : false , error : err.message || "Failed to update feature permissions" };
}
}
export function resetUserFeaturePermissions (
userId : number ,
) : { success : boolean ; error? : string } {
try {
db . prepare ( "DELETE FROM user_feature_permissions WHERE user_id = ?" ). run ( userId );
invalidateFeaturePermCache ( userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] resetUserFeaturePermissions error:" , err );
return { success : false , error : err.message || "Failed to reset feature permissions" };
}
}
2026-04-19 18:24:50 -05:00
export function validatePasswordPolicy ( password : string ) : string | null {
2026-03-06 20:50:02 -06:00
const security = getConfig (). security ;
const minLength = Math . min ( 128 , Math . max ( 6 , Number ( security . passwordMinLength ) || 6 ));
if ( ! password || password . length < minLength ) {
return `Password must be at least ${ minLength } characters` ;
}
if ( security . passwordRequireUppercase && ! /[A-Z]/ . test ( password )) {
return "Password must include at least one uppercase letter" ;
}
if ( security . passwordRequireLowercase && ! /[a-z]/ . test ( password )) {
return "Password must include at least one lowercase letter" ;
}
if ( security . passwordRequireNumber && ! /[0-9]/ . test ( password )) {
return "Password must include at least one number" ;
}
if ( security . passwordRequireSymbol && ! /[^A-Za-z0-9]/ . test ( password )) {
return "Password must include at least one symbol" ;
}
return null ;
}
2026-03-05 00:04:26 -06:00
export async function createUser (
username : string ,
password : string ,
role : UserRole ,
createdBy : string ,
) : Promise < { success : boolean ; error? : string ; userId? : number } > {
if ( ! username || username . length < 3 || username . length > 32 ) {
return {
success : false ,
error : "Username must be between 3 and 32 characters" ,
};
}
if ( ! /^[a-zA-Z0-9_-]+$/ . test ( username )) {
return {
success : false ,
error :
"Username can only contain letters, numbers, hyphens, and underscores" ,
};
}
2026-03-06 20:50:02 -06:00
const policyError = validatePasswordPolicy ( password );
if ( policyError ) {
return { success : false , error : policyError };
2026-03-05 00:04:26 -06:00
}
const existing = getUserByUsername ( username );
if ( existing ) {
return { success : false , error : "Username already exists" };
}
try {
const passwordHash = await Bun . password . hash ( password , {
algorithm : "bcrypt" ,
cost : 10 ,
});
const result = db
. prepare (
2026-05-05 15:44:26 -05:00
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" ,
2026-03-05 00:04:26 -06:00
)
2026-05-05 15:44:26 -05:00
. run ( username , passwordHash , role , Date . now (), createdBy , role === "admin" ? "all" : "none" , role === "admin" ? "all" : "none" , role === "admin" || role === "operator" ? 1 : 0 , role === "admin" ? 1 : 0 );
2026-03-05 00:04:26 -06:00
2026-03-22 23:21:27 -05:00
invalidateNotificationDeliveryCache ();
2026-03-05 00:04:26 -06:00
return { success : true , userId : result.lastInsertRowid as number };
} catch ( err : any ) {
logger . error ( "[users] Create user error:" , err );
return { success : false , error : err.message || "Failed to create user" };
}
}
export async function updateUserPassword (
userId : number ,
newPassword : string ,
) : Promise < { success : boolean ; error? : string } > {
2026-03-06 20:50:02 -06:00
const policyError = validatePasswordPolicy ( newPassword );
if ( policyError ) {
return { success : false , error : policyError };
2026-03-05 00:04:26 -06:00
}
try {
const passwordHash = await Bun . password . hash ( newPassword , {
algorithm : "bcrypt" ,
cost : 10 ,
});
db . prepare (
"UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?" ,
). run ( passwordHash , userId );
return { success : true };
} catch ( err : any ) {
console . error ( "[users] Update password error:" , err );
return {
success : false ,
error : err.message || "Failed to update password" ,
};
}
}
export function updateUserRole (
userId : number ,
newRole : UserRole ,
) : { success : boolean ; error? : string } {
try {
2026-03-06 20:50:02 -06:00
const nextScope : ClientAccessScope = newRole === "admin" ? "all" : "none" ;
2026-05-05 15:44:26 -05:00
const nextPluginScope : PluginAccessScope = newRole === "admin" ? "all" : "none" ;
db . prepare ( "UPDATE users SET role = ?, client_scope = ?, plugin_scope = ? WHERE id = ?" ). run (
2026-03-06 20:50:02 -06:00
newRole ,
nextScope ,
2026-05-05 15:44:26 -05:00
nextPluginScope ,
2026-03-06 20:50:02 -06:00
userId ,
);
2026-03-22 23:21:27 -05:00
invalidateUserAccessCache ( userId );
2026-05-05 15:44:26 -05:00
invalidatePluginAccessCache ( userId );
2026-03-22 23:21:27 -05:00
invalidateNotificationDeliveryCache ();
2026-03-05 00:04:26 -06:00
return { success : true };
} catch ( err : any ) {
console . error ( "[users] Update role error:" , err );
return { success : false , error : err.message || "Failed to update role" };
}
}
export function deleteUser ( userId : number ) : {
success : boolean ;
error? : string ;
} {
const admins = db
. prepare ( "SELECT COUNT(*) as count FROM users WHERE role = 'admin'" )
. get () as { count : number };
const user = getUserById ( userId );
if ( user ? . role === "admin" && admins . count <= 1 ) {
return { success : false , error : "Cannot delete the last admin user" };
}
try {
db . prepare ( "DELETE FROM users WHERE id = ?" ). run ( userId );
2026-03-22 23:21:27 -05:00
invalidateUserAccessCache ( userId );
2026-05-05 15:44:26 -05:00
invalidatePluginAccessCache ( userId );
2026-03-22 23:21:27 -05:00
invalidateNotificationDeliveryCache ();
2026-03-05 00:04:26 -06:00
return { success : true };
} catch ( err : any ) {
console . error ( "[users] Delete user error:" , err );
return { success : false , error : err.message || "Failed to delete user" };
}
}
export function updateLastLogin ( userId : number ) : void {
db . prepare ( "UPDATE users SET last_login = ? WHERE id = ?" ). run (
Date . now (),
userId ,
);
}
2026-04-29 00:22:37 -05:00
let _dummyHashPromise : Promise < string > | null = null ;
function getDummyPasswordHash () : Promise < string > {
if ( ! _dummyHashPromise ) {
_dummyHashPromise = Bun . password . hash ( "__overlord_dummy_hash_for_timing__" , {
algorithm : "bcrypt" ,
cost : 10 ,
});
}
return _dummyHashPromise ;
}
2026-03-05 00:04:26 -06:00
export async function verifyPassword (
username : string ,
password : string ,
) : Promise < User | null > {
const user = getUserByUsername ( username );
2026-04-29 00:22:37 -05:00
if ( ! user ) {
try {
const dummy = await getDummyPasswordHash ();
await Bun . password . verify ( password , dummy );
} catch {
}
return null ;
}
2026-03-05 00:04:26 -06:00
const isValid = await Bun . password . verify ( password , user . password_hash );
if ( ! isValid ) return null ;
updateLastLogin ( user . id );
return user ;
}
export function canManageUsers ( role : UserRole ) : boolean {
return role === "admin" ;
}
export function canControlClients ( role : UserRole ) : boolean {
return role === "admin" || role === "operator" ;
}
2026-03-12 21:26:53 -05:00
export function canBuildClients ( userId : number , role : UserRole ) : boolean {
if ( role === "admin" ) return true ;
const user = getUserById ( userId );
return user ? user . can_build === 1 : false ;
}
2026-03-05 00:04:26 -06:00
export function canViewAuditLogs ( role : UserRole ) : boolean {
2026-04-13 14:11:49 -05:00
return role === "admin" || role === "operator" ;
2026-03-05 00:04:26 -06:00
}
2026-04-13 22:45:12 -05:00
export function canManageEnrollment ( role : UserRole ) : boolean {
return role === "admin" || role === "operator" ;
}
2026-03-12 21:26:53 -05:00
export function hasPermission ( role : UserRole , permission : string , userId? : number ) : boolean {
2026-03-05 00:04:26 -06:00
switch ( permission ) {
case "users:manage" :
return canManageUsers ( role );
case "clients:control" :
return canControlClients ( role );
2026-03-12 21:26:53 -05:00
case "clients:build" :
if ( userId !== undefined ) return canBuildClients ( userId , role );
return role === "admin" || role === "operator" ;
2026-04-13 22:45:12 -05:00
case "clients:enroll" :
return canManageEnrollment ( role );
2026-03-05 00:04:26 -06:00
case "audit:view" :
return canViewAuditLogs ( role );
2026-04-14 21:56:18 -05:00
case "chat:write" :
if ( userId !== undefined ) return canChatWrite ( userId , role );
return role === "admin" || role === "operator" ;
2026-03-05 00:04:26 -06:00
default :
return false ;
}
}
2026-03-12 21:26:53 -05:00
export function setUserCanBuild (
userId : number ,
canBuild : boolean ,
) : { success : boolean ; error? : string } {
try {
db . prepare ( "UPDATE users SET can_build = ? WHERE id = ?" ). run ( canBuild ? 1 : 0 , userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserCanBuild error:" , err );
return { success : false , error : err.message || "Failed to update build permission" };
}
}
2026-04-08 10:33:22 -05:00
export function canUploadFiles ( userId : number , role : UserRole ) : boolean {
if ( role === "admin" ) return true ;
const user = getUserById ( userId );
return user ? user . can_upload_files === 1 : false ;
}
export function setUserCanUploadFiles (
userId : number ,
canUpload : boolean ,
) : { success : boolean ; error? : string } {
try {
db . prepare ( "UPDATE users SET can_upload_files = ? WHERE id = ?" ). run ( canUpload ? 1 : 0 , userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserCanUploadFiles error:" , err );
return { success : false , error : err.message || "Failed to update upload permission" };
}
}
2026-04-14 21:56:18 -05:00
export function canChatWrite ( userId : number , role : UserRole ) : boolean {
if ( role === "admin" ) return true ;
const user = getUserById ( userId );
if ( ! user ) return false ;
const chatWrite = ( user as any ). chat_write ;
if ( chatWrite === null || chatWrite === undefined ) {
return role === "operator" ;
}
return chatWrite === 1 ;
}
export function setUserChatWrite (
userId : number ,
canWrite : boolean | null ,
) : { success : boolean ; error? : string } {
try {
db . prepare ( "UPDATE users SET chat_write = ? WHERE id = ?" ). run ( canWrite === null ? null : ( canWrite ? 1 : 0 ), userId );
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserChatWrite error:" , err );
return { success : false , error : err.message || "Failed to update chat write permission" };
}
}
2026-03-12 21:26:53 -05:00
export function setUserTelegramChatId (
userId : number ,
chatId : string | null ,
) : { success : boolean ; error? : string } {
try {
db . prepare ( "UPDATE users SET telegram_chat_id = ? WHERE id = ?" ). run ( chatId , userId );
2026-03-22 23:21:27 -05:00
invalidateNotificationDeliveryCache ();
2026-03-12 21:26:53 -05:00
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] setUserTelegramChatId error:" , err );
return { success : false , error : err.message || "Failed to update Telegram chat ID" };
}
}
export function getUserTelegramChatId ( userId : number ) : string | null {
const row = db
. prepare ( "SELECT telegram_chat_id FROM users WHERE id = ?" )
. get ( userId ) as { telegram_chat_id? : string | null } | undefined ;
return row ? . telegram_chat_id || null ;
}
export function getUsersWithTelegramChatId () : Array < { id : number ; username : string ; role : UserRole ; client_scope : ClientAccessScope ; telegram_chat_id : string } > {
return db
. prepare (
"SELECT id, username, role, client_scope, telegram_chat_id FROM users WHERE telegram_chat_id IS NOT NULL AND telegram_chat_id != ''" ,
)
. all () as any [];
}
2026-03-13 23:17:24 -05:00
// ─── Per-user notification delivery settings ─────────────────────────────────
export interface UserNotificationSettings {
webhook_enabled : number ;
webhook_url : string | null ;
webhook_template : string | null ;
telegram_enabled : number ;
telegram_bot_token : string | null ;
telegram_chat_id : string | null ;
telegram_template : string | null ;
2026-04-13 14:11:49 -05:00
client_event_webhook : number ;
client_event_telegram : number ;
client_event_push : number ;
2026-03-13 23:17:24 -05:00
}
export function getUserNotificationSettings ( userId : number ) : UserNotificationSettings | null {
const row = db
. prepare (
2026-04-13 14:11:49 -05:00
"SELECT webhook_enabled, webhook_url, webhook_template, telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template, client_event_webhook, client_event_telegram, client_event_push FROM users WHERE id = ?" ,
2026-03-13 23:17:24 -05:00
)
. get ( userId ) as UserNotificationSettings | undefined ;
return row ?? null ;
}
export function updateUserNotificationSettings (
userId : number ,
settings : Partial < UserNotificationSettings >,
) : { success : boolean ; error? : string } {
const fields : string [] = [];
const values : any [] = [];
if ( "webhook_enabled" in settings ) {
fields . push ( "webhook_enabled = ?" );
values . push ( settings . webhook_enabled ? 1 : 0 );
}
if ( "webhook_url" in settings ) {
fields . push ( "webhook_url = ?" );
values . push ( settings . webhook_url || null );
}
if ( "webhook_template" in settings ) {
fields . push ( "webhook_template = ?" );
values . push ( settings . webhook_template || null );
}
if ( "telegram_enabled" in settings ) {
fields . push ( "telegram_enabled = ?" );
values . push ( settings . telegram_enabled ? 1 : 0 );
}
if ( "telegram_bot_token" in settings ) {
fields . push ( "telegram_bot_token = ?" );
values . push ( settings . telegram_bot_token || null );
}
if ( "telegram_chat_id" in settings ) {
fields . push ( "telegram_chat_id = ?" );
values . push ( settings . telegram_chat_id || null );
}
if ( "telegram_template" in settings ) {
fields . push ( "telegram_template = ?" );
values . push ( settings . telegram_template || null );
}
2026-04-13 14:11:49 -05:00
if ( "client_event_webhook" in settings ) {
fields . push ( "client_event_webhook = ?" );
values . push ( settings . client_event_webhook ? 1 : 0 );
}
if ( "client_event_telegram" in settings ) {
fields . push ( "client_event_telegram = ?" );
values . push ( settings . client_event_telegram ? 1 : 0 );
}
if ( "client_event_push" in settings ) {
fields . push ( "client_event_push = ?" );
values . push ( settings . client_event_push ? 1 : 0 );
}
2026-03-13 23:17:24 -05:00
if ( fields . length === 0 ) return { success : true };
try {
values . push ( userId );
db . prepare ( `UPDATE users SET ${ fields . join ( ", " ) } WHERE id = ?` ). run (... values );
2026-03-22 23:21:27 -05:00
invalidateNotificationDeliveryCache ();
2026-03-13 23:17:24 -05:00
return { success : true };
} catch ( err : any ) {
logger . error ( "[users] updateUserNotificationSettings error:" , err );
return { success : false , error : err.message || "Failed to update notification settings" };
}
}
export interface UserDeliveryRow {
id : number ;
username : string ;
role : UserRole ;
client_scope : ClientAccessScope ;
webhook_enabled : number ;
webhook_url : string | null ;
webhook_template : string | null ;
telegram_enabled : number ;
telegram_bot_token : string | null ;
telegram_chat_id : string | null ;
telegram_template : string | null ;
2026-04-13 14:11:49 -05:00
client_event_webhook : number ;
client_event_telegram : number ;
client_event_push : number ;
2026-03-13 23:17:24 -05:00
}
export function getUsersForNotificationDelivery () : UserDeliveryRow [] {
2026-03-22 23:21:27 -05:00
if ( notificationDeliveryCache ) {
return notificationDeliveryCache ;
}
notificationDeliveryCache = db
2026-03-13 23:17:24 -05:00
. prepare (
`SELECT id, username, role, client_scope,
webhook_enabled, webhook_url, webhook_template,
2026-04-13 14:11:49 -05:00
telegram_enabled, telegram_bot_token, telegram_chat_id, telegram_template,
client_event_webhook, client_event_telegram, client_event_push
2026-03-13 23:17:24 -05:00
FROM users
WHERE (webhook_enabled = 1 AND webhook_url IS NOT NULL AND webhook_url != '')
OR (telegram_enabled = 1 AND telegram_bot_token IS NOT NULL AND telegram_bot_token != ''
AND telegram_chat_id IS NOT NULL AND telegram_chat_id != '')` ,
)
. all () as UserDeliveryRow [];
2026-03-22 23:21:27 -05:00
return notificationDeliveryCache ;
}
export function getUsersForNotificationDeliveryByClient ( clientId : string ) : UserDeliveryRow [] {
return getUsersForNotificationDelivery (). filter (( user ) =>
canUserAccessClient ( user . id , user . role , clientId ),
);
2026-03-13 23:17:24 -05:00
}
2026-04-19 18:24:50 -05:00
2026-05-17 16:46:03 -05:00
function getClientOwnerUserId ( clientId : string ) : number | null {
if ( ! clientId ) return null ;
try {
const row = db
. prepare ( "SELECT built_by_user_id FROM clients WHERE id = ?" )
. get ( clientId ) as { built_by_user_id : number | null } | undefined ;
if ( ! row || row . built_by_user_id == null ) return null ;
return typeof row . built_by_user_id === "number" ? row.built_by_user_id : null ;
} catch {
return null ;
}
}
export function getUsersForNotificationDeliveryByClientOwnership (
clientId : string ,
) : UserDeliveryRow [] {
const ownerId = getClientOwnerUserId ( clientId );
const all = getUsersForNotificationDelivery ();
if ( ownerId != null ) {
return all . filter (( user ) => user . id === ownerId );
}
return all . filter (( user ) => user . role === "admin" );
}
export function isClientOwnedByUser ( userId : number , clientId : string ) : boolean {
const ownerId = getClientOwnerUserId ( clientId );
return ownerId != null && ownerId === userId ;
}
2026-04-19 18:24:50 -05:00
export interface RegistrationKey {
id : number ;
key : string ;
label : string | null ;
created_by : number ;
created_at : number ;
expires_at : number | null ;
used_by : number | null ;
used_at : number | null ;
}
export interface PendingRegistration {
id : number ;
username : string ;
password_hash : string ;
requested_at : number ;
status : "pending" | "approved" | "denied" ;
reviewed_by : number | null ;
reviewed_at : number | null ;
key_used : number | null ;
}
export function generateRegistrationKey () : string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" ;
const segments = 4 ;
const segmentLen = 5 ;
const parts : string [] = [];
for ( let s = 0 ; s < segments ; s ++ ) {
let seg = "" ;
const bytes = new Uint8Array ( segmentLen );
crypto . getRandomValues ( bytes );
for ( let i = 0 ; i < segmentLen ; i ++ ) {
seg += chars [ bytes [ i ] % chars . length ];
}
parts . push ( seg );
}
return parts . join ( "-" );
}
export function createRegistrationKeys (
count : number ,
createdBy : number ,
label? : string ,
expiresInHours? : number ,
) : RegistrationKey [] {
const safeCount = Math . min ( 100 , Math . max ( 1 , count ));
const now = Date . now ();
const expiresAt = expiresInHours && expiresInHours > 0
? now + expiresInHours * 60 * 60 * 1000
: null ;
const safeLabel = label ? String ( label ). slice ( 0 , 128 ). trim () : null ;
const stmt = db . prepare (
`INSERT INTO registration_keys ("key", label, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?)` ,
);
const keys : RegistrationKey [] = [];
const tx = db . transaction (() => {
for ( let i = 0 ; i < safeCount ; i ++ ) {
const key = generateRegistrationKey ();
const result = stmt . run ( key , safeLabel , createdBy , now , expiresAt );
keys . push ({
id : result.lastInsertRowid as number ,
key ,
label : safeLabel ,
created_by : createdBy ,
created_at : now ,
expires_at : expiresAt ,
used_by : null ,
used_at : null ,
});
}
});
tx ();
return keys ;
}
export function listRegistrationKeys () : RegistrationKey [] {
return db . prepare (
`SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys ORDER BY created_at DESC` ,
). all () as RegistrationKey [];
}
export function getRegistrationKeyByValue ( keyValue : string ) : RegistrationKey | null {
const row = db . prepare (
`SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys WHERE "key" = ?` ,
). get ( keyValue ) as RegistrationKey | undefined ;
return row || null ;
}
export function markRegistrationKeyUsed ( keyId : number , usedByUserId : number ) : void {
db . prepare ( `UPDATE registration_keys SET used_by = ?, used_at = ? WHERE id = ?` ). run (
usedByUserId , Date . now (), keyId ,
);
}
export function claimRegistrationKey (
keyValue : string ,
usedByUserId : number ,
) : { success : true ; key : RegistrationKey } | { success : false ; error : string } {
const tx = db . transaction (() => {
const row = db . prepare (
`SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys WHERE "key" = ?` ,
). get ( keyValue ) as RegistrationKey | undefined ;
if ( ! row ) return { success : false as const , error : "Invalid registration key" };
if ( row . used_by !== null ) return { success : false as const , error : "This registration key has already been used" };
if ( row . expires_at && row . expires_at < Date . now ()) return { success : false as const , error : "This registration key has expired" };
db . prepare ( `UPDATE registration_keys SET used_by = ?, used_at = ? WHERE id = ? AND used_by IS NULL` ). run (
usedByUserId , Date . now (), row . id ,
);
return { success : true as const , key : row };
});
return tx ();
}
export function deleteRegistrationKey ( keyId : number ) : boolean {
const result = db . prepare ( `DELETE FROM registration_keys WHERE id = ?` ). run ( keyId );
return ( result . changes as number ) > 0 ;
}
export function createPendingRegistration (
username : string ,
passwordHash : string ,
keyUsed? : number ,
) : { success : boolean ; id? : number ; error? : string } {
try {
const result = db . prepare (
`INSERT INTO pending_registrations (username, password_hash, requested_at, status, key_used) VALUES (?, ?, ?, 'pending', ?)` ,
). run ( username , passwordHash , Date . now (), keyUsed || null );
return { success : true , id : result.lastInsertRowid as number };
} catch ( err : any ) {
if ( err . message ? . includes ( "UNIQUE constraint" )) {
return { success : false , error : "A registration with this username is already pending" };
}
return { success : false , error : err.message || "Failed to create pending registration" };
}
}
export function listPendingRegistrations () : PendingRegistration [] {
return db . prepare (
`SELECT id, username, requested_at, status, reviewed_by, reviewed_at, key_used FROM pending_registrations WHERE status = 'pending' ORDER BY requested_at ASC` ,
). all () as PendingRegistration [];
}
export function getPendingRegistration ( id : number ) : PendingRegistration | null {
const row = db . prepare (
`SELECT * FROM pending_registrations WHERE id = ?` ,
). get ( id ) as PendingRegistration | undefined ;
return row || null ;
}
export async function approvePendingRegistration (
pendingId : number ,
reviewedBy : number ,
defaultRole : UserRole ,
) : Promise < { success : boolean ; userId? : number ; error? : string } > {
const pending = getPendingRegistration ( pendingId );
if ( ! pending ) return { success : false , error : "Pending registration not found" };
if ( pending . status !== "pending" ) return { success : false , error : "Registration already reviewed" };
const existing = getUserByUsername ( pending . username );
if ( existing ) return { success : false , error : "Username already exists" };
try {
const role = defaultRole === "viewer" ? "viewer" : "operator" ;
const result = db . prepare (
`INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
). run (
pending . username , pending . password_hash , role , Date . now (), "registration" ,
"allowlist" , role === "operator" ? 1 : 0 , 0 , "approval" ,
);
db . prepare (
`UPDATE pending_registrations SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?` ,
). run ( reviewedBy , Date . now (), pendingId );
invalidateNotificationDeliveryCache ();
return { success : true , userId : result.lastInsertRowid as number };
} catch ( err : any ) {
logger . error ( "[users] approvePendingRegistration error:" , err );
return { success : false , error : err.message || "Failed to approve registration" };
}
}
export function denyPendingRegistration (
pendingId : number ,
reviewedBy : number ,
) : { success : boolean ; error? : string } {
const pending = getPendingRegistration ( pendingId );
if ( ! pending ) return { success : false , error : "Pending registration not found" };
if ( pending . status !== "pending" ) return { success : false , error : "Registration already reviewed" };
db . prepare (
`UPDATE pending_registrations SET status = 'denied', reviewed_by = ?, reviewed_at = ? WHERE id = ?` ,
). run ( reviewedBy , Date . now (), pendingId );
return { success : true };
}
export async function registerUser (
username : string ,
password : string ,
registeredVia : "open" | "key" ,
defaultRole : UserRole ,
) : Promise < { success : boolean ; error? : string ; userId? : number } > {
if ( ! username || username . length < 3 || username . length > 32 ) {
return { success : false , error : "Username must be between 3 and 32 characters" };
}
if ( ! /^[a-zA-Z0-9_-]+$/ . test ( username )) {
return { success : false , error : "Username can only contain letters, numbers, hyphens, and underscores" };
}
const policyError = validatePasswordPolicy ( password );
if ( policyError ) return { success : false , error : policyError };
const existing = getUserByUsername ( username );
if ( existing ) return { success : false , error : "Username already exists" };
const pendingExisting = db . prepare (
`SELECT id FROM pending_registrations WHERE username = ? AND status = 'pending'` ,
). get ( username );
if ( pendingExisting ) return { success : false , error : "A registration with this username is already pending" };
try {
const passwordHash = await Bun . password . hash ( password , { algorithm : "bcrypt" , cost : 10 });
const role = defaultRole === "viewer" ? "viewer" : "operator" ;
const result = db . prepare (
`INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
). run (
username , passwordHash , role , Date . now (), "registration" ,
"allowlist" , role === "operator" ? 1 : 0 , 0 , registeredVia ,
);
invalidateNotificationDeliveryCache ();
return { success : true , userId : result.lastInsertRowid as number };
} catch ( err : any ) {
if ( err . message ? . includes ( "UNIQUE constraint" )) {
return { success : false , error : "Username already exists" };
}
logger . error ( "[users] registerUser error:" , err );
return { success : false , error : err.message || "Failed to register user" };
}
}
export function getTotalUserCount () : number {
const row = db . prepare ( "SELECT COUNT(*) as count FROM users" ). get () as { count : number };
return row . count ;
}