draft: database layer refactor (#1054)

* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: start database layer refactor

* docs: plan database layer refactor

* docs: audit database layer refactor phase zero

* chore: add database runtime adapter skeleton

* chore: add settings repository skeleton

* chore: add user session repository skeleton

* chore: add host credential repository skeleton

* chore: add field encryption boundary

* chore: migrate settings route slice

* chore: migrate user settings routes

* chore: migrate host metrics settings routes

* chore: migrate acme settings route

* chore: migrate terminal settings route

* chore: migrate tailscale settings read

* chore: migrate guacamole settings reads

* chore: migrate session timeout settings reads

* chore: migrate auth route settings reads

* chore: migrate host metrics settings reads

* chore: migrate startup settings reads

* chore: migrate user settings cleanup

* chore: migrate password reset settings

* chore: migrate oidc legacy settings read

* chore: migrate user route settings slice

* chore: migrate oidc state settings

* chore: migrate user login settings reads

* chore: migrate user crypto settings

* chore: consolidate startup settings defaults

* chore: consolidate database settings import export

* chore: migrate core session auth paths

* chore: migrate remaining session auth paths

* chore: migrate admin user routes

* chore: migrate user route admin checks

* chore: migrate user lifecycle routes

* chore: migrate auth user lookups

* chore: migrate oidc user routes

* chore: migrate api key repository paths

* docs: add database gray rollout guide

* chore: migrate trusted device paths

* chore: migrate user session route user lookups

* chore: add database repository rollout guard

* chore: expose repository rollout status

* chore: warn on repository rollout misconfiguration

* chore: migrate remaining user lookup helpers

* chore: migrate ssh user lookups

* chore: migrate user settings admin lookups

* chore: migrate acme ssl user lookups

* chore: migrate audit log admin checks

* chore: migrate oidc account user updates

* chore: migrate password reset user updates

* chore: migrate user deletion core records

* chore: migrate snippet audit user lookups

* chore: migrate ldap user sync paths

* chore: migrate totp user updates

* chore: migrate rbac user checks

* chore: migrate rbac role paths

* chore: migrate permission role lookups

* chore: migrate rbac access list reads

* chore: migrate shared rbac reads

* chore: migrate rbac access writes

* chore: migrate permission host access

* chore: migrate role host access lookup

* chore: migrate snippet access lookup

* chore: migrate shared credential access lookups

* chore: migrate host access cleanup writes

* chore: migrate host list access checks

* chore: migrate host access cleanup routes

* chore: migrate shared credential role lookups

* chore: migrate user role cleanup

* chore: migrate admin role sync

* chore: migrate ldap role sync

* chore: migrate user role assignment

* chore: migrate sso provider access

* chore: migrate audit log access

* chore: migrate user preference access

* chore: migrate open tab access

* chore: migrate dismissed alert access

* chore: migrate homepage layout access

* chore: migrate network topology access

* chore: migrate dashboard service link access

* chore: migrate command history access

* chore: migrate recent activity cleanup

* chore: migrate ssh credential usage access

* chore: migrate transfer recent access

* chore: migrate file manager bookmark access

* chore: migrate c2s tunnel preset access

* chore: migrate homepage item access

* chore: migrate session recording access

* chore: migrate tmux session tag access

* chore: migrate opkssh token access

* chore: migrate vault token access

* chore: migrate vault profile access

* chore: migrate host metrics preference access

* chore: migrate host health access

* chore: migrate host metrics history access

* chore: migrate alert persistence access

* chore: route alert host lookup through repository

* chore: migrate user data export reads

* chore: route host metrics stats sync through repository

* chore: migrate host folder persistence

* chore: migrate host resolution reads

* chore: route jump host resolution reads

* chore: route docker console jump host reads

* chore: route docker ssh resolution reads

* chore: route proxmox discovery resolution reads

* chore: route file manager activity host reads

* chore: route host metrics resolution reads

* chore: route ssh auth credential reads

* chore: route tunnel endpoint credential reads

* chore: route credential deployment resolution reads

* chore: route command history host flag reads

* chore: route snippet execution resolution reads

* chore: route terminal host resolution reads

* chore: route vault oidc host resolution reads

* chore: route wake on lan host reads

* chore: route internal host list reads

* chore: route host key verification persistence

* chore: route credential read paths

* chore: route credential host usage reads

* chore: route credential folder rename

* chore: route host owner access checks

* chore: route shared credential source reads

* chore: route user host credential cleanup

* chore: route credential delete reads

* chore: route credential update reads

* chore: route host credential reads

* chore: route host read paths

* chore: route host projection reads

* chore: route host list reads

* chore: route snippet read paths

* chore: route snippet folder writes

* chore: route snippet crud paths

* chore: route snippet bulk import

* chore: route rbac ownership reads

* chore: route user count reads

* chore: route cleanup snippets folders

* chore: route shared credential persistence

* chore: route dashboard activity

* chore: route guacamole host reads

* chore: route host bulk lookups

* chore: remove unlock-only simple db ops

* chore: route host autostart persistence

* chore: route ldap provisioning through users

* chore: route credential encrypted writes

* chore: route host encrypted writes

* chore: route bulk host encrypted writes

* chore: route termix id credentials

* chore: route termix id ca persistence

* chore: route termix identity persistence

* chore: route credential system migration

* chore: isolate user encryption migration storage

* chore: remove legacy simple db ops

* chore: isolate legacy sqlite migration copy

* chore: route database settings import export

* chore: route database host credential export

* chore: route database host credential import

* chore: route database file-manager import export

* chore: route database alert usage import export

* chore: route database user checks

* chore: isolate auth lazy migration storage

* chore: route explicit database saves

* chore: initialize database save boundary

* chore: route migration snapshot saves

* chore: isolate sqlite import constraints

* chore: route import sqlite boundary

* chore: route user encryption migration store

* chore: centralize current repository runtime

* chore: route more current repositories

* chore: route activity repository runtimes

* chore: route token repository runtimes

* chore: route health repository runtimes

* chore: route identity repository runtimes

* chore: route rbac repository runtime

* chore: centralize current sqlite runtime access

* chore: route user deletion key cleanup

* chore: route user deletion vault cleanup

* chore: route user deletion homepage cleanup

* chore: route user deletion health cleanup

* chore: route user deletion alert cleanup

* chore: route user deletion identity cleanup

* chore: add database layer preupgrade backup

* Fix database repository type errors

* fix: complete post-merge compile fixes for database refactor

Restore missing DatabaseSaveTrigger/getDb imports, session log format
fallback, OIDC provider resolution, guacamole recording insert, and
passwordFallbackOnly typing after merging current dev.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
This commit is contained in:
ZacharyZcR
2026-07-16 12:28:10 +08:00
committed by GitHub
parent ed6cda4ea4
commit 1e7c9ea53c
229 changed files with 24327 additions and 8382 deletions
+97 -301
View File
@@ -3,14 +3,20 @@ import crypto from "crypto";
import { UserCrypto } from "./user-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
import { databaseLogger, authLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import { db } from "../database/db/index.js";
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
import { eq, and, inArray, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { and, eq, inArray } from "drizzle-orm";
import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
import { createCurrentSettingsRepository } from "../database/repositories/current-settings-repository.js";
import { createCurrentSessionRepository } from "../database/repositories/current-session-repository.js";
import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js";
import { createCurrentApiKeyRepository } from "../database/repositories/current-api-key-repository.js";
import { createCurrentTrustedDeviceRepository } from "../database/repositories/current-trusted-device-repository.js";
interface AuthenticationResult {
success: boolean;
@@ -191,20 +197,7 @@ class AuthManager {
return;
}
const { getSqlite, saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
const sqlite = getSqlite();
const migrationResult = await DataCrypto.migrateUserSensitiveFields(
userId,
userDataKey,
sqlite,
);
if (migrationResult.migrated) {
await saveMemoryDatabaseToFile();
}
await DataCrypto.migrateCurrentUserSensitiveFields(userId, userDataKey);
try {
const { CredentialSystemEncryptionMigration } =
@@ -213,7 +206,9 @@ class AuthManager {
const credResult = await credMigration.migrateUserCredentials(userId);
if (credResult.migrated > 0) {
await saveMemoryDatabaseToFile();
await DatabaseSaveTrigger.forceSave(
"login_credential_migration_explicit_save",
);
}
} catch (error) {
databaseLogger.warn("Credential migration failed during login", {
@@ -353,10 +348,10 @@ class AuthManager {
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
const timeoutRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
.get() as { value: string } | undefined;
const defaultExpiry = `${timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24}h`;
const timeoutValue = await createCurrentSettingsRepository().get(
"session_timeout_hours",
);
const defaultExpiry = `${timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24}h`;
let expiresIn = options.expiresIn;
if (!expiresIn && !options.pendingTOTP) {
@@ -389,7 +384,7 @@ class AuthManager {
const createdAt = now.toISOString();
try {
await db.insert(sessions).values({
await createCurrentSessionRepository().create({
id: sessionId,
userId,
jwtToken: token,
@@ -402,21 +397,6 @@ class AuthManager {
expiresAt,
lastActiveAt: createdAt,
});
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session creation",
saveError,
{
operation: "session_create_db_save_failed",
sessionId,
},
);
}
} catch (error) {
databaseLogger.error("Failed to create session", error, {
operation: "session_create_failed",
@@ -461,13 +441,11 @@ class AuthManager {
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
const sessionRecord = await createCurrentSessionRepository().findById(
payload.sessionId,
);
if (sessionRecords.length === 0) {
if (!sessionRecord) {
databaseLogger.warn("Session not found during JWT verification", {
operation: "jwt_verify_session_not_found",
sessionId: payload.sessionId,
@@ -478,7 +456,7 @@ class AuthManager {
await this.restoreDataKeyFromPayload(
payload,
sessionRecords[0].expiresAt,
sessionRecord.expiresAt,
);
} catch (dbError) {
databaseLogger.error(
@@ -509,17 +487,14 @@ class AuthManager {
userId: string,
sessionId: string,
): Promise<{ token: string; maxAge: number } | null> {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
const sessionRecord =
await createCurrentSessionRepository().findById(sessionId);
if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) {
if (!sessionRecord || sessionRecord.userId !== userId) {
return null;
}
const expiresAt = new Date(sessionRecords[0].expiresAt).getTime();
const expiresAt = new Date(sessionRecord.expiresAt).getTime();
const maxAge = expiresAt - Date.now();
if (!Number.isFinite(maxAge) || maxAge <= 0) {
return null;
@@ -532,28 +507,7 @@ class AuthManager {
expiresIn: Math.ceil(maxAge / 1000),
} as jwt.SignOptions);
await db
.update(sessions)
.set({
jwtToken: token,
lastActiveAt: new Date().toISOString(),
})
.where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session token refresh",
saveError,
{
operation: "session_token_refresh_db_save_failed",
sessionId,
},
);
}
await createCurrentSessionRepository().updateToken(sessionId, token);
return { token, maxAge };
}
@@ -573,22 +527,7 @@ class AuthManager {
sessionId,
});
await db.delete(sessions).where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session revocation",
saveError,
{
operation: "session_revoke_db_save_failed",
sessionId,
},
);
}
await createCurrentSessionRepository().revoke(sessionId);
return true;
} catch (error) {
@@ -605,10 +544,8 @@ class AuthManager {
exceptSessionId?: string,
): Promise<number> {
try {
const userSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
const sessionRepository = createCurrentSessionRepository();
const userSessions = await sessionRepository.listByUserId(userId);
const deletedCount = userSessions.filter(
(s) => !exceptSessionId || s.id !== exceptSessionId,
@@ -620,33 +557,7 @@ class AuthManager {
sessionCount: deletedCount,
});
if (exceptSessionId) {
await db
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
sql`${sessions.id} != ${exceptSessionId}`,
),
);
} else {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after revoking all user sessions",
saveError,
{
operation: "user_sessions_revoke_db_save_failed",
userId,
},
);
}
await sessionRepository.revokeAllForUser(userId, exceptSessionId);
return deletedCount;
} catch (error) {
@@ -673,6 +584,7 @@ class AuthManager {
if (ssoProviderId != null)
conditions.push(eq(sessions.ssoProviderId, ssoProviderId));
const db = getDb();
const matched = await db
.select()
.from(sessions)
@@ -716,10 +628,9 @@ class AuthManager {
async cleanupExpiredSessions(): Promise<number> {
try {
const expiredSessions = await db
.select()
.from(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
const sessionRepository = createCurrentSessionRepository();
const now = new Date();
const expiredSessions = await sessionRepository.listExpired(now);
const expiredCount = expiredSessions.length;
@@ -727,30 +638,11 @@ class AuthManager {
return 0;
}
await db
.delete(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`);
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after cleaning up expired sessions",
saveError,
{
operation: "sessions_cleanup_db_save_failed",
},
);
}
await sessionRepository.deleteExpired(now);
const affectedUsers = new Set(expiredSessions.map((s) => s.userId));
for (const userId of affectedUsers) {
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
const remainingSessions = await sessionRepository.listByUserId(userId);
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(userId);
@@ -768,8 +660,7 @@ class AuthManager {
async getAllSessions(): Promise<Record<string, unknown>[]> {
try {
const allSessions = await db.select().from(sessions);
return allSessions;
return createCurrentSessionRepository().listAll();
} catch (error) {
databaseLogger.error("Failed to get all sessions", error, {
operation: "sessions_get_all_failed",
@@ -780,11 +671,7 @@ class AuthManager {
async getUserSessions(userId: string): Promise<Record<string, unknown>[]> {
try {
const userSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
return userSessions;
return createCurrentSessionRepository().listByUserId(userId);
} catch (error) {
databaseLogger.error("Failed to get user sessions", error, {
operation: "sessions_get_user_failed",
@@ -825,13 +712,10 @@ class AuthManager {
): Promise<void> {
try {
const tokenPrefix = token.substring(0, 12);
const apiKeyRepository = createCurrentApiKeyRepository();
const candidates = await db
.select()
.from(apiKeys)
.where(
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
);
const candidates =
await apiKeyRepository.listActiveByTokenPrefix(tokenPrefix);
if (candidates.length === 0) {
res.status(401).json({ error: "Invalid API key" });
@@ -857,21 +741,17 @@ class AuthManager {
}
if (requireAdmin) {
const { users } = await import("../database/db/schema.js");
const userRows = await db
.select()
.from(users)
.where(eq(users.id, matchedKey.userId))
.limit(1);
if (!userRows[0]?.isAdmin) {
const user = await createCurrentUserRepository().findById(
matchedKey.userId,
);
if (!user?.isAdmin) {
res.status(403).json({ error: "Admin access required" });
return;
}
}
db.update(apiKeys)
.set({ lastUsedAt: new Date().toISOString() })
.where(eq(apiKeys.id, matchedKey.id))
apiKeyRepository
.updateLastUsedAt(matchedKey.id, new Date().toISOString())
.then(() => {})
.catch((err) => {
databaseLogger.warn("Failed to update API key lastUsedAt", {
@@ -898,13 +778,10 @@ class AuthManager {
!this.userCrypto.isUserUnlocked(matchedKey.userId)
) {
try {
const { users } = await import("../database/db/schema.js");
const oidcRows = await db
.select()
.from(users)
.where(eq(users.id, matchedKey.userId))
.limit(1);
if (oidcRows[0]?.isOidc) {
const keyUser = await createCurrentUserRepository().findById(
matchedKey.userId,
);
if (keyUser?.isOidc) {
await this.authenticateOIDCUser(matchedKey.userId);
}
} catch (err) {
@@ -965,13 +842,10 @@ class AuthManager {
if (payload.sessionId) {
try {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, payload.sessionId))
.limit(1);
const sessionRepository = createCurrentSessionRepository();
const session = await sessionRepository.findById(payload.sessionId);
if (sessionRecords.length === 0) {
if (!session) {
databaseLogger.warn("Session not found in middleware", {
operation: "middleware_session_not_found",
sessionId: payload.sessionId,
@@ -986,8 +860,6 @@ class AuthManager {
});
}
const session = sessionRecords[0];
const sessionExpiryTime = new Date(session.expiresAt).getTime();
const currentTime = Date.now();
const isExpired = sessionExpiryTime < currentTime;
@@ -1002,18 +874,12 @@ class AuthManager {
difference: currentTime - sessionExpiryTime,
});
db.delete(sessions)
.where(eq(sessions.id, payload.sessionId))
sessionRepository
.revoke(payload.sessionId)
.then(async () => {
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, payload.userId));
const remainingSessions =
await sessionRepository.listByUserId(payload.userId);
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(payload.userId);
@@ -1049,9 +915,8 @@ class AuthManager {
});
}
db.update(sessions)
.set({ lastActiveAt: new Date().toISOString() })
.where(eq(sessions.id, payload.sessionId))
sessionRepository
.touch(payload.sessionId)
.then(() => {})
.catch((error) => {
databaseLogger.warn("Failed to update session lastActiveAt", {
@@ -1132,16 +997,11 @@ class AuthManager {
}
try {
const { db } = await import("../database/db/index.js");
const { users } = await import("../database/db/schema.js");
const { eq } = await import("drizzle-orm");
const user = await createCurrentUserRepository().findById(
payload.userId,
);
const user = await db
.select()
.from(users)
.where(eq(users.id, payload.userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
if (!user?.isAdmin) {
databaseLogger.warn(
"Non-admin user attempted to access admin endpoint",
{
@@ -1171,30 +1031,13 @@ class AuthManager {
}
async logoutUser(userId: string, sessionId?: string): Promise<void> {
const sessionRepository = createCurrentSessionRepository();
if (sessionId) {
try {
await db.delete(sessions).where(eq(sessions.id, sessionId));
await sessionRepository.revoke(sessionId);
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after logout",
saveError,
{
operation: "logout_db_save_failed",
userId,
sessionId,
},
);
}
const remainingSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
const remainingSessions = await sessionRepository.listByUserId(userId);
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(userId);
@@ -1210,15 +1053,7 @@ class AuthManager {
}
} else {
try {
await db.delete(sessions).where(eq(sessions.userId, userId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch {
// best effort
}
await sessionRepository.revokeAllForUser(userId);
} catch (error) {
databaseLogger.error("Failed to revoke all sessions on logout", error, {
operation: "session_revoke_all_failed",
@@ -1264,38 +1099,29 @@ class AuthManager {
deviceFingerprint: string,
): Promise<boolean> {
try {
const device = await db
.select()
.from(trustedDevices)
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
)
.limit(1);
const trustedDeviceRepository = createCurrentTrustedDeviceRepository();
const device = await trustedDeviceRepository.findByUserAndFingerprint(
userId,
deviceFingerprint,
);
if (!device || device.length === 0) {
if (!device) {
return false;
}
const now = new Date();
const expiresAt = new Date(device[0].expiresAt);
const expiresAt = new Date(device.expiresAt);
if (now > expiresAt) {
await this.removeTrustedDevice(userId, deviceFingerprint);
return false;
}
await db
.update(trustedDevices)
.set({ lastUsedAt: now.toISOString() })
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
);
await trustedDeviceRepository.touch(
userId,
deviceFingerprint,
now.toISOString(),
);
return true;
} catch (error) {
@@ -1313,56 +1139,26 @@ class AuthManager {
const now = new Date();
const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const existingDevice = await db
.select()
.from(trustedDevices)
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
)
.limit(1);
if (existingDevice && existingDevice.length > 0) {
await db
.update(trustedDevices)
.set({
expiresAt: expiresAt.toISOString(),
lastUsedAt: now.toISOString(),
})
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
);
} else {
await db.insert(trustedDevices).values({
id: nanoid(),
userId,
deviceFingerprint,
deviceType,
deviceInfo,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
lastUsedAt: now.toISOString(),
});
}
await createCurrentTrustedDeviceRepository().upsert({
id: nanoid(),
userId,
deviceFingerprint,
deviceType,
deviceInfo,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
lastUsedAt: now.toISOString(),
});
}
async removeTrustedDevice(
userId: string,
deviceFingerprint: string,
): Promise<void> {
await db
.delete(trustedDevices)
.where(
and(
eq(trustedDevices.userId, userId),
eq(trustedDevices.deviceFingerprint, deviceFingerprint),
),
);
await createCurrentTrustedDeviceRepository().deleteByUserAndFingerprint(
userId,
deviceFingerprint,
);
}
}