From 695da128f38f486c004ccd808f7b3dce286114d1 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Fri, 13 Mar 2026 17:00:26 +0300 Subject: [PATCH] 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. --- package-lock.json | 196 ++++++++++++++++++ package.json | 4 +- server/routes/agent.js | 4 +- server/src/app.ts | 6 +- server/src/bootstrap.ts | 2 +- server/src/config/runtime.ts | 2 +- server/src/shared/database/connection.ts | 148 +++++++++++++ server/src/shared/database/init-db.ts | 18 ++ server/src/shared/database/migrations.ts | 44 ++++ .../shared/database/repositories/api-keys.ts | 103 +++++++++ .../database/repositories/app-config.ts | 53 +++++ .../database/repositories/credentials.ts | 106 ++++++++++ .../database/repositories/session-names.ts | 109 ++++++++++ .../src/shared/database/repositories/users.ts | 123 +++++++++++ server/src/shared/database/schema.ts | 90 ++++++++ server/src/shared/database/types.ts | 127 ++++++++++++ server/src/shared/http/api-response.ts | 6 +- server/src/shared/http/error-handler.ts | 8 +- server/src/shared/http/not-found-handler.ts | 4 +- server/src/shared/http/request-context.ts | 2 +- server/tsconfig.json | 4 + 21 files changed, 1144 insertions(+), 15 deletions(-) create mode 100644 server/src/shared/database/connection.ts create mode 100644 server/src/shared/database/init-db.ts create mode 100644 server/src/shared/database/migrations.ts create mode 100644 server/src/shared/database/repositories/api-keys.ts create mode 100644 server/src/shared/database/repositories/app-config.ts create mode 100644 server/src/shared/database/repositories/credentials.ts create mode 100644 server/src/shared/database/repositories/session-names.ts create mode 100644 server/src/shared/database/repositories/users.ts create mode 100644 server/src/shared/database/schema.ts create mode 100644 server/src/shared/database/types.ts diff --git a/package-lock.json b/package-lock.json index 6a7eb70c..92d416e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "@commitlint/config-conventional": "^20.4.3", "@eslint/js": "^9.39.3", "@release-it/conventional-changelog": "^10.0.5", + "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "^22.19.7", @@ -102,6 +103,7 @@ "release-it": "^19.0.5", "sharp": "^0.34.2", "tailwindcss": "^3.4.0", + "tsc-alias": "^1.8.16", "tsx": "^4.20.6", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", @@ -3736,6 +3738,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -4928,6 +4940,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -6786,6 +6808,19 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -8632,6 +8667,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -12139,6 +12195,20 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -13102,6 +13172,16 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -13164,6 +13244,19 @@ "pathe": "^2.0.3" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13525,6 +13618,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -15286,6 +15389,16 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -16592,6 +16705,89 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsc-alias/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/tsc-alias/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tsc-alias/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 5659cc77..8041afc4 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "dev": "concurrently --kill-others \"npm run server\" \"npm run client\"", "server:dev": "tsx watch server/src/bootstrap.ts", "server": "tsx server/src/bootstrap.ts", - "server:build": "tsc -p server/tsconfig.json", + "server:build": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json", "server:start": "node server/index.js", "client": "vite", "build": "vite build", @@ -116,6 +116,7 @@ "@commitlint/config-conventional": "^20.4.3", "@eslint/js": "^9.39.3", "@release-it/conventional-changelog": "^10.0.5", + "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "^22.19.7", @@ -141,6 +142,7 @@ "release-it": "^19.0.5", "sharp": "^0.34.2", "tailwindcss": "^3.4.0", + "tsc-alias": "^1.8.16", "tsx": "^4.20.6", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", diff --git a/server/routes/agent.js b/server/routes/agent.js index bf2d36de..cff26e6d 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -875,8 +875,9 @@ router.post('/', validateExternalApiKey, async (req, res) => { // Determine the final project path if (githubUrl) { // Clone repository (to projectPath if provided, otherwise generate path) + // TODO: use credinitalsDB when refactoring const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id); - + let targetPath; if (projectPath) { targetPath = projectPath; @@ -995,6 +996,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { console.log('🔄 Starting GitHub branch/PR creation workflow...'); // Get GitHub token + // TODO: use credinitalsDB when refactoring const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id); if (!tokenToUse) { diff --git a/server/src/app.ts b/server/src/app.ts index 8d9ec379..23f9a18b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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(); diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index e5e9719b..04361f38 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -1,4 +1,4 @@ -import { createServerApplication } from './app.js'; +import { createServerApplication } from '@/app.js'; async function startServerApplication(): Promise { const application = createServerApplication(); diff --git a/server/src/config/runtime.ts b/server/src/config/runtime.ts index bb11f63d..a5d6f71e 100644 --- a/server/src/config/runtime.ts +++ b/server/src/config/runtime.ts @@ -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); diff --git a/server/src/shared/database/connection.ts b/server/src/shared/database/connection.ts new file mode 100644 index 00000000..dd31faf0 --- /dev/null +++ b/server/src/shared/database/connection.ts @@ -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: /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'); + } +} diff --git a/server/src/shared/database/init-db.ts b/server/src/shared/database/init-db.ts new file mode 100644 index 00000000..6c9f2b45 --- /dev/null +++ b/server/src/shared/database/init-db.ts @@ -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; + } +}; diff --git a/server/src/shared/database/migrations.ts b/server/src/shared/database/migrations.ts new file mode 100644 index 00000000..40c35096 --- /dev/null +++ b/server/src/shared/database/migrations.ts @@ -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; + } +}; diff --git a/server/src/shared/database/repositories/api-keys.ts b/server/src/shared/database/repositories/api-keys.ts new file mode 100644 index 00000000..41cd64be --- /dev/null +++ b/server/src/shared/database/repositories/api-keys.ts @@ -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; + }, +}; diff --git a/server/src/shared/database/repositories/app-config.ts b/server/src/shared/database/repositories/app-config.ts new file mode 100644 index 00000000..62ed4857 --- /dev/null +++ b/server/src/shared/database/repositories/app-config.ts @@ -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; + }, +}; diff --git a/server/src/shared/database/repositories/credentials.ts b/server/src/shared/database/repositories/credentials.ts new file mode 100644 index 00000000..077cce4a --- /dev/null +++ b/server/src/shared/database/repositories/credentials.ts @@ -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; + }, +}; diff --git a/server/src/shared/database/repositories/session-names.ts b/server/src/shared/database/repositories/session-names.ts new file mode 100644 index 00000000..597b41b0 --- /dev/null +++ b/server/src/shared/database/repositories/session-names.ts @@ -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 for efficient overlay onto session lists. + */ + getNames(sessionIds: string[], provider: string): Map { + 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, + }); + } +} diff --git a/server/src/shared/database/repositories/users.ts b/server/src/shared/database/repositories/users.ts new file mode 100644 index 00000000..fd4c557d --- /dev/null +++ b/server/src/shared/database/repositories/users.ts @@ -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; + }, +}; diff --git a/server/src/shared/database/schema.ts b/server/src/shared/database/schema.ts new file mode 100644 index 00000000..76c8c5ec --- /dev/null +++ b/server/src/shared/database/schema.ts @@ -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} +`; + diff --git a/server/src/shared/database/types.ts b/server/src/shared/database/types.ts new file mode 100644 index 00000000..442fff3d --- /dev/null +++ b/server/src/shared/database/types.ts @@ -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; + +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; + +/** + * 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; +}; diff --git a/server/src/shared/http/api-response.ts b/server/src/shared/http/api-response.ts index c9c40f35..e3cab7b6 100644 --- a/server/src/shared/http/api-response.ts +++ b/server/src/shared/http/api-response.ts @@ -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 { diff --git a/server/src/shared/http/error-handler.ts b/server/src/shared/http/error-handler.ts index 9c50fe93..97a95de1 100644 --- a/server/src/shared/http/error-handler.ts +++ b/server/src/shared/http/error-handler.ts @@ -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, diff --git a/server/src/shared/http/not-found-handler.ts b/server/src/shared/http/not-found-handler.ts index dfa19834..09ed7876 100644 --- a/server/src/shared/http/not-found-handler.ts +++ b/server/src/shared/http/not-found-handler.ts @@ -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); diff --git a/server/src/shared/http/request-context.ts b/server/src/shared/http/request-context.ts index 2f1dfe69..28888ada 100644 --- a/server/src/shared/http/request-context.ts +++ b/server/src/shared/http/request-context.ts @@ -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; diff --git a/server/tsconfig.json b/server/tsconfig.json index afb85ecc..5e1ca68e 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -3,6 +3,10 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "baseUrl": "./src", + "paths": { + "@/*": ["./*"] + }, "rootDir": "./src", "outDir": "./dist", "strict": true,