mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 17:16:19 +00:00
refactor: restructure db logic and add import alias using tsc-alias
Note: the legacy githubTokensDb migration is not included in this commit. It's used only in `agents.js` and will be removed in a future commit. We will directly use credentials repository instead of github tokens repository.
This commit is contained in:
148
server/src/shared/database/connection.ts
Normal file
148
server/src/shared/database/connection.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Database connection management.
|
||||
*
|
||||
* Owns the single SQLite connection used across all repositories.
|
||||
* Handles path resolution, directory creation, legacy database migration,
|
||||
* and eager app_config bootstrap so the auth middleware can read the
|
||||
* JWT secret before the full schema is applied.
|
||||
*
|
||||
* Consumers should never create their own Database instance — they use
|
||||
* `getConnection()` to obtain the shared singleton.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { APP_CONFIG_TABLE_SCHEMA_SQL } from '@/shared/database/schema.js';
|
||||
import { logger } from '@/shared/utils/logger.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves the database file path from environment or falls back
|
||||
* to the legacy location inside the server/database/ folder.
|
||||
*
|
||||
* Priority:
|
||||
* 1. DATABASE_PATH environment variable (set by cli.js or load-env.js)
|
||||
* 2. Legacy path: server/database/auth.db
|
||||
*/
|
||||
function resolveDatabasePath(): string {
|
||||
if (process.env.DATABASE_PATH) {
|
||||
return process.env.DATABASE_PATH;
|
||||
}
|
||||
|
||||
// Fallback: <project-root>/server/database/auth.db
|
||||
const serverDir = path.resolve(__dirname, '..', '..', '..');
|
||||
return path.join(serverDir, 'database', 'auth.db');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the legacy database path (always inside server/database/).
|
||||
* Used for the one-time migration to the new external location.
|
||||
*/
|
||||
function resolveLegacyDatabasePath(): string {
|
||||
const serverDir = path.resolve(__dirname, '..', '..', '..');
|
||||
return path.join(serverDir, 'database', 'auth.db');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory & migration helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureDatabaseDirectory(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
logger.info('Created database directory', { path: dir });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the database was moved to an external location (e.g. ~/.cloudcli/)
|
||||
* but the user still has a legacy auth.db inside the install directory,
|
||||
* copy it to the new location as a one-time migration.
|
||||
*/
|
||||
function migrateLegacyDatabase(targetPath: string): void {
|
||||
const legacyPath = resolveLegacyDatabasePath();
|
||||
|
||||
if (targetPath === legacyPath) return;
|
||||
if (fs.existsSync(targetPath)) return;
|
||||
if (!fs.existsSync(legacyPath)) return;
|
||||
|
||||
try {
|
||||
fs.copyFileSync(legacyPath, targetPath);
|
||||
logger.info('Migrated legacy database', { from: legacyPath, to: targetPath });
|
||||
|
||||
// copy the write-ahead log and shared memory files (auth.db-wal, auth.db-shm) if they exist, to preserve any uncommitted transactions
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
const src = legacyPath + suffix;
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, targetPath + suffix);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn('Could not migrate legacy database', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let instance: Database.Database | null = null;
|
||||
|
||||
/**
|
||||
* Returns the shared database connection, creating it on first call.
|
||||
*
|
||||
* The first invocation:
|
||||
* 1. Resolves the target database path
|
||||
* 2. Ensures the parent directory exists
|
||||
* 3. Migrates from the legacy install-directory path if needed
|
||||
* 4. Opens the SQLite connection
|
||||
* 5. Eagerly creates the app_config table (auth reads JWT secret at import time)
|
||||
* 6. Logs the database location
|
||||
*/
|
||||
export function getConnection(): Database.Database {
|
||||
if (instance) return instance;
|
||||
|
||||
const dbPath = resolveDatabasePath();
|
||||
|
||||
ensureDatabaseDirectory(dbPath);
|
||||
migrateLegacyDatabase(dbPath);
|
||||
|
||||
instance = new Database(dbPath);
|
||||
|
||||
// app_config must exist immediately — the auth middleware reads
|
||||
// the JWT secret at module-load time, before initializeDatabase() runs.
|
||||
instance.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved database file path without opening a connection.
|
||||
* Useful for diagnostics and CLI status commands.
|
||||
*/
|
||||
export function getDatabasePath(): string {
|
||||
return resolveDatabasePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection and clears the singleton.
|
||||
* Primarily used for graceful shutdown or testing.
|
||||
*/
|
||||
export function closeConnection(): void {
|
||||
if (instance) {
|
||||
instance.close();
|
||||
instance = null;
|
||||
logger.info('Database connection closed');
|
||||
}
|
||||
}
|
||||
18
server/src/shared/database/init-db.ts
Normal file
18
server/src/shared/database/init-db.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getConnection } from '@/shared/database/connection.js';
|
||||
import { runMigrations } from '@/shared/database/migrations.js';
|
||||
import { INIT_SCHEMA_SQL } from '@/shared/database/schema.js';
|
||||
import { logger } from '@/shared/utils/logger.js';
|
||||
|
||||
// Initialize database with schema
|
||||
export const initializeDatabase = async () => {
|
||||
try {
|
||||
const db = getConnection();
|
||||
db.exec(INIT_SCHEMA_SQL);
|
||||
logger.info('Database schema applied');
|
||||
runMigrations(db);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('Database initialization failed', { error: message });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
44
server/src/shared/database/migrations.ts
Normal file
44
server/src/shared/database/migrations.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Database } from "better-sqlite3";
|
||||
import { APP_CONFIG_TABLE_SCHEMA_SQL, SESSION_NAMES_TABLE_SCHEMA_SQL } from "@/shared/database/schema.js";
|
||||
import { logger } from "@/shared/utils/logger.js";
|
||||
|
||||
const addColumnToUsersTableIfNotExists = (
|
||||
db: Database,
|
||||
columnNames: string[],
|
||||
columnName: string,
|
||||
columnType: string,
|
||||
) => {
|
||||
if (!columnNames.includes(columnName)) {
|
||||
logger.info(
|
||||
`Running migration: Adding ${columnName} column to users table`,
|
||||
);
|
||||
db.exec(`ALTER TABLE users ADD COLUMN ${columnName} ${columnType}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const runMigrations = (db: Database) => {
|
||||
try {
|
||||
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
|
||||
const columnNames = tableInfo.map((col) => col.name);
|
||||
|
||||
addColumnToUsersTableIfNotExists(db, columnNames, "git_name", "TEXT");
|
||||
addColumnToUsersTableIfNotExists(db, columnNames, "git_email", "TEXT");
|
||||
addColumnToUsersTableIfNotExists(db, columnNames, "has_completed_onboarding", "BOOLEAN DEFAULT 0",
|
||||
);
|
||||
|
||||
// Create app_config table if it doesn't exist (for existing installations)
|
||||
db.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
|
||||
|
||||
// Create session_names table if it doesn't exist (for existing installations)
|
||||
db.exec(SESSION_NAMES_TABLE_SCHEMA_SQL);
|
||||
db.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)",
|
||||
);
|
||||
|
||||
logger.info("Database migrations completed successfully");
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("Error running migrations: ", error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
103
server/src/shared/database/repositories/api-keys.ts
Normal file
103
server/src/shared/database/repositories/api-keys.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* API keys repository.
|
||||
*
|
||||
* Manages API keys used for external/programmatic access to the backend.
|
||||
* Keys are prefixed with `ck_` and tied to a user via foreign key.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { getConnection } from '@/shared/database/connection.js';
|
||||
import type {
|
||||
ApiKeyRow,
|
||||
CreateApiKeyResult,
|
||||
ValidatedApiKeyUser,
|
||||
} from '@/shared/database/types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Generates a cryptographically random API key with the `ck_` prefix. */
|
||||
function generateApiKey(): string {
|
||||
return 'ck_' + crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const apiKeysDb = {
|
||||
generateApiKey,
|
||||
|
||||
/** Creates a new API key for the given user and returns it for one-time display. */
|
||||
createApiKey(userId: number, keyName: string): CreateApiKeyResult {
|
||||
const db = getConnection();
|
||||
const apiKey = generateApiKey();
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)'
|
||||
)
|
||||
.run(userId, keyName, apiKey);
|
||||
return { id: result.lastInsertRowid, keyName, apiKey };
|
||||
},
|
||||
|
||||
/** Lists all API keys for a user, most recent first. */
|
||||
getApiKeys(userId: number): ApiKeyRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC'
|
||||
)
|
||||
.all(userId) as ApiKeyRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Validates an API key and resolves the owning user.
|
||||
* If the key is valid, its `last_used` timestamp is updated as a side effect.
|
||||
* Returns undefined when the key is invalid or the user is inactive.
|
||||
*/
|
||||
validateApiKey(apiKey: string): ValidatedApiKeyUser | undefined {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.username, ak.id as api_key_id
|
||||
FROM api_keys ak
|
||||
JOIN users u ON ak.user_id = u.id
|
||||
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1`
|
||||
)
|
||||
.get(apiKey) as ValidatedApiKeyUser | undefined;
|
||||
|
||||
if (row) {
|
||||
db.prepare(
|
||||
'UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(row.api_key_id);
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
/** Permanently removes an API key. Returns true if a row was deleted. */
|
||||
deleteApiKey(userId: number, apiKeyId: number): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?')
|
||||
.run(apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/** Enables or disables an API key without deleting it. */
|
||||
toggleApiKey(
|
||||
userId: number,
|
||||
apiKeyId: number,
|
||||
isActive: boolean
|
||||
): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare(
|
||||
'UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?'
|
||||
)
|
||||
.run(isActive ? 1 : 0, apiKeyId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
};
|
||||
53
server/src/shared/database/repositories/app-config.ts
Normal file
53
server/src/shared/database/repositories/app-config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* App config repository.
|
||||
*
|
||||
* Key-value store for application-level configuration that persists
|
||||
* across restarts (JWT secret, feature flags, etc.). Values are always
|
||||
* stored as strings; callers handle parsing.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { getConnection } from '@/shared/database/connection.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const appConfigDb = {
|
||||
/** Returns the stored value for a config key, or null if missing. */
|
||||
get(key: string): string | null {
|
||||
try {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare('SELECT value FROM app_config WHERE key = ?')
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
} catch {
|
||||
// Swallow errors so early-startup reads (e.g. JWT secret) do not crash.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Inserts or updates a config key (upsert). */
|
||||
set(key: string, value: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run(key, value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the JWT signing secret, generating and persisting one
|
||||
* if it does not already exist. This ensures the secret survives
|
||||
* server restarts while being created automatically on first boot.
|
||||
*/
|
||||
getOrCreateJwtSecret(): string {
|
||||
let secret = appConfigDb.get('jwt_secret');
|
||||
if (!secret) {
|
||||
secret = crypto.randomBytes(64).toString('hex');
|
||||
appConfigDb.set('jwt_secret', secret);
|
||||
}
|
||||
return secret;
|
||||
},
|
||||
};
|
||||
106
server/src/shared/database/repositories/credentials.ts
Normal file
106
server/src/shared/database/repositories/credentials.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* User credentials repository.
|
||||
*
|
||||
* Manages external service tokens (GitHub, GitLab, Bitbucket, etc.)
|
||||
* stored per-user. Each credential has a type discriminator so multiple
|
||||
* credential kinds can coexist in the same table.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/shared/database/connection.js';
|
||||
import type {
|
||||
CreateCredentialResult,
|
||||
CredentialPublicRow,
|
||||
} from '@/shared/database/types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const credentialsDb = {
|
||||
/** Stores a new credential and returns a safe (no raw value) result. */
|
||||
createCredential(
|
||||
userId: number,
|
||||
credentialName: string,
|
||||
credentialType: string,
|
||||
credentialValue: string,
|
||||
description: string | null = null
|
||||
): CreateCredentialResult {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(userId, credentialName, credentialType, credentialValue, description);
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
credentialName,
|
||||
credentialType,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Lists credentials for a user (excluding raw values).
|
||||
* Optionally filters by credential type (e.g. 'github_token').
|
||||
*/
|
||||
getCredentials(
|
||||
userId: number,
|
||||
credentialType: string | null = null
|
||||
): CredentialPublicRow[] {
|
||||
const db = getConnection();
|
||||
|
||||
if (credentialType) {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? AND credential_type = ? ORDER BY created_at DESC'
|
||||
)
|
||||
.all(userId, credentialType) as CredentialPublicRow[];
|
||||
}
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? ORDER BY created_at DESC'
|
||||
)
|
||||
.all(userId) as CredentialPublicRow[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the raw credential value for the most recent active
|
||||
* credential of the given type, or null if none exists.
|
||||
*/
|
||||
getActiveCredential(
|
||||
userId: number,
|
||||
credentialType: string
|
||||
): string | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1'
|
||||
)
|
||||
.get(userId, credentialType) as { credential_value: string } | undefined;
|
||||
return row?.credential_value ?? null;
|
||||
},
|
||||
|
||||
/** Permanently removes a credential. Returns true if a row was deleted. */
|
||||
deleteCredential(userId: number, credentialId: number): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?')
|
||||
.run(credentialId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/** Enables or disables a credential without deleting it. */
|
||||
toggleCredential(
|
||||
userId: number,
|
||||
credentialId: number,
|
||||
isActive: boolean
|
||||
): boolean {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare(
|
||||
'UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?'
|
||||
)
|
||||
.run(isActive ? 1 : 0, credentialId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
};
|
||||
109
server/src/shared/database/repositories/session-names.ts
Normal file
109
server/src/shared/database/repositories/session-names.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Session names repository.
|
||||
*
|
||||
* Manages custom display names for provider sessions. When a user
|
||||
* renames a chat session in the UI, the override is stored here
|
||||
* and applied on top of the CLI-generated summary.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/shared/database/connection.js';
|
||||
import type {
|
||||
SessionNameLookupRow,
|
||||
SessionWithSummary,
|
||||
} from '@/shared/database/types.js';
|
||||
import { logger } from '@/shared/utils/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const sessionNamesDb = {
|
||||
/** Inserts or updates a custom session name (upsert on session_id + provider). */
|
||||
setName(sessionId: string, provider: string, customName: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`INSERT INTO session_names (session_id, provider, custom_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, provider)
|
||||
DO UPDATE SET custom_name = excluded.custom_name,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(sessionId, provider, customName);
|
||||
},
|
||||
|
||||
/** Returns the custom name for a single session, or null if unset. */
|
||||
getName(sessionId: string, provider: string): string | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
)
|
||||
.get(sessionId, provider) as { custom_name: string } | undefined;
|
||||
return row?.custom_name ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Batch lookup for multiple session IDs.
|
||||
* Returns a Map<sessionId, customName> for efficient overlay onto session lists.
|
||||
*/
|
||||
getNames(sessionIds: string[], provider: string): Map<string, string> {
|
||||
if (sessionIds.length === 0) return new Map();
|
||||
|
||||
const db = getConnection();
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT session_id, custom_name FROM session_names
|
||||
WHERE session_id IN (${placeholders}) AND provider = ?`
|
||||
)
|
||||
.all(...sessionIds, provider) as SessionNameLookupRow[];
|
||||
|
||||
return new Map(rows.map((r) => [r.session_id, r.custom_name]));
|
||||
},
|
||||
|
||||
/** Removes a custom session name. Returns true if a row was deleted. */
|
||||
deleteName(sessionId: string, provider: string): boolean {
|
||||
const db = getConnection();
|
||||
return (
|
||||
db
|
||||
.prepare(
|
||||
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
)
|
||||
.run(sessionId, provider).changes > 0
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session overlay helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overlays custom session names from the database onto a list of sessions.
|
||||
* Mutates each session's `summary` field in-place when a custom name exists.
|
||||
*
|
||||
* This is the typed equivalent of the legacy `applyCustomSessionNames` function.
|
||||
* Non-fatal: logs a warning on failure instead of throwing.
|
||||
*/
|
||||
export function applyCustomSessionNames(
|
||||
sessions: SessionWithSummary[] | undefined | null,
|
||||
provider: string
|
||||
): void {
|
||||
if (!sessions?.length) return;
|
||||
|
||||
try {
|
||||
const ids = sessions.map((s) => s.id);
|
||||
const customNames = sessionNamesDb.getNames(ids, provider);
|
||||
|
||||
for (const session of sessions) {
|
||||
const custom = customNames.get(session.id);
|
||||
if (custom) {
|
||||
session.summary = custom;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`Failed to apply custom session names for ${provider}`, {
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
123
server/src/shared/database/repositories/users.ts
Normal file
123
server/src/shared/database/repositories/users.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* User repository.
|
||||
*
|
||||
* Provides typed CRUD operations for the `users` table.
|
||||
* This is a single-user system, but the schema supports multiple
|
||||
* users for forward compatibility.
|
||||
*/
|
||||
|
||||
import { getConnection } from '@/shared/database/connection.js';
|
||||
import type {
|
||||
CreateUserResult,
|
||||
UserGitConfig,
|
||||
UserPublicRow,
|
||||
UserRow,
|
||||
} from '@/shared/database/types.js';
|
||||
import { logger } from '@/shared/utils/logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const userDb = {
|
||||
/** Returns true if at least one user exists in the database. */
|
||||
hasUsers(): boolean {
|
||||
const db = getConnection();
|
||||
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as {
|
||||
count: number;
|
||||
};
|
||||
return row.count > 0;
|
||||
},
|
||||
|
||||
/** Inserts a new user and returns the created ID + username. */
|
||||
createUser(username: string, passwordHash: string): CreateUserResult {
|
||||
const db = getConnection();
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||
.run(username, passwordHash);
|
||||
return { id: result.lastInsertRowid, username };
|
||||
},
|
||||
|
||||
/**
|
||||
* Looks up an active user by username.
|
||||
* Returns the full row (including password hash) for auth verification.
|
||||
*/
|
||||
getUserByUsername(username: string): UserRow | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1')
|
||||
.get(username) as UserRow | undefined;
|
||||
},
|
||||
|
||||
/** Updates the last_login timestamp. Non-fatal — logs but does not throw. */
|
||||
updateLastLogin(userId: number): void {
|
||||
try {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(userId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn('Failed to update last login', { error: message });
|
||||
}
|
||||
},
|
||||
|
||||
/** Returns public user fields by ID (no password hash). */
|
||||
getUserById(userId: number): UserPublicRow | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1'
|
||||
)
|
||||
.get(userId) as UserPublicRow | undefined;
|
||||
},
|
||||
|
||||
/** Returns the first active user. Used for single-user mode lookups. */
|
||||
getFirstUser(): UserPublicRow | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1'
|
||||
)
|
||||
.get() as UserPublicRow | undefined;
|
||||
},
|
||||
|
||||
/** Stores the user's preferred git name and email. */
|
||||
updateGitConfig(
|
||||
userId: number,
|
||||
gitName: string,
|
||||
gitEmail: string
|
||||
): void {
|
||||
const db = getConnection();
|
||||
db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?').run(
|
||||
gitName,
|
||||
gitEmail,
|
||||
userId
|
||||
);
|
||||
},
|
||||
|
||||
/** Retrieves the user's git identity (name + email). */
|
||||
getGitConfig(userId: number): UserGitConfig | undefined {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare('SELECT git_name, git_email FROM users WHERE id = ?')
|
||||
.get(userId) as UserGitConfig | undefined;
|
||||
},
|
||||
|
||||
/** Marks onboarding as complete for the given user. */
|
||||
completeOnboarding(userId: number): void {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
'UPDATE users SET has_completed_onboarding = 1 WHERE id = ?'
|
||||
).run(userId);
|
||||
},
|
||||
|
||||
/** Returns true if the user has finished the onboarding flow. */
|
||||
hasCompletedOnboarding(userId: number): boolean {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?')
|
||||
.get(userId) as { has_completed_onboarding: number } | undefined;
|
||||
return row?.has_completed_onboarding === 1;
|
||||
},
|
||||
};
|
||||
90
server/src/shared/database/schema.ts
Normal file
90
server/src/shared/database/schema.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
const USER_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
git_name TEXT,
|
||||
git_email TEXT,
|
||||
has_completed_onboarding BOOLEAN DEFAULT 0
|
||||
);
|
||||
`;
|
||||
|
||||
export const API_KEYS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
key_name TEXT NOT NULL,
|
||||
api_key TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const USER_CREDENTIALS_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS user_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
credential_name TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
|
||||
credential_value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
|
||||
export const SESSION_NAMES_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS session_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'claude',
|
||||
custom_name TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, provider)
|
||||
);
|
||||
`;
|
||||
|
||||
export const APP_CONFIG_TABLE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
|
||||
export const INIT_SCHEMA_SQL = `
|
||||
-- Initialize authentication database
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
${USER_TABLE_SCHEMA_SQL}
|
||||
-- Indexes for performance for user lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
|
||||
${API_KEYS_TABLE_SCHEMA_SQL}
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
||||
|
||||
${USER_CREDENTIALS_TABLE_SCHEMA_SQL}
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
|
||||
${SESSION_NAMES_TABLE_SCHEMA_SQL}
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
|
||||
|
||||
${APP_CONFIG_TABLE_SCHEMA_SQL}
|
||||
`;
|
||||
|
||||
127
server/src/shared/database/types.ts
Normal file
127
server/src/shared/database/types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Database entity types and operation result shapes.
|
||||
*
|
||||
* These types mirror the SQLite schema tables and provide type safety
|
||||
* for all repository operations. Row types represent what comes back
|
||||
* from SELECT queries; input types represent what goes into INSERT/UPDATE.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type UserRow = {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
is_active: number; // SQLite boolean: 0 | 1
|
||||
git_name: string | null;
|
||||
git_email: string | null;
|
||||
has_completed_onboarding: number; // SQLite boolean: 0 | 1
|
||||
};
|
||||
|
||||
/** Safe subset returned to callers that should never see the password hash. */
|
||||
export type UserPublicRow = Pick<
|
||||
UserRow,
|
||||
'id' | 'username' | 'created_at' | 'last_login'
|
||||
>;
|
||||
|
||||
export type UserGitConfig = {
|
||||
git_name: string | null;
|
||||
git_email: string | null;
|
||||
};
|
||||
|
||||
export type CreateUserResult = {
|
||||
id: number | bigint;
|
||||
username: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API Keys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ApiKeyRow = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
key_name: string;
|
||||
api_key: string;
|
||||
created_at: string;
|
||||
last_used: string | null;
|
||||
is_active: number; // SQLite boolean: 0 | 1
|
||||
};
|
||||
|
||||
/** Returned after creating a new API key (includes the raw key for one-time display). */
|
||||
export type CreateApiKeyResult = {
|
||||
id: number | bigint;
|
||||
keyName: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
/** Returned when an API key is validated and the owning user is resolved. */
|
||||
export type ValidatedApiKeyUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
api_key_id: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User Credentials (GitHub tokens, GitLab tokens, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CredentialRow = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
credential_name: string;
|
||||
credential_type: string;
|
||||
credential_value: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
is_active: number; // SQLite boolean: 0 | 1
|
||||
};
|
||||
|
||||
/** Safe subset that omits the raw credential value. */
|
||||
export type CredentialPublicRow = Omit<CredentialRow, 'credential_value' | 'user_id'>;
|
||||
|
||||
export type CreateCredentialResult = {
|
||||
id: number | bigint;
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session Names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SessionNameRow = {
|
||||
id: number;
|
||||
session_id: string;
|
||||
provider: string;
|
||||
custom_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** Minimal shape used in batch lookups. */
|
||||
export type SessionNameLookupRow = Pick<SessionNameRow, 'session_id' | 'custom_name'>;
|
||||
|
||||
/**
|
||||
* Any object that has an `id` and `summary` field.
|
||||
* Used by `applyCustomSessionNames` to overlay database names onto session lists.
|
||||
*/
|
||||
export type SessionWithSummary = {
|
||||
id: string;
|
||||
summary?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AppConfigRow = {
|
||||
key: string;
|
||||
value: string;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ApiErrorShape, ApiMeta, ApiSuccessShape } from '../types/http.js';
|
||||
import type {
|
||||
ApiErrorShape,
|
||||
ApiMeta,
|
||||
ApiSuccessShape,
|
||||
} from '@/shared/types/http.js';
|
||||
|
||||
export function createApiMeta(requestId?: string, startedAt?: string): ApiMeta {
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { AppError } from '../utils/app-error.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { createApiErrorResponse, createApiMeta } from './api-response.js';
|
||||
import { getRequestContext } from './request-context.js';
|
||||
import { createApiErrorResponse, createApiMeta } from '@/shared/http/api-response.js';
|
||||
import { getRequestContext } from '@/shared/http/request-context.js';
|
||||
import { AppError } from '@/shared/utils/app-error.js';
|
||||
import { logger } from '@/shared/utils/logger.js';
|
||||
|
||||
export function errorHandler(
|
||||
error: Error,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { createApiErrorResponse, createApiMeta } from './api-response.js';
|
||||
import { getRequestContext } from './request-context.js';
|
||||
import { createApiErrorResponse, createApiMeta } from '@/shared/http/api-response.js';
|
||||
import { getRequestContext } from '@/shared/http/request-context.js';
|
||||
|
||||
export function notFoundHandler(req: Request, res: Response): void {
|
||||
const context = getRequestContext(req);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import type { RequestContext } from '../types/http.js';
|
||||
import type { RequestContext } from '@/shared/types/http.js';
|
||||
|
||||
type RequestWithContext = Request & {
|
||||
context?: RequestContext;
|
||||
|
||||
Reference in New Issue
Block a user