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:
Haileyesus
2026-03-13 17:00:26 +03:00
parent e67738c9fc
commit 695da128f3
21 changed files with 1144 additions and 15 deletions

View File

@@ -1,8 +1,8 @@
import { pathToFileURL } from 'url';
import { getRuntimePaths } from './config/runtime.js';
import type { ServerApplication } from './shared/types/app.js';
import { logger } from './shared/utils/logger.js';
import { getRuntimePaths } from '@/config/runtime.js';
import type { ServerApplication } from '@/shared/types/app.js';
import { logger } from '@/shared/utils/logger.js';
export function createServerApplication(): ServerApplication {
const runtimePaths = getRuntimePaths();

View File

@@ -1,4 +1,4 @@
import { createServerApplication } from './app.js';
import { createServerApplication } from '@/app.js';
async function startServerApplication(): Promise<void> {
const application = createServerApplication();

View File

@@ -1,7 +1,7 @@
import path from 'path';
import { fileURLToPath } from 'url';
import type { RuntimePaths } from '../shared/types/app.js';
import type { RuntimePaths } from '@/shared/types/app.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

View 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');
}
}

View 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;
}
};

View 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;
}
};

View 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;
},
};

View 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;
},
};

View 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;
},
};

View 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,
});
}
}

View 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;
},
};

View 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}
`;

View 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;
};

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;