From 1e7c9ea53c90dcd8dc27c733193fb3e2458da920 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 16 Jul 2026 12:28:10 +0800 Subject: [PATCH] draft: database layer refactor (#1054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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] 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] 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] 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] 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 * 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] Co-authored-by: DivByZero Co-authored-by: LukeGus 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 Co-authored-by: sash --- readme/DATABASE-LAYER-GRAY-ROLLOUT.md | 447 +++++++ readme/DATABASE-LAYER-PHASE-0-AUDIT.md | 548 ++++++++ readme/DATABASE-LAYER-REFACTOR-PLAN.md | 1187 +++++++++++++++++ src/backend/dashboard.ts | 140 +- src/backend/database/database.ts | 685 +++++----- src/backend/database/db/index.ts | 127 +- .../repositories/alert-repository.test.ts | 383 ++++++ .../database/repositories/alert-repository.ts | 622 +++++++++ .../repositories/api-key-repository.test.ts | 149 +++ .../repositories/api-key-repository.ts | 103 ++ .../repositories/audit-log-repository.test.ts | 136 ++ .../repositories/audit-log-repository.ts | 135 ++ .../c2s-tunnel-preset-repository.test.ts | 126 ++ .../c2s-tunnel-preset-repository.ts | 136 ++ .../command-history-repository.test.ts | 102 ++ .../command-history-repository.ts | 161 +++ .../repositories/credential-repository.ts | 360 +++++ .../repositories/current-alert-repository.ts | 15 + .../current-api-key-repository.ts | 15 + .../current-audit-log-repository.ts | 15 + .../current-c2s-tunnel-preset-repository.ts | 15 + .../current-command-history-repository.ts | 15 + .../current-credential-repository.ts | 15 + ...rrent-dashboard-service-link-repository.ts | 15 + .../current-dismissed-alert-repository.ts | 15 + ...urrent-file-manager-bookmark-repository.ts | 15 + .../current-homepage-item-repository.ts | 15 + .../current-homepage-layout-repository.ts | 15 + .../current-host-folder-repository.ts | 15 + .../current-host-health-repository.ts | 15 + ...current-host-metrics-history-repository.ts | 15 + ...rent-host-metrics-preference-repository.ts | 17 + .../repositories/current-host-repository.ts | 15 + .../current-host-resolution-repository.ts | 15 + .../current-network-topology-repository.ts | 15 + .../current-open-tab-repository.ts | 15 + .../current-opkssh-token-repository.ts | 15 + .../current-rbac-access-repository.ts | 15 + .../current-recent-activity-repository.ts | 15 + .../current-repository-runtime.ts | 21 + .../repositories/current-role-repository.ts | 15 + .../current-session-recording-repository.ts | 15 + .../current-session-repository.ts | 15 + .../current-settings-repository.ts | 26 + .../current-shared-credential-repository.ts | 15 + .../current-snippet-repository.ts | 15 + ...current-ssh-credential-usage-repository.ts | 15 + .../current-sso-provider-repository.ts | 15 + .../current-termix-identity-ca-repository.ts | 15 + .../current-termix-identity-repository.ts | 15 + .../current-tmux-session-tag-repository.ts | 15 + .../current-transfer-recent-repository.ts | 15 + .../current-trusted-device-repository.ts | 15 + .../current-user-data-export-repository.ts | 9 + .../current-user-preference-repository.ts | 15 + .../repositories/current-user-repository.ts | 15 + .../current-vault-profile-repository.ts | 15 + .../current-vault-token-repository.ts | 15 + .../dashboard-service-link-repository.test.ts | 139 ++ .../dashboard-service-link-repository.ts | 129 ++ .../dismissed-alert-repository.test.ts | 112 ++ .../dismissed-alert-repository.ts | 108 ++ .../field-encryption-boundary.test.ts | 105 ++ .../repositories/field-encryption-boundary.ts | 161 +++ .../file-manager-bookmark-repository.test.ts | 236 ++++ .../file-manager-bookmark-repository.ts | 533 ++++++++ .../homepage-item-repository.test.ts | 150 +++ .../repositories/homepage-item-repository.ts | 114 ++ .../homepage-layout-repository.test.ts | 104 ++ .../homepage-layout-repository.ts | 64 + .../host-credential-repositories.test.ts | 757 +++++++++++ .../host-folder-repository.test.ts | 280 ++++ .../repositories/host-folder-repository.ts | 168 +++ .../host-health-repository.test.ts | 192 +++ .../repositories/host-health-repository.ts | 164 +++ .../host-metrics-history-repository.test.ts | 97 ++ .../host-metrics-history-repository.ts | 64 + ...host-metrics-preference-repository.test.ts | 145 ++ .../host-metrics-preference-repository.ts | 97 ++ .../database/repositories/host-repository.ts | 239 ++++ .../host-resolution-repository.test.ts | 501 +++++++ .../host-resolution-repository.ts | 354 +++++ .../network-topology-repository.test.ts | 93 ++ .../network-topology-repository.ts | 63 + .../repositories/open-tab-repository.test.ts | 150 +++ .../repositories/open-tab-repository.ts | 169 +++ .../opkssh-token-repository.test.ts | 137 ++ .../repositories/opkssh-token-repository.ts | 125 ++ .../rbac-access-repository.test.ts | 509 +++++++ .../repositories/rbac-access-repository.ts | 677 ++++++++++ .../recent-activity-repository.test.ts | 131 ++ .../recent-activity-repository.ts | 112 ++ .../repositories/repository-rollout.test.ts | 219 +++ .../repositories/repository-rollout.ts | 381 ++++++ .../repositories/role-repository.test.ts | 282 ++++ .../database/repositories/role-repository.ts | 276 ++++ .../session-recording-repository.test.ts | 171 +++ .../session-recording-repository.ts | 239 ++++ .../repositories/session-repository.ts | 114 ++ .../repositories/settings-repository.test.ts | 95 ++ .../repositories/settings-repository.ts | 69 + .../shared-credential-repository.test.ts | 151 +++ .../shared-credential-repository.ts | 142 ++ .../repositories/snippet-repository.test.ts | 383 ++++++ .../repositories/snippet-repository.ts | 540 ++++++++ .../ssh-credential-usage-repository.test.ts | 109 ++ .../ssh-credential-usage-repository.ts | 79 ++ .../sso-provider-repository.test.ts | 132 ++ .../repositories/sso-provider-repository.ts | 119 ++ .../termix-identity-ca-repository.test.ts | 278 ++++ .../termix-identity-ca-repository.ts | 163 +++ .../termix-identity-repository.test.ts | 206 +++ .../termix-identity-repository.ts | 244 ++++ .../tmux-session-tag-repository.test.ts | 130 ++ .../tmux-session-tag-repository.ts | 139 ++ .../transfer-recent-repository.test.ts | 145 ++ .../transfer-recent-repository.ts | 171 +++ .../trusted-device-repository.test.ts | 168 +++ .../repositories/trusted-device-repository.ts | 100 ++ .../user-data-export-repository.test.ts | 187 +++ .../user-data-export-repository.ts | 26 + .../user-preference-repository.test.ts | 113 ++ .../user-preference-repository.ts | 67 + .../database/repositories/user-repository.ts | 161 +++ .../user-session-repositories.test.ts | 298 +++++ .../vault-profile-repository.test.ts | 146 ++ .../repositories/vault-profile-repository.ts | 130 ++ .../vault-token-repository.test.ts | 135 ++ .../repositories/vault-token-repository.ts | 125 ++ .../database/routes/acme-ssl-routes.ts | 93 +- .../database/routes/alert-rules-routes.ts | 445 ++---- src/backend/database/routes/alerts.ts | 55 +- .../database/routes/audit-log-routes.ts | 79 +- .../database/routes/c2s-tunnel-presets.ts | 106 +- .../routes/credential-deploy-routes.ts | 217 ++- src/backend/database/routes/credentials.ts | 320 ++--- .../routes/dashboard-service-links-routes.ts | 116 +- .../database/routes/delete-user-data.ts | 144 +- .../database/routes/homepage-items-routes.ts | 68 +- .../database/routes/homepage-layout-routes.ts | 41 +- .../database/routes/host-autostart-routes.ts | 76 +- .../database/routes/host-bulk-routes.ts | 134 +- .../routes/host-command-history-routes.ts | 39 +- .../host-file-manager-bookmark-routes.ts | 169 +-- .../database/routes/host-folder-routes.ts | 213 +-- .../database/routes/host-internal-routes.ts | 15 +- .../database/routes/host-network-routes.ts | 18 +- src/backend/database/routes/host.ts | 787 ++--------- .../database/routes/ldap-auth-routes.ts | 151 +-- .../database/routes/network-topology.ts | 36 +- src/backend/database/routes/open-tabs.ts | 110 +- src/backend/database/routes/rbac.ts | 655 +++------ .../database/routes/session-log-routes.ts | 118 +- src/backend/database/routes/snippets.ts | 624 ++------- .../database/routes/sso-provider-routes.ts | 98 +- .../database/routes/tailscale-routes.ts | 10 +- src/backend/database/routes/terminal.ts | 133 +- src/backend/database/routes/termix-id.test.ts | 40 +- src/backend/database/routes/termix-id.ts | 446 +++---- .../database/routes/user-admin-routes.ts | 327 ++--- .../database/routes/user-api-key-routes.ts | 78 +- .../routes/user-oidc-account-routes.ts | 136 +- .../database/routes/user-oidc-utils.ts | 39 +- .../routes/user-password-reset-routes.ts | 195 +-- .../database/routes/user-preferences.ts | 45 +- .../database/routes/user-session-routes.ts | 104 +- .../database/routes/user-settings-routes.ts | 207 ++- .../database/routes/user-totp-routes.test.ts | 20 +- .../database/routes/user-totp-routes.ts | 154 +-- src/backend/database/routes/users.ts | 753 ++++------- src/backend/database/routes/vault.ts | 97 +- src/backend/database/runtime/adapter.ts | 31 + src/backend/database/runtime/config.test.ts | 57 + src/backend/database/runtime/config.ts | 58 + .../database/runtime/sqlite-adapter.test.ts | 55 + .../database/runtime/sqlite-adapter.ts | 92 ++ .../sqlite-foreign-key-boundary.test.ts | 28 + .../runtime/sqlite-foreign-key-boundary.ts | 23 + src/backend/guacamole/guacamole-server.ts | 9 +- src/backend/guacamole/routes.ts | 92 +- src/backend/ssh/alert-engine.ts | 173 +-- src/backend/ssh/auth-manager.ts | 26 +- src/backend/ssh/credential-username.test.ts | 32 +- src/backend/ssh/credential-username.ts | 16 +- src/backend/ssh/docker-console.ts | 36 +- .../ssh/file-manager-content-routes.ts | 371 ++---- src/backend/ssh/file-manager.ts | 66 +- src/backend/ssh/host-key-verifier.ts | 60 +- .../ssh/host-metrics-history-routes.ts | 23 +- src/backend/ssh/host-metrics-jump-hosts.ts | 34 +- .../ssh/host-metrics-preferences-routes.ts | 50 +- .../ssh/host-metrics-settings-routes.ts | 125 +- src/backend/ssh/host-metrics-viewer-routes.ts | 8 +- src/backend/ssh/host-metrics.ts | 186 +-- src/backend/ssh/host-resolver.ts | 83 +- src/backend/ssh/jump-host-chain.ts | 34 +- src/backend/ssh/managers/health.ts | 103 +- src/backend/ssh/opkssh-auth.ts | 88 +- src/backend/ssh/terminal-agent-auth.test.ts | 34 +- src/backend/ssh/terminal-auth-helpers.ts | 16 +- src/backend/ssh/terminal-jump-hosts.ts | 34 +- .../ssh/terminal-session-manager.test.ts | 32 +- src/backend/ssh/terminal-session-manager.ts | 50 +- src/backend/ssh/terminal.ts | 149 +-- src/backend/ssh/tmux-monitor.ts | 91 +- src/backend/ssh/tunnel.ts | 26 +- src/backend/ssh/vault-oidc-auth.ts | 30 +- src/backend/ssh/vault-signer-auth.ts | 60 +- src/backend/starter.ts | 35 +- src/backend/utils/audit-logger.test.ts | 67 +- src/backend/utils/audit-logger.ts | 23 +- .../utils/auth-manager-oidc-logout.test.ts | 4 +- src/backend/utils/auth-manager.ts | 398 ++---- ...ential-system-encryption-migration.test.ts | 85 ++ .../credential-system-encryption-migration.ts | 41 +- src/backend/utils/data-crypto.ts | 186 +-- .../database-layer-preupgrade-backup.test.ts | 144 ++ .../utils/database-layer-preupgrade-backup.ts | 201 +++ src/backend/utils/database-migration.ts | 227 +--- .../utils/database-save-trigger.test.ts | 41 + src/backend/utils/lazy-field-encryption.ts | 31 +- .../legacy-sqlite-database-copy-store.test.ts | 71 + .../legacy-sqlite-database-copy-store.ts | 169 +++ src/backend/utils/permission-manager.ts | 116 +- .../utils/shared-credential-manager.ts | 237 ++-- src/backend/utils/user-crypto.ts | 126 +- src/backend/utils/user-data-export.ts | 70 +- .../user-encryption-migration-store.test.ts | 108 ++ .../utils/user-encryption-migration-store.ts | 150 +++ 229 files changed, 24327 insertions(+), 8382 deletions(-) create mode 100644 readme/DATABASE-LAYER-GRAY-ROLLOUT.md create mode 100644 readme/DATABASE-LAYER-PHASE-0-AUDIT.md create mode 100644 readme/DATABASE-LAYER-REFACTOR-PLAN.md create mode 100644 src/backend/database/repositories/alert-repository.test.ts create mode 100644 src/backend/database/repositories/alert-repository.ts create mode 100644 src/backend/database/repositories/api-key-repository.test.ts create mode 100644 src/backend/database/repositories/api-key-repository.ts create mode 100644 src/backend/database/repositories/audit-log-repository.test.ts create mode 100644 src/backend/database/repositories/audit-log-repository.ts create mode 100644 src/backend/database/repositories/c2s-tunnel-preset-repository.test.ts create mode 100644 src/backend/database/repositories/c2s-tunnel-preset-repository.ts create mode 100644 src/backend/database/repositories/command-history-repository.test.ts create mode 100644 src/backend/database/repositories/command-history-repository.ts create mode 100644 src/backend/database/repositories/credential-repository.ts create mode 100644 src/backend/database/repositories/current-alert-repository.ts create mode 100644 src/backend/database/repositories/current-api-key-repository.ts create mode 100644 src/backend/database/repositories/current-audit-log-repository.ts create mode 100644 src/backend/database/repositories/current-c2s-tunnel-preset-repository.ts create mode 100644 src/backend/database/repositories/current-command-history-repository.ts create mode 100644 src/backend/database/repositories/current-credential-repository.ts create mode 100644 src/backend/database/repositories/current-dashboard-service-link-repository.ts create mode 100644 src/backend/database/repositories/current-dismissed-alert-repository.ts create mode 100644 src/backend/database/repositories/current-file-manager-bookmark-repository.ts create mode 100644 src/backend/database/repositories/current-homepage-item-repository.ts create mode 100644 src/backend/database/repositories/current-homepage-layout-repository.ts create mode 100644 src/backend/database/repositories/current-host-folder-repository.ts create mode 100644 src/backend/database/repositories/current-host-health-repository.ts create mode 100644 src/backend/database/repositories/current-host-metrics-history-repository.ts create mode 100644 src/backend/database/repositories/current-host-metrics-preference-repository.ts create mode 100644 src/backend/database/repositories/current-host-repository.ts create mode 100644 src/backend/database/repositories/current-host-resolution-repository.ts create mode 100644 src/backend/database/repositories/current-network-topology-repository.ts create mode 100644 src/backend/database/repositories/current-open-tab-repository.ts create mode 100644 src/backend/database/repositories/current-opkssh-token-repository.ts create mode 100644 src/backend/database/repositories/current-rbac-access-repository.ts create mode 100644 src/backend/database/repositories/current-recent-activity-repository.ts create mode 100644 src/backend/database/repositories/current-repository-runtime.ts create mode 100644 src/backend/database/repositories/current-role-repository.ts create mode 100644 src/backend/database/repositories/current-session-recording-repository.ts create mode 100644 src/backend/database/repositories/current-session-repository.ts create mode 100644 src/backend/database/repositories/current-settings-repository.ts create mode 100644 src/backend/database/repositories/current-shared-credential-repository.ts create mode 100644 src/backend/database/repositories/current-snippet-repository.ts create mode 100644 src/backend/database/repositories/current-ssh-credential-usage-repository.ts create mode 100644 src/backend/database/repositories/current-sso-provider-repository.ts create mode 100644 src/backend/database/repositories/current-termix-identity-ca-repository.ts create mode 100644 src/backend/database/repositories/current-termix-identity-repository.ts create mode 100644 src/backend/database/repositories/current-tmux-session-tag-repository.ts create mode 100644 src/backend/database/repositories/current-transfer-recent-repository.ts create mode 100644 src/backend/database/repositories/current-trusted-device-repository.ts create mode 100644 src/backend/database/repositories/current-user-data-export-repository.ts create mode 100644 src/backend/database/repositories/current-user-preference-repository.ts create mode 100644 src/backend/database/repositories/current-user-repository.ts create mode 100644 src/backend/database/repositories/current-vault-profile-repository.ts create mode 100644 src/backend/database/repositories/current-vault-token-repository.ts create mode 100644 src/backend/database/repositories/dashboard-service-link-repository.test.ts create mode 100644 src/backend/database/repositories/dashboard-service-link-repository.ts create mode 100644 src/backend/database/repositories/dismissed-alert-repository.test.ts create mode 100644 src/backend/database/repositories/dismissed-alert-repository.ts create mode 100644 src/backend/database/repositories/field-encryption-boundary.test.ts create mode 100644 src/backend/database/repositories/field-encryption-boundary.ts create mode 100644 src/backend/database/repositories/file-manager-bookmark-repository.test.ts create mode 100644 src/backend/database/repositories/file-manager-bookmark-repository.ts create mode 100644 src/backend/database/repositories/homepage-item-repository.test.ts create mode 100644 src/backend/database/repositories/homepage-item-repository.ts create mode 100644 src/backend/database/repositories/homepage-layout-repository.test.ts create mode 100644 src/backend/database/repositories/homepage-layout-repository.ts create mode 100644 src/backend/database/repositories/host-credential-repositories.test.ts create mode 100644 src/backend/database/repositories/host-folder-repository.test.ts create mode 100644 src/backend/database/repositories/host-folder-repository.ts create mode 100644 src/backend/database/repositories/host-health-repository.test.ts create mode 100644 src/backend/database/repositories/host-health-repository.ts create mode 100644 src/backend/database/repositories/host-metrics-history-repository.test.ts create mode 100644 src/backend/database/repositories/host-metrics-history-repository.ts create mode 100644 src/backend/database/repositories/host-metrics-preference-repository.test.ts create mode 100644 src/backend/database/repositories/host-metrics-preference-repository.ts create mode 100644 src/backend/database/repositories/host-repository.ts create mode 100644 src/backend/database/repositories/host-resolution-repository.test.ts create mode 100644 src/backend/database/repositories/host-resolution-repository.ts create mode 100644 src/backend/database/repositories/network-topology-repository.test.ts create mode 100644 src/backend/database/repositories/network-topology-repository.ts create mode 100644 src/backend/database/repositories/open-tab-repository.test.ts create mode 100644 src/backend/database/repositories/open-tab-repository.ts create mode 100644 src/backend/database/repositories/opkssh-token-repository.test.ts create mode 100644 src/backend/database/repositories/opkssh-token-repository.ts create mode 100644 src/backend/database/repositories/rbac-access-repository.test.ts create mode 100644 src/backend/database/repositories/rbac-access-repository.ts create mode 100644 src/backend/database/repositories/recent-activity-repository.test.ts create mode 100644 src/backend/database/repositories/recent-activity-repository.ts create mode 100644 src/backend/database/repositories/repository-rollout.test.ts create mode 100644 src/backend/database/repositories/repository-rollout.ts create mode 100644 src/backend/database/repositories/role-repository.test.ts create mode 100644 src/backend/database/repositories/role-repository.ts create mode 100644 src/backend/database/repositories/session-recording-repository.test.ts create mode 100644 src/backend/database/repositories/session-recording-repository.ts create mode 100644 src/backend/database/repositories/session-repository.ts create mode 100644 src/backend/database/repositories/settings-repository.test.ts create mode 100644 src/backend/database/repositories/settings-repository.ts create mode 100644 src/backend/database/repositories/shared-credential-repository.test.ts create mode 100644 src/backend/database/repositories/shared-credential-repository.ts create mode 100644 src/backend/database/repositories/snippet-repository.test.ts create mode 100644 src/backend/database/repositories/snippet-repository.ts create mode 100644 src/backend/database/repositories/ssh-credential-usage-repository.test.ts create mode 100644 src/backend/database/repositories/ssh-credential-usage-repository.ts create mode 100644 src/backend/database/repositories/sso-provider-repository.test.ts create mode 100644 src/backend/database/repositories/sso-provider-repository.ts create mode 100644 src/backend/database/repositories/termix-identity-ca-repository.test.ts create mode 100644 src/backend/database/repositories/termix-identity-ca-repository.ts create mode 100644 src/backend/database/repositories/termix-identity-repository.test.ts create mode 100644 src/backend/database/repositories/termix-identity-repository.ts create mode 100644 src/backend/database/repositories/tmux-session-tag-repository.test.ts create mode 100644 src/backend/database/repositories/tmux-session-tag-repository.ts create mode 100644 src/backend/database/repositories/transfer-recent-repository.test.ts create mode 100644 src/backend/database/repositories/transfer-recent-repository.ts create mode 100644 src/backend/database/repositories/trusted-device-repository.test.ts create mode 100644 src/backend/database/repositories/trusted-device-repository.ts create mode 100644 src/backend/database/repositories/user-data-export-repository.test.ts create mode 100644 src/backend/database/repositories/user-data-export-repository.ts create mode 100644 src/backend/database/repositories/user-preference-repository.test.ts create mode 100644 src/backend/database/repositories/user-preference-repository.ts create mode 100644 src/backend/database/repositories/user-repository.ts create mode 100644 src/backend/database/repositories/user-session-repositories.test.ts create mode 100644 src/backend/database/repositories/vault-profile-repository.test.ts create mode 100644 src/backend/database/repositories/vault-profile-repository.ts create mode 100644 src/backend/database/repositories/vault-token-repository.test.ts create mode 100644 src/backend/database/repositories/vault-token-repository.ts create mode 100644 src/backend/database/runtime/adapter.ts create mode 100644 src/backend/database/runtime/config.test.ts create mode 100644 src/backend/database/runtime/config.ts create mode 100644 src/backend/database/runtime/sqlite-adapter.test.ts create mode 100644 src/backend/database/runtime/sqlite-adapter.ts create mode 100644 src/backend/database/runtime/sqlite-foreign-key-boundary.test.ts create mode 100644 src/backend/database/runtime/sqlite-foreign-key-boundary.ts create mode 100644 src/backend/utils/credential-system-encryption-migration.test.ts create mode 100644 src/backend/utils/database-layer-preupgrade-backup.test.ts create mode 100644 src/backend/utils/database-layer-preupgrade-backup.ts create mode 100644 src/backend/utils/database-save-trigger.test.ts create mode 100644 src/backend/utils/legacy-sqlite-database-copy-store.test.ts create mode 100644 src/backend/utils/legacy-sqlite-database-copy-store.ts create mode 100644 src/backend/utils/user-encryption-migration-store.test.ts create mode 100644 src/backend/utils/user-encryption-migration-store.ts diff --git a/readme/DATABASE-LAYER-GRAY-ROLLOUT.md b/readme/DATABASE-LAYER-GRAY-ROLLOUT.md new file mode 100644 index 00000000..1c6e18b4 --- /dev/null +++ b/readme/DATABASE-LAYER-GRAY-ROLLOUT.md @@ -0,0 +1,447 @@ +# Database Layer Gray Rollout Guide + +Status: Draft branch gray-rollout guide +Branch: `feature/database-layer-refactor` +Scope: repository-boundary migration for current SQLite snapshot runtime + +This branch is not the full multi-database refactor. Gray rollout is only for +the already-migrated repository boundary paths while the production runtime still +uses the existing encrypted SQLite snapshot model. + +## 1. Gray Scope + +Allowed in gray rollout: + +- Existing encrypted SQLite snapshot runtime. +- Existing database file format. +- Existing schema and migration flow. +- Settings, user, host, API key, session, trusted device, audit log, user + preference, open tab, dismissed alert, homepage layout/item, and network + topology, dashboard service link, session recording, command history, recent + activity, transfer recent, and file-manager bookmark current repository + plus C2S tunnel preset, tmux session tag, OPKSSH token, Vault token/profile, + SSH credential usage, role, SSO provider, host folder, alert, host health, + host metrics preference/history, credential, host resolution, snippet, + RBAC access, shared credential, Termix ID identity/CA, and user data export + current repository factories share `current-repository-runtime` for SQLite + context and write-save hooks. +- Settings reads and writes migrated behind `SettingsRepository`. +- Database import/export settings reads and admin upserts migrated behind + `SettingsRepository`. +- Session create/read/update/revoke/list paths migrated behind + `SessionRepository`. +- User create/read/update/delete/auth paths migrated behind `UserRepository`. +- User setup/count/db-health, password-login TOTP guard, and last-admin delete + guard reads migrated behind `UserRepository`. +- Database import/export user unlock/admin checks migrated behind + `UserRepository`. +- LDAP first-user provisioning and admin-group user creation migrated behind + `UserRepository`. +- API key create/list/delete and authentication last-used updates migrated behind + `ApiKeyRepository`. +- Trusted device check/add/remove and TOTP trusted-device cleanup migrated + behind `TrustedDeviceRepository`. +- Credential list, folder list, detail reads, update lookup/readback, host route + credential resolution reads, folder rename writes, apply usage writes, shared + credential source row reads, credential delete lookup/delete, and + credential-host list reads migrated behind `CredentialRepository` and + `HostResolutionRepository`. +- Database export host and credential read/decryption paths migrated behind + `HostRepository` and `CredentialRepository`. +- Database import host and credential duplicate checks plus encrypted creates + migrated behind `HostRepository` and `CredentialRepository`. +- Credential create/update encrypted writes migrated behind + `CredentialRepository`, including system-key copies for shared credentials. +- Credential delete host cleanup and apply-to-host writes migrated behind + `HostRepository`. +- Credential system-key copy backfill migration migrated behind + `CredentialRepository` and `SharedCredentialRepository`. +- Legacy user field-encryption migration SQL centralized behind + `RawSqliteUserEncryptionMigrationStore`. +- Auth login lazy user-field encryption migration now calls the `DataCrypto` + current-runtime migration boundary instead of opening SQLite in + `auth-manager.ts`. +- Current user-field encryption migration now resolves SQLite through + `createCurrentUserEncryptionMigrationStore` and the shared current runtime + helper, keeping `DataCrypto` on the migration-store interface. +- User, admin, TOTP, OIDC account, and credential migration explicit saves now + use `DatabaseSaveTrigger` instead of importing the SQLite snapshot save + function from routes. +- Current SQLite snapshot save trigger now initializes after database startup + regardless of file-encryption mode, and backend shutdown uses that save + boundary. +- Direct SQLite snapshot save-function imports are now isolated inside + `database/db/index.ts`; current user-field migration saves through + `DatabaseSaveTrigger`. +- Database import SQLite foreign-key toggling is isolated behind the + `withSqliteForeignKeysDisabled` runtime boundary and restores constraints in + a `finally` path; the current variant resolves SQLite through the shared + current runtime helper. +- Database import now uses the current SQLite foreign-key boundary without + importing `getDb()` in the route handler. +- Legacy unencrypted SQLite copy/verification path centralized behind + `LegacySqliteDatabaseCopyStore`. +- Termix ID credential lookup, generated credential persistence, and generated + credential cleanup migrated behind `CredentialRepository`. +- Host route update readback, single-host fetch, password-field fetch, and host + export reads migrated behind `HostResolutionRepository`. +- Host route create/update encrypted writes migrated behind `HostRepository`. +- Host route update-state and delete-audit host reads migrated behind + `HostResolutionRepository`. +- Host route delete final host-row writes and audit actor username lookups + migrated behind `HostRepository` and `UserRepository`. +- Host route own/shared list assembly reads migrated behind + `HostResolutionRepository` while preserving route-level own-host decryption. +- Host bulk-update state reads and non-sensitive bulk flag/config writes + migrated behind `HostRepository`. +- Host bulk import overwrite lookup and credential fallback reads migrated behind + current host resolution and credential repository boundaries. +- Host bulk JSON and SSH-config import encrypted create/update writes migrated + behind `HostRepository`. +- Host autostart enable, disable, status, and endpoint-host resolution paths + migrated behind `HostRepository`. +- Guacamole host token host and protocol credential reads migrated behind + `HostResolutionRepository`. +- Host user-cleanup delete paths migrated behind `HostRepository`. +- Snippet folder list/create/metadata/rename/delete, owned lookup, visible-list + owned reads, reorder, create/update/delete, export reads, and bulk import + plus user/password-reset cleanup migrated behind `SnippetRepository`. +- Host folder metadata user cleanup migrated behind `HostFolderRepository`. +- SSO provider listing, management, OIDC config loading, and LDAP provider + validation migrated behind `SsoProviderRepository`. +- Audit log writes, filtered reads, action lists, and user cleanup migrated + behind `AuditLogRepository`. +- User preferences read/write and user cleanup migrated behind + `UserPreferenceRepository`. +- Open tab restore/upsert/sync/update/delete and user cleanup migrated behind + `OpenTabRepository`. +- Dismissed alert read/dismiss/undismiss/export and user cleanup migrated behind + `DismissedAlertRepository`. +- Database import/export dismissed alert reads and writes migrated behind + `DismissedAlertRepository`. +- Homepage layout read/write paths migrated behind + `HomepageLayoutRepository`. +- Homepage item list/create/update/delete migrated behind + `HomepageItemRepository`. +- Network topology read/write and user cleanup migrated behind + `NetworkTopologyRepository`. +- Dashboard service link list/create/update/delete migrated behind + `DashboardServiceLinkRepository`. +- Session recording create/list/read/content/delete/prune and host/folder/user + cleanup migrated behind `SessionRecordingRepository`. +- Command history save/list/delete and host/user cleanup migrated behind + `CommandHistoryRepository`. +- Dashboard recent activity list/log/trim/reset and host/user cleanup migrated + behind `RecentActivityRepository`. +- SSH credential usage writes and host/user cleanup migrated behind + `SshCredentialUsageRepository`. +- Database export SSH credential usage reads migrated behind + `SshCredentialUsageRepository`. +- Transfer recent list/upsert/prune/export and host/folder/user cleanup migrated + behind `TransferRecentRepository`. +- File manager recent/pinned/shortcut list/create/delete/export and + host/folder/user/password-reset cleanup migrated behind + `FileManagerBookmarkRepository`. +- Database import/export file-manager recent/pinned/shortcut target reads and + writes migrated behind `FileManagerBookmarkRepository`. +- C2S tunnel preset list/create/update/delete migrated behind + `C2sTunnelPresetRepository`. +- Tmux session tag list/rename/delete/replace migrated behind + `TmuxSessionTagRepository`. +- OPKSSH token upsert/read/touch/delete and user cleanup migrated behind + `OpksshTokenRepository`. +- Vault token upsert/read/touch/delete migrated behind `VaultTokenRepository`. +- Vault profile list/create/update/delete and profile lookup migrated behind + `VaultProfileRepository`. +- Host metrics layout preference read/upsert and statsConfig widget sync migrated behind + `HostMetricsPreferenceRepository`. +- Host health check config and history read/write migrated behind + `HostHealthRepository`. +- Host metrics history record/prune/query migrated behind + `HostMetricsHistoryRepository`. +- Alert notification channels, rules, linked channels, firings, and alert + engine persistence reads/writes migrated behind `AlertRepository`. +- User data export host and credential read models migrated behind + `UserDataExportRepository`. +- SSH folder list, metadata upsert, rename, and folder host deletion writes + migrated behind `HostFolderRepository`. +- Host, jump-host, Docker SSH, Proxmox discovery, Docker console jump-host, + file-manager activity, host metrics, terminal SSH auth, and tunnel endpoint + credential plus credential deployment and command history host-flag resolution + plus snippet execution, terminal OPKSSH/activity, Vault OIDC profile, and + Wake-on-LAN host, internal host list, host-key verification metadata, + credential, permission-manager owner checks, and shared override read/write + models migrated behind `HostResolutionRepository`. +- Host metrics, host metrics viewer, tmux monitor, Docker, tunnel, and Proxmox + unlock gates now use `DataCrypto` directly instead of `SimpleDBOps`. +- Legacy `SimpleDBOps` compatibility helper removed after migrated route and + utility paths stopped importing it. +- RBAC role management and user-role assignment/listing paths migrated behind + `RoleRepository`. +- Permission manager role permission aggregation and admin-role checks migrated + behind `RoleRepository`. +- User deletion role-assignment cleanup migrated behind `RoleRepository`. +- User deletion API key, trusted device, dashboard link, homepage layout/item, + host health, host metrics preference, C2S preset, Vault token/profile, audit, + alert, Termix ID identity/CA, tmux session tag, encrypted-data, UI state, and + related per-user cleanup now uses current repository boundaries before the + final user row delete. +- User admin route role sync and admin-created default role assignment migrated + behind `RoleRepository`. +- LDAP login default role assignment and admin-role sync migrated behind + `RoleRepository`. +- Local, GitHub OIDC, and standard OIDC user default role assignment plus OIDC + admin-group role sync migrated behind `RoleRepository`. +- RBAC host/snippet access-list read models migrated behind + `RbacAccessRepository`. +- RBAC shared host/shared snippet read models migrated behind + `RbacAccessRepository`. +- RBAC route host/snippet owner checks and direct host-access credential + existence reads migrated behind current host, snippet, and credential + repository boundaries. +- RBAC host/snippet access grant, revoke, and direct host-access credential + override writes migrated behind `RbacAccessRepository`. +- Shared credential material create/update/delete, pending re-encryption, and + user cleanup persistence migrated behind `SharedCredentialRepository`. +- Permission manager host-access cleanup, shared-access lookup, and last-access + touch migrated behind `RbacAccessRepository`. +- Termix ID identity handle CRUD/resolution, public key publish/list/update/delete, + linked credential lookup, and certificate target key lookup migrated behind + `TermixIdentityRepository`. +- Termix ID CA public lookup, encrypted private-key create/rotate/delete, and + certificate signing reads migrated behind `TermixIdentityCaRepository`. +- Current field encryption behavior. + +Not included in gray rollout: + +- PostgreSQL or MySQL/MariaDB runtime. +- New external database configuration UI. +- New schema migration strategy. +- Host and credential route migration beyond existing repository skeletons. +- Remaining audit, preferences, file manager, and metrics repository migration. +- Multi-instance backend deployment. + +## 2. Required Preflight + +Before enabling this branch for any gray target: + +1. Confirm the target is running from a full backup of the data directory. +2. Confirm the app can write to the data directory so the automatic + pre-upgrade backup can be created before first database startup. +3. Record the exact source commit and container/image tag currently serving + traffic. +4. Confirm the gray build commit is known. +5. Run the validation commands from this branch. +6. Use a target with a small, known user set first. +7. Keep an operator available for rollback during the first login/API-key smoke + tests. + +Repository rollout is controlled by `DATABASE_LAYER_REPOSITORY_ROLLOUT`. + +Recommended gray value: + +```bash +DATABASE_LAYER_REPOSITORY_ROLLOUT=settings,users,sessions,api_keys,trusted_devices,credentials,termix_identity,termix_identity_ca,hosts,snippets,sso_providers,audit_logs,user_preferences,open_tabs,dismissed_alerts,homepage_layouts,homepage_items,network_topology,dashboard_service_links,session_recordings,command_history,recent_activity,ssh_credential_usage,transfer_recent,file_manager_bookmarks,c2s_tunnel_presets,tmux_session_tags,opkssh_tokens,vault_tokens,vault_profiles,host_metrics_preferences,host_health,host_metrics_history,alerts,user_data_exports,host_folders,host_resolution,roles,rbac_access,shared_credentials +``` + +Accepted values: + +| Value | Behavior | +| ------------------------------------------- | --------------------------------------------- | +| unset | current migrated slice enabled; logs implicit | +| `all`, `true`, `1`, `on`, `enabled` | all current migrated repository domains | +| `off`, `false`, `0`, `none`, `disabled` | no migrated repository domains; fail closed | +| comma list, for example `settings,users` | only listed repository domains enabled | +| aliases such as `user`, `api-key`, `apikey` | normalized to the supported domain names | + +Supported domains: + +- `settings` +- `users` +- `sessions` +- `api_keys` +- `trusted_devices` +- `credentials` +- `termix_identity` +- `termix_identity_ca` +- `hosts` +- `snippets` +- `sso_providers` +- `audit_logs` +- `user_preferences` +- `open_tabs` +- `dismissed_alerts` +- `homepage_layouts` +- `homepage_items` +- `network_topology` +- `dashboard_service_links` +- `session_recordings` +- `command_history` +- `recent_activity` +- `ssh_credential_usage` +- `transfer_recent` +- `file_manager_bookmarks` +- `c2s_tunnel_presets` +- `tmux_session_tags` +- `opkssh_tokens` +- `vault_tokens` +- `vault_profiles` +- `host_metrics_preferences` +- `host_health` +- `host_metrics_history` +- `alerts` +- `user_data_exports` +- `host_folders` +- `host_resolution` +- `roles` +- `rbac_access` +- `shared_credentials` + +The backend logs the parsed rollout mode at startup with operation +`repository_rollout_config`. Use an explicit value in any staging or production +gray target so logs show the intended rollout state. + +Admin users can also verify the active rollout state through +`GET /database/migration/status`. The response includes `repositoryRollout` +with the parsed mode, enabled domains, supported domains, env key, and whether +the value was explicitly configured. Startup logs and this endpoint also expose +rollout warnings for implicit, disabled, or partial configurations. + +Minimum backup artifacts: + +- encrypted SQLite snapshot file +- environment configuration +- application version or image tag +- logs from the last healthy startup on the previous version + +This branch also creates one automatic pre-upgrade backup before opening the +database for the first time. If `db.sqlite.encrypted`, `db.sqlite.encrypted.meta`, +or `db.sqlite` exists and `.database-layer-preupgrade-backup.json` is absent, the +backend copies the database file(s) plus `.env` into: + +```text +/backups/pre-database-layer-refactor-/ +``` + +The backup directory and marker both include a `manifest.json` payload with the +app version, rollout config, source files, copied files, and backup reason. A +successful marker prevents repeat backups on every restart. If the backup cannot +be created, startup fails closed. Operators may bypass only when an external +backup has already been verified: + +```bash +DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP=1 +``` + +The app keeps the latest three automatic pre-upgrade backups by default. Override +with `DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP=`. + +## 3. Required Validation Commands + +Run before deployment: + +```bash +npm run type-check +npx eslint src/backend/database/repositories/repository-rollout.ts src/backend/database/repositories/repository-rollout.test.ts src/backend/database/repositories/current-settings-repository.ts src/backend/database/repositories/current-user-repository.ts src/backend/database/repositories/current-session-repository.ts src/backend/database/repositories/current-api-key-repository.ts src/backend/database/repositories/current-trusted-device-repository.ts src/backend/database/repositories/current-credential-repository.ts src/backend/database/repositories/credential-repository.ts src/backend/database/repositories/current-host-repository.ts src/backend/database/repositories/host-repository.ts src/backend/database/repositories/host-credential-repositories.test.ts src/backend/database/repositories/current-sso-provider-repository.ts src/backend/database/repositories/sso-provider-repository.ts src/backend/database/repositories/sso-provider-repository.test.ts src/backend/database/repositories/current-audit-log-repository.ts src/backend/database/repositories/audit-log-repository.ts src/backend/database/repositories/audit-log-repository.test.ts src/backend/database/repositories/current-user-preference-repository.ts src/backend/database/repositories/user-preference-repository.ts src/backend/database/repositories/user-preference-repository.test.ts src/backend/database/repositories/current-open-tab-repository.ts src/backend/database/repositories/open-tab-repository.ts src/backend/database/repositories/open-tab-repository.test.ts src/backend/database/repositories/current-dismissed-alert-repository.ts src/backend/database/repositories/dismissed-alert-repository.ts src/backend/database/repositories/dismissed-alert-repository.test.ts src/backend/database/repositories/current-homepage-layout-repository.ts src/backend/database/repositories/homepage-layout-repository.ts src/backend/database/repositories/homepage-layout-repository.test.ts src/backend/database/repositories/current-homepage-item-repository.ts src/backend/database/repositories/homepage-item-repository.ts src/backend/database/repositories/homepage-item-repository.test.ts src/backend/database/repositories/current-network-topology-repository.ts src/backend/database/repositories/network-topology-repository.ts src/backend/database/repositories/network-topology-repository.test.ts src/backend/database/repositories/current-dashboard-service-link-repository.ts src/backend/database/repositories/dashboard-service-link-repository.ts src/backend/database/repositories/dashboard-service-link-repository.test.ts src/backend/database/repositories/current-session-recording-repository.ts src/backend/database/repositories/session-recording-repository.ts src/backend/database/repositories/session-recording-repository.test.ts src/backend/database/repositories/current-command-history-repository.ts src/backend/database/repositories/command-history-repository.ts src/backend/database/repositories/command-history-repository.test.ts src/backend/database/repositories/current-recent-activity-repository.ts src/backend/database/repositories/recent-activity-repository.ts src/backend/database/repositories/recent-activity-repository.test.ts src/backend/database/repositories/current-ssh-credential-usage-repository.ts src/backend/database/repositories/ssh-credential-usage-repository.ts src/backend/database/repositories/ssh-credential-usage-repository.test.ts src/backend/database/repositories/current-transfer-recent-repository.ts src/backend/database/repositories/transfer-recent-repository.ts src/backend/database/repositories/transfer-recent-repository.test.ts src/backend/database/repositories/current-file-manager-bookmark-repository.ts src/backend/database/repositories/file-manager-bookmark-repository.ts src/backend/database/repositories/file-manager-bookmark-repository.test.ts src/backend/database/repositories/current-c2s-tunnel-preset-repository.ts src/backend/database/repositories/c2s-tunnel-preset-repository.ts src/backend/database/repositories/c2s-tunnel-preset-repository.test.ts src/backend/database/repositories/current-tmux-session-tag-repository.ts src/backend/database/repositories/tmux-session-tag-repository.ts src/backend/database/repositories/tmux-session-tag-repository.test.ts src/backend/database/repositories/current-opkssh-token-repository.ts src/backend/database/repositories/opkssh-token-repository.ts src/backend/database/repositories/opkssh-token-repository.test.ts src/backend/database/repositories/current-vault-token-repository.ts src/backend/database/repositories/vault-token-repository.ts src/backend/database/repositories/vault-token-repository.test.ts src/backend/database/repositories/current-vault-profile-repository.ts src/backend/database/repositories/vault-profile-repository.ts src/backend/database/repositories/vault-profile-repository.test.ts src/backend/database/repositories/current-host-metrics-preference-repository.ts src/backend/database/repositories/host-metrics-preference-repository.ts src/backend/database/repositories/host-metrics-preference-repository.test.ts src/backend/database/repositories/current-host-health-repository.ts src/backend/database/repositories/host-health-repository.ts src/backend/database/repositories/host-health-repository.test.ts src/backend/database/repositories/current-host-metrics-history-repository.ts src/backend/database/repositories/host-metrics-history-repository.ts src/backend/database/repositories/host-metrics-history-repository.test.ts src/backend/database/repositories/current-alert-repository.ts src/backend/database/repositories/alert-repository.ts src/backend/database/repositories/alert-repository.test.ts src/backend/database/repositories/current-user-data-export-repository.ts src/backend/database/repositories/user-data-export-repository.ts src/backend/database/repositories/user-data-export-repository.test.ts src/backend/database/repositories/current-host-folder-repository.ts src/backend/database/repositories/host-folder-repository.ts src/backend/database/repositories/host-folder-repository.test.ts src/backend/database/repositories/current-host-resolution-repository.ts src/backend/database/repositories/host-resolution-repository.ts src/backend/database/repositories/host-resolution-repository.test.ts src/backend/database/repositories/current-snippet-repository.ts src/backend/database/repositories/snippet-repository.ts src/backend/database/repositories/snippet-repository.test.ts src/backend/database/repositories/current-role-repository.ts src/backend/database/repositories/role-repository.ts src/backend/database/repositories/role-repository.test.ts src/backend/database/repositories/current-rbac-access-repository.ts src/backend/database/repositories/rbac-access-repository.ts src/backend/database/repositories/rbac-access-repository.test.ts src/backend/database/routes/rbac.ts src/backend/database/routes/snippets.ts src/backend/database/routes/host.ts src/backend/database/routes/credentials.ts src/backend/database/routes/delete-user-data.ts src/backend/database/routes/open-tabs.ts src/backend/database/routes/user-preferences.ts src/backend/database/routes/user-admin-routes.ts src/backend/database/routes/ldap-auth-routes.ts src/backend/database/routes/users.ts src/backend/database/routes/user-oidc-utils.ts src/backend/database/routes/sso-provider-routes.ts src/backend/database/routes/audit-log-routes.ts src/backend/database/routes/host-folder-routes.ts src/backend/database/routes/host-file-manager-bookmark-routes.ts src/backend/database/routes/c2s-tunnel-presets.ts src/backend/database/routes/alerts.ts src/backend/database/routes/alert-rules-routes.ts src/backend/database/routes/homepage-layout-routes.ts src/backend/database/routes/homepage-items-routes.ts src/backend/database/routes/network-topology.ts src/backend/database/routes/dashboard-service-links-routes.ts src/backend/database/routes/session-log-routes.ts src/backend/database/routes/host-command-history-routes.ts src/backend/database/routes/terminal.ts src/backend/database/routes/user-password-reset-routes.ts src/backend/ssh/terminal-session-manager.ts src/backend/ssh/tmux-monitor.ts src/backend/ssh/opkssh-auth.ts src/backend/ssh/vault-signer-auth.ts src/backend/ssh/vault-oidc-auth.ts src/backend/ssh/host-resolver.ts src/backend/ssh/host-metrics-preferences-routes.ts src/backend/ssh/managers/health.ts src/backend/ssh/host-metrics.ts src/backend/ssh/host-metrics-history-routes.ts src/backend/ssh/alert-engine.ts src/backend/utils/user-data-export.ts src/backend/utils/audit-logger.ts src/backend/utils/audit-logger.test.ts src/backend/utils/permission-manager.ts src/backend/utils/shared-credential-manager.ts src/backend/starter.ts +npm run test -- src/backend/database/runtime/config.test.ts src/backend/database/runtime/sqlite-adapter.test.ts src/backend/database/repositories/repository-rollout.test.ts src/backend/database/repositories/settings-repository.test.ts src/backend/database/repositories/user-session-repositories.test.ts src/backend/database/repositories/api-key-repository.test.ts src/backend/database/repositories/trusted-device-repository.test.ts src/backend/database/repositories/sso-provider-repository.test.ts src/backend/database/repositories/audit-log-repository.test.ts src/backend/database/repositories/user-preference-repository.test.ts src/backend/database/repositories/open-tab-repository.test.ts src/backend/database/repositories/dismissed-alert-repository.test.ts src/backend/database/repositories/homepage-layout-repository.test.ts src/backend/database/repositories/homepage-item-repository.test.ts src/backend/database/repositories/network-topology-repository.test.ts src/backend/database/repositories/dashboard-service-link-repository.test.ts src/backend/database/repositories/session-recording-repository.test.ts src/backend/database/repositories/command-history-repository.test.ts src/backend/database/repositories/recent-activity-repository.test.ts src/backend/database/repositories/ssh-credential-usage-repository.test.ts src/backend/database/repositories/transfer-recent-repository.test.ts src/backend/database/repositories/file-manager-bookmark-repository.test.ts src/backend/database/repositories/c2s-tunnel-preset-repository.test.ts src/backend/database/repositories/tmux-session-tag-repository.test.ts src/backend/database/repositories/opkssh-token-repository.test.ts src/backend/database/repositories/vault-token-repository.test.ts src/backend/database/repositories/vault-profile-repository.test.ts src/backend/database/repositories/host-metrics-preference-repository.test.ts src/backend/database/repositories/host-health-repository.test.ts src/backend/database/repositories/host-metrics-history-repository.test.ts src/backend/database/repositories/alert-repository.test.ts src/backend/database/repositories/user-data-export-repository.test.ts src/backend/database/repositories/host-folder-repository.test.ts src/backend/database/repositories/host-resolution-repository.test.ts src/backend/database/repositories/snippet-repository.test.ts src/backend/database/repositories/role-repository.test.ts src/backend/database/repositories/rbac-access-repository.test.ts src/backend/database/repositories/host-credential-repositories.test.ts src/backend/database/repositories/field-encryption-boundary.test.ts src/backend/utils/field-crypto.test.ts src/backend/utils/audit-logger.test.ts src/backend/guacamole/token-service.test.ts src/backend/database/routes/user-oidc-utils.test.ts src/backend/database/routes/termix-id.test.ts src/backend/database/routes/user-totp-routes.test.ts src/backend/utils/permission-manager.test.ts src/backend/ssh/credential-username.test.ts src/backend/ssh/tmux-monitor-helpers.test.ts +git diff --check +``` + +## 4. Smoke Test Matrix + +Run these against the gray target: + +| Area | Required check | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| Startup | App starts from existing encrypted snapshot without schema errors | +| Login | Password login succeeds for an existing local user | +| Sessions | Refresh, logout, and session list/revoke still work | +| Users | `/users/me`, user list, admin create, make/remove admin still work | +| Registration | New local user registration works when registration is enabled | +| Password | Change password, logout, and login with the new password | +| OIDC | Existing OIDC login works; auto-provision check only if enabled | +| API keys | Admin create/list/delete API key; API key authentication updates usage | +| Settings | Read/write user settings and global auth settings | +| Preferences | Read/write user preferences and user cleanup still work | +| Open tabs | Tab restore, single upsert, bulk sync, update/delete, active session list, and user cleanup work | +| Alerts | Active alert filtering, dismiss/undismiss, dismissed list, export, and user cleanup work | +| Homepage | Homepage layout read/write still works | +| Topology | Network topology read/write and user cleanup still work | +| Dashboard | Dashboard service link list/create/update/delete still works | +| Command log | Terminal and host command history save/list/delete and cleanup still work | +| Activity | Recent activity cleanup for host, folder, password reset, and user deletion still works | +| Usage | SSH credential usage tracking and host/folder/user cleanup still work | +| SSO providers | Login provider list, admin provider list/create/update/delete, and OIDC/LDAP login config loading work | +| Audit logs | Audit write, audit list filters, action filter list, and user cleanup still work | +| Roles | Admin list/create/update/delete role and assign/remove a user role | +| RBAC access | Host/snippet share/revoke/list and shared host/snippet endpoints work | +| Security | Non-admin user is rejected from admin-only endpoints | + +Do not continue gray rollout if any check fails. + +Also verify these startup log cases before production gray: + +| Environment value | Expected startup behavior | +| -------------------------------------------- | -------------------------------------------------- | +| `DATABASE_LAYER_REPOSITORY_ROLLOUT=all` | startup logs `mode: all` and all supported domains | +| `DATABASE_LAYER_REPOSITORY_ROLLOUT=off` | migrated repository use fails closed | +| `DATABASE_LAYER_REPOSITORY_ROLLOUT=settings` | only settings repository boundary is allowed | + +Then check `/database/migration/status` as an admin and confirm +`repositoryRollout.mode`, `repositoryRollout.enabledDomains`, and +`repositoryRollout.explicit` match the intended gray target. +`repositoryRollout.warnings` should be empty for the recommended full gray +slice. + +## 5. Rollout Stages + +Recommended stages: + +1. Local fixture run with copied production-like data. +2. Internal staging with one admin and one normal user. +3. Internal gray target with real integrations enabled. +4. Small production gray group. + +Do not run multiple backend instances from this branch. The current runtime still +uses the encrypted SQLite snapshot model and is not safe for multi-writer +deployment. + +## 6. Rollback + +Rollback should be operationally simple because this gray scope does not change +the data format or require an external database. + +Rollback steps: + +1. Stop the gray build. +2. Restore the pre-gray encrypted database snapshot if any write-path smoke test + failed. +3. Deploy the previous known-good commit or image tag. +4. Start the previous version. +5. Verify startup, login, settings read, and one admin endpoint. + +If the gray build only served read paths and no smoke test failed, restoring the +snapshot may not be necessary. If there is any doubt, restore the snapshot. + +## 7. Stop Conditions + +Stop gray rollout immediately if any of these happen: + +- Login failure spike. +- API key authentication failure for known-good keys. +- Admin authorization mismatch. +- OIDC callback failure for existing users. +- Missing or stale settings after write. +- Data snapshot save errors. +- Any database error mentioning missing tables, unknown columns, or failed + serialization. +- Any user encryption or data unlock failure. +- Any unexpected `Repository domain ... is disabled` error for a domain that was + intended to be enabled. + +## 8. Current Gray Readiness + +Current branch can be considered gray-candidate only after all validation +commands and smoke checks above pass on staging. + +The branch should remain draft until gray evidence is attached to the PR. diff --git a/readme/DATABASE-LAYER-PHASE-0-AUDIT.md b/readme/DATABASE-LAYER-PHASE-0-AUDIT.md new file mode 100644 index 00000000..2177c2a1 --- /dev/null +++ b/readme/DATABASE-LAYER-PHASE-0-AUDIT.md @@ -0,0 +1,548 @@ +# Database Layer Refactor Phase 0 Audit + +Status: Draft +Branch: `feature/database-layer-refactor` +Purpose: Establish the current database access inventory, domain map, sensitive field map, and first implementation boundaries before changing runtime persistence. + +## 1. Scope + +Phase 0 does not change runtime behavior. + +It produces the evidence needed for the next implementation phases: + +- where database access currently happens +- which modules write directly to the database +- which tables belong to which product domains +- which fields are sensitive +- which direct writes are risky under the current in-memory snapshot model +- which domains should move first into repositories + +## 2. Current Evidence + +Commands used for the initial audit: + +```bash +rg -n "getDb\\(|getSqlite\\(|DatabaseSaveTrigger|SimpleDBOps|db\\.\\$client|\\.prepare\\(" src/backend +rg -n "getDb\\(\\)\\.(insert|update|delete)|await db\\.(insert|update|delete)|db\\.(insert|update|delete)|\\.\\$client\\.prepare\\(\\\"(INSERT|UPDATE|DELETE)|\\.prepare\\(\\\"(INSERT|UPDATE|DELETE)|DatabaseSaveTrigger\\.triggerSave|DatabaseSaveTrigger\\.forceSave" src/backend +rg -n "export const .* = sqliteTable\\(" src/backend/database/db/schema.ts +``` + +High-level findings: + +- Database infrastructure is concentrated in `src/backend/database/db/index.ts`. +- Business database access is spread across route modules, SSH modules, utilities, and auth helpers. +- `SimpleDBOps` is not the only write path. +- There are many direct Drizzle writes and raw SQLite writes. +- Some direct writes manually trigger `DatabaseSaveTrigger`; many write paths do not. +- Schema is SQLite-specific through `sqliteTable` and manual `CREATE TABLE IF NOT EXISTS` / `addColumnIfNotExists`. + +## 3. Database Access Hotspots + +The most database-heavy files by audit hits: + +| File | Approx. hits | Notes | +| ----------------------------------------------------- | -----------: | --------------------------------------------------------------- | +| `src/backend/database/db/index.ts` | 75 | database init, schema creation, ad-hoc migration, snapshot save | +| `src/backend/database/routes/alert-rules-routes.ts` | 64 | alert rules, channels, firings, raw SQL deletes | +| `src/backend/database/routes/users.ts` | 54 | users, settings, OIDC/GitHub settings, admin flows | +| `src/backend/database/database.ts` | 27 | import/export, legacy SQLite handling | +| `src/backend/ssh/host-metrics.ts` | 26 | metrics connection and settings reads | +| `src/backend/database/routes/user-settings-routes.ts` | 16 | user settings | +| `src/backend/dashboard.ts` | 15 | dashboard aggregation reads | +| `src/backend/ssh/docker.ts` | 14 | host/credential reads for Docker SSH | +| `src/backend/ssh/managers/health.ts` | 13 | host health checks | +| `src/backend/ssh/alert-engine.ts` | 13 | alert evaluation and firing state | +| `src/backend/utils/user-crypto.ts` | 12 | settings writes for crypto metadata | +| `src/backend/ssh/host-metrics-settings-routes.ts` | 12 | metrics settings | +| `src/backend/ssh/tmux-monitor.ts` | 11 | tmux session tags | +| `src/backend/guacamole/routes.ts` | 11 | Guacamole config/routes | +| `src/backend/database/routes/host.ts` | 10 | host operations and related rows | + +This confirms the refactor must be domain-by-domain. A mechanical replacement of `getDb()` would be noisy and unsafe. + +## 4. Direct Write Risk Inventory + +Under the current architecture, a direct write is risky when it bypasses `SimpleDBOps` and does not trigger `DatabaseSaveTrigger`. + +### 4.1 Direct Writes That Need Repository Ownership + +These areas perform direct writes and should move behind repositories/services: + +| Area | Representative files | Examples | +| --------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------- | +| users/settings/auth | `routes/users.ts`, `utils/auth-manager.ts`, `utils/user-crypto.ts` | sessions, trusted devices, OIDC settings, registration settings | +| RBAC/sharing | `routes/rbac.ts`, `utils/shared-credential-manager.ts` | roles, host access, snippet access, shared credentials | +| hosts/credentials | `routes/host.ts`, `routes/credentials.ts`, `host-resolver.ts` | host access cleanup, credential usage | +| file manager metadata | `host-file-manager-bookmark-routes.ts` | recent, pinned, shortcuts | +| alerts | `alert-rules-routes.ts`, `ssh/alert-engine.ts` | channels, rules, firings | +| metrics | `host-metrics-preferences-routes.ts`, `managers/health.ts` | preferences, health checks, history | +| terminal logs | `terminal-session-manager.ts` | session recording metadata | +| tmux | `tmux-monitor.ts` | tmux session tags | +| import/export | `database/database.ts` | SQLite import and forced save | +| open tabs | `routes/open-tabs.ts` | tab persistence and cleanup | +| API keys | `user-api-key-routes.ts`, `utils/auth-manager.ts` | API key create/delete/last-used | +| SSO and identity | `sso-provider-routes.ts`, `termix-id.ts` | providers, identity keys/CA | + +### 4.2 Immediate Compatibility Rule + +Until a domain is migrated to repositories: + +- Every direct write must either be moved into a repository or explicitly trigger persistence in the old runtime. +- New code should not add direct `getDb()` writes outside infrastructure or repositories. +- The draft branch should add an enforcement check before Phase 8, not immediately, because the current codebase still violates the target rule widely. + +## 5. Table Domain Map + +### 5.1 Identity and Authentication + +| Tables | Notes | +| ---------------------- | -------------------------------------------- | +| `users` | local/OIDC users, TOTP fields, password hash | +| `sessions` | JWT sessions | +| `trusted_devices` | remembered devices | +| `api_keys` | API token hashes/prefixes | +| `sso_providers` | configured SSO providers | +| `termix_identities` | Termix identity records | +| `termix_identity_keys` | public/private identity key metadata | +| `termix_identity_ca` | CA material, private key is sensitive | + +Suggested repository: + +- `userRepository` +- `sessionRepository` +- `trustedDeviceRepository` +- `apiKeyRepository` +- `ssoProviderRepository` +- `termixIdentityRepository` + +### 5.2 Hosts and Credentials + +| Tables | Notes | +| ---------------------- | ------------------------------------------------ | +| `ssh_data` | primary host table, many config JSON/text fields | +| `ssh_credentials` | reusable credentials | +| `ssh_credential_usage` | usage history | +| `ssh_folders` | folder metadata | +| `host_access` | sharing/RBAC access rows | +| `shared_credentials` | encrypted shared credential material | +| `network_topology` | topology graph/config | + +Suggested repository: + +- `hostRepository` +- `credentialRepository` +- `credentialUsageRepository` +- `hostAccessRepository` +- `sharedCredentialRepository` +- `hostFolderRepository` +- `networkTopologyRepository` + +### 5.3 RBAC + +| Tables | Notes | +| ---------------- | ------------------------ | +| `roles` | role definitions | +| `user_roles` | user to role assignments | +| `host_access` | host-level grants | +| `snippet_access` | snippet-level grants | + +Suggested repository: + +- `roleRepository` +- `accessRepository` + +### 5.4 File Manager + +| Tables | Notes | +| ------------------------ | ---------------------------------- | +| `file_manager_recent` | recently opened paths | +| `file_manager_pinned` | pinned paths | +| `file_manager_shortcuts` | saved shortcuts | +| `transfer_recent` | host-to-host transfer destinations | + +Suggested repository: + +- `fileManagerRepository` +- `transferRecentRepository` + +### 5.5 Snippets + +| Tables | Notes | +| ----------------- | ----------------------- | +| `snippets` | command snippets | +| `snippet_folders` | snippet folder metadata | +| `snippet_access` | snippet sharing | + +Suggested repository: + +- `snippetRepository` + +### 5.6 Runtime Metadata and Audit + +| Tables | Notes | +| -------------------- | ------------------------------------------------------ | +| `audit_logs` | append-only audit trail | +| `session_recordings` | terminal recording metadata, log content is file-based | +| `recent_activity` | host activity feed | +| `command_history` | terminal command history | +| `user_open_tabs` | UI restore state; currently cleared on startup | +| `user_preferences` | per-user UI/preferences | +| `settings` | global settings | + +Suggested repository: + +- `auditRepository` +- `sessionRecordingRepository` +- `activityRepository` +- `commandHistoryRepository` +- `openTabsRepository` +- `userPreferencesRepository` +- `settingsRepository` + +### 5.7 Metrics and Alerts + +| Tables | Notes | +| -------------------------- | -------------------------- | +| `host_metrics_preferences` | metrics layout/preferences | +| `host_health_checks` | health check definitions | +| `host_health_history` | health check results | +| `host_metrics_history` | metrics history | +| `alert_rules` | alert definitions | +| `notification_channels` | webhook/ntfy/etc config | +| `alert_rule_channels` | rule/channel joins | +| `alert_firings` | firing/ack state | +| `dismissed_alerts` | dismissed system alerts | + +Suggested repository: + +- `metricsRepository` +- `healthCheckRepository` +- `alertRepository` +- `notificationRepository` + +### 5.8 Integrations and Feature Config + +| Tables | Notes | +| ------------------------- | --------------------------------------- | +| `c2s_tunnel_presets` | tunnel preset config | +| `opkssh_tokens` | OPKSSH cert/private key cache | +| `vault_profiles` | Vault profile config, mostly non-secret | +| `vault_tokens` | Vault cert/private key cache | +| `dashboard_service_links` | dashboard links | +| `homepage_items` | homepage widgets/items | +| `homepage_layouts` | homepage layouts | +| `tmux_session_tags` | tmux tag metadata | + +Suggested repository: + +- `tunnelPresetRepository` +- `opksshTokenRepository` +- `vaultRepository` +- `dashboardRepository` +- `homepageRepository` +- `tmuxRepository` + +## 6. Sensitive Field Map + +The current explicit `FieldCrypto` map encrypts: + +| Table | Fields | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `users` | `passwordHash`, `clientSecret`, `totpSecret`, `totpBackupCodes`, `oidcIdentifier` | +| `ssh_data` | `password`, `key`, `keyPassword`, `sudoPassword`, `autostartPassword`, `autostartKey`, `autostartKeyPassword`, `socks5Password`, `rdpPassword`, `vncPassword`, `telnetPassword` | +| `ssh_credentials` | `password`, `privateKey`, `keyPassword`, `key`, `publicKey` | +| `opkssh_tokens` | `sshCert`, `privateKey` | +| `termix_identity_ca` | `privateKey` | +| `vault_tokens` | `sshCert`, `privateKey` | + +Additional sensitive fields by table semantics: + +| Table | Fields / reason | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ssh_credentials` | `systemPassword`, `systemKey`, `systemKeyPassword` are system-key encrypted credential copies | +| `shared_credentials` | `encryptedUsername`, `encryptedAuthType`, `encryptedPassword`, `encryptedKey`, `encryptedKeyPassword`, `encryptedKeyType` are already encrypted payload fields | +| `api_keys` | `tokenHash` is not plaintext but is authentication material; `tokenPrefix` may remain plaintext for display | +| `settings` | some keys may hold provider secrets or reset codes; repository must classify by key | +| `notification_channels` | config may include webhook URLs/tokens; treat config as sensitive unless split | +| `alert_rules` | rule definitions are usually not secret but can include host/resource metadata | +| `homepage_items` | widget config can include URLs/API config; classify per widget type | +| `c2s_tunnel_presets` | config can include connection details; review before plaintext external DB storage | +| `termix_identity_keys` | inspect key material fields before migration; public keys are not secret but private material must never be plaintext | +| `vault_profiles` | current comments say profile fields are non-secret; keep that invariant explicit | + +Open privacy decision: + +- `ssh_data.ip`, domain fields, usernames, folders, and tags are queryable today. +- Encrypting them improves confidentiality but breaks search/filter/sort unless blind indexes are added. +- Recommended initial migration: keep them plaintext and document privacy implications; add optional privacy mode later. + +## 7. Repository Migration Order + +Use a vertical slice instead of broad replacement. + +### 7.1 First Slice + +1. `settingsRepository` +2. `userRepository` +3. `sessionRepository` +4. `hostRepository` +5. `credentialRepository` + +Reasons: + +- Covers the app's boot/login/core host management path. +- Exercises field encryption. +- Exercises per-user data unlock requirements. +- Exercises transaction and migration behavior. +- Builds reusable patterns for the rest of the backend. + +### 7.2 Second Slice + +1. `hostAccessRepository` +2. `roleRepository` +3. `sharedCredentialRepository` +4. `auditRepository` +5. `userPreferencesRepository` + +Reasons: + +- Completes permission and sharing boundaries. +- Removes high-risk direct writes in admin/RBAC flows. +- Moves audit to an append-only repository. + +### 7.3 Third Slice + +1. `snippetRepository` +2. `fileManagerRepository` +3. `metricsRepository` +4. `alertRepository` +5. `homepageRepository` + +Reasons: + +- These are broad feature domains with many routes. +- They should reuse patterns from the core slices. + +### 7.4 Fourth Slice + +1. `vaultRepository` +2. `opksshTokenRepository` +3. `termixIdentityRepository` +4. `tunnelPresetRepository` +5. `tmuxRepository` + +Reasons: + +- Sensitive token/key cache domains need careful encryption tests. +- Some data is transient and should have retention cleanup. + +## 8. Adapter Design Notes + +The adapter boundary should expose: + +```ts +interface DatabaseAdapter { + dialect: "sqlite" | "postgres" | "mysql"; + connect(): Promise; + close(): Promise; + migrate(): Promise; + transaction(fn: (tx: DatabaseTransaction) => Promise): Promise; +} +``` + +The repository layer should not depend on `better-sqlite3`. + +For Drizzle, likely options: + +- keep dialect-specific Drizzle clients internally +- expose repositories instead of exposing the raw Drizzle client +- keep schema definitions close to migrations, not route handlers + +Important: do not make route modules import dialect-specific schema objects after migration. + +## 9. Compatibility Shims + +During the migration, avoid a flag day. + +Add a compatibility database module that lets old code continue to run while new repositories are introduced: + +```text +legacy getDb() path +new adapter/repository path +``` + +Rules: + +- New modules use repositories only. +- Migrated modules must not fall back to `getDb()`. +- Legacy path is removed only after all domains move. + +## 10. Phase 1 Entry Criteria + +Before implementing the adapter skeleton: + +- This audit document exists. +- The sensitive field map is reviewed. +- The first vertical slice is accepted. +- The draft PR remains draft. +- No runtime behavior has changed. + +## 11. Phase 1 Deliverables + +Recommended first implementation PR on this branch: + +- `src/backend/database/runtime/config.ts` +- `src/backend/database/runtime/adapter.ts` +- `src/backend/database/runtime/sqlite-adapter.ts` +- `src/backend/database/repositories/settings-repository.ts` +- `src/backend/database/repositories/user-repository.ts` +- `src/backend/database/repositories/session-repository.ts` +- `src/backend/database/repositories/host-repository.ts` +- `src/backend/database/repositories/credential-repository.ts` +- `src/backend/database/repositories/field-encryption-boundary.ts` +- `src/backend/database/repositories/current-settings-repository.ts` +- tests for config parsing and SQLite adapter boot + +Started: + +- runtime config parser +- SQLite adapter skeleton +- migration metadata table bootstrap +- `SettingsRepository` skeleton and tests +- `UserRepository` and `SessionRepository` skeletons and tests +- `HostRepository` and `CredentialRepository` skeletons and tests +- `FieldEncryptionBoundary` skeleton and tests +- first settings route slice wired through `SettingsRepository` +- user settings route direct `settings` table access moved behind + `SettingsRepository` +- host metrics settings route direct `settings` table access moved behind + `SettingsRepository` +- ACME SSL settings route direct `settings` table access moved behind + `SettingsRepository` +- terminal route direct `settings` table access moved behind + `SettingsRepository` +- tailscale route direct `settings` table access moved behind + `SettingsRepository` +- Guacamole route and WebSocket server direct `settings` table access moved + behind the current settings repository boundary +- auth token expiry and terminal session timeout settings reads moved behind the + current settings repository boundary +- open tabs, TOTP, and LDAP auth route settings reads moved behind the current + settings repository boundary +- host metrics polling settings reads moved behind the current settings + repository boundary +- backend startup settings reads moved behind the current settings repository + boundary +- user deletion cleanup now removes per-user settings through + `SettingsRepository.deleteLike` +- password reset route reset code and temporary token settings access moved + behind `SettingsRepository` +- OIDC utility legacy config fallback reads `oidc_config` through + `SettingsRepository` +- user route registration/password flags and OIDC config administration moved + behind the current settings repository boundary +- OIDC authorize/callback temporary state and auto-provision reads moved behind + the current settings repository boundary +- user login settings reads moved behind the current settings repository + boundary, completing direct `settings` access cleanup in `routes/users.ts` +- user encryption metadata in `utils/user-crypto.ts` moved behind the current + settings repository boundary +- database startup and schema migration defaults in `database/db/index.ts` + moved to local raw settings helpers +- database import/export settings handling in `database/database.ts` moved to + local helper boundaries +- core `auth-manager.ts` session create/read/update/revoke/list paths started + using the current session repository boundary +- remaining `auth-manager.ts` session cleanup/middleware/logout paths and + `user-session-routes.ts` single-session lookup moved behind the current + session repository boundary +- current user repository factory/write-save hook added, and + `user-admin-routes.ts` list/admin promotion/admin removal/admin-create user + paths moved behind the current user repository boundary +- low-risk `routes/users.ts` current-user lookup and admin gate checks moved + behind the current user repository boundary +- user registration, self-delete, password change hash updates, and admin + delete-user lookup paths in `routes/users.ts` moved behind the current user + repository boundary, with first-user admin creation kept transactional inside + `UserRepository` +- traditional login username lookup and `auth-manager.ts` admin user checks + moved behind the current user repository boundary +- GitHub and standard OIDC callback user lookup/create/rollback/profile/admin + sync writes moved behind the current user repository boundary, removing direct + Drizzle `users` table access from `routes/users.ts`, `user-admin-routes.ts`, + and `auth-manager.ts` +- API key create/list/delete and API key authentication last-used updates moved + behind the current API key repository boundary +- trusted device check/add/remove and TOTP trusted-device cleanup moved behind + the current trusted device repository boundary +- user session routes moved user/admin lookups and admin session username + enrichment behind the current user repository boundary +- vault admin checks, Termix ID audit username lookup, permission manager admin + checks, and user data export user lookup moved behind the current user + repository boundary +- SSH credential OIDC username expansion and tmux monitor audit actor username + lookup moved behind the current user repository boundary +- user settings route admin checks and audit actor username lookups moved behind + the current user repository boundary +- ACME SSL route admin checks and audit actor username lookups moved behind the + current user repository boundary +- audit log route admin checks moved behind the current user repository boundary +- OIDC account link/unlink route user lookups and OIDC field updates moved + behind the current user repository boundary +- password reset route user lookups, password hash updates, and TOTP reset + fields moved behind the current user repository boundary +- user deletion helper now removes sessions through the current session + repository and the final user record through the current user repository +- snippet create/update/delete audit actor username lookups moved behind the + current user repository boundary +- LDAP login existing-user lookup, encryption rollback delete, admin sync, and + display-name sync moved behind the current user repository boundary +- TOTP setup/enable/disable/backup-code/login verification user updates and + session revocation moved behind the current user/session repository + boundaries +- RBAC host sharing, role assignment, and snippet sharing target-user existence + checks moved behind the current user repository boundary, with existing + RBAC/snippet owner username joins retained +- RBAC role list/create/update/delete, user-role assignment/removal/listing, + and shared host/snippet role-id lookups moved behind the current role + repository boundary, with existing RBAC/share credential joins retained +- permission manager role permission aggregation, role-id lookups for shared + host access, and admin role checks moved behind the current role repository + boundary +- RBAC host/snippet access-list read models moved behind the current RBAC access + repository boundary, and snippet route shared-access role-id lookups moved + behind the current role repository boundary +- RBAC shared host/shared snippet read models and the main snippet + shared-snippet read model moved behind the current RBAC access repository + boundary +- RBAC host/snippet access grant, revoke, and direct host-access credential + override writes moved behind the current RBAC access repository boundary, + with shared credential material creation retained in the existing manager +- permission manager host-access expiration cleanup, shared host-access lookup, + and last-access timestamp updates moved behind the current RBAC access + repository boundary +- repository rollout guard added through `DATABASE_LAYER_REPOSITORY_ROLLOUT` + for the migrated settings/users/sessions/API-key/trusted-device/role/RBAC-access + slice + +Keep it small. Do not wire host or credential routes into the new repositories in +the same first implementation commit. + +## 12. Current Unknowns + +- Exact Drizzle multi-dialect strategy needs a spike. +- The project may need a migration generator or a custom migration runner. +- Some raw SQL in `routes/users.ts`, `alert-rules-routes.ts`, and `db/index.ts` must be rewritten or isolated. +- `settings` contains mixed public and sensitive values; key-level classification is required. +- `homepage_items.config`, `notification_channels.config`, and tunnel configs may contain embedded secrets. +- Legacy encrypted snapshot fixtures need to be created before migration implementation. + +## 13. Decision Log + +- Default upgrade target should be persistent SQLite, not PostgreSQL/MySQL. +- PostgreSQL/MySQL migration should be explicit and initially manual/experimental. +- Runtime SSH/WebSocket/tunnel state remains memory-only. +- Field-level encryption is mandatory for every database backend. +- Field-level encryption must use a stable record id; temporary encryption + contexts are forbidden for newly written repository data. +- Repository migration should start with settings/users/sessions/hosts/credentials. diff --git a/readme/DATABASE-LAYER-REFACTOR-PLAN.md b/readme/DATABASE-LAYER-REFACTOR-PLAN.md new file mode 100644 index 00000000..fdc0d957 --- /dev/null +++ b/readme/DATABASE-LAYER-REFACTOR-PLAN.md @@ -0,0 +1,1187 @@ +# Database Layer Refactor Plan + +Status: Draft +Branch: `feature/database-layer-refactor` +Target: Replace the current in-memory encrypted SQLite snapshot model with a persistent, secure, multi-database architecture. + +Phase 0 audit: [`DATABASE-LAYER-PHASE-0-AUDIT.md`](./DATABASE-LAYER-PHASE-0-AUDIT.md) +Gray rollout guide: [`DATABASE-LAYER-GRAY-ROLLOUT.md`](./DATABASE-LAYER-GRAY-ROLLOUT.md) + +Phase 1 status: database runtime config and SQLite adapter skeleton have started under +`src/backend/database/runtime/`. This code is not wired into the existing production +database initialization yet. + +Repository status: the first repository skeletons, `SettingsRepository`, +`UserRepository`, `SessionRepository`, `HostRepository`, and +`CredentialRepository`, have started under `src/backend/database/repositories/`. +`RoleRepository` has also started for the RBAC role/user-role slice, and these +repositories are covered by SQLite-backed tests. `RbacAccessRepository` has +started for RBAC host/snippet access-list read models. + +Field encryption status: repository-level field encryption boundary tests have +started. New field encryption requires a stable record id and must not invent a +temporary encryption context. + +Settings migration status: a first low-risk production route slice now uses +`SettingsRepository` through the current SQLite runtime context. Legacy in-memory +runtime writes still force a snapshot save through `DatabaseSaveTrigger`. +The user settings route has also moved its direct `settings` table reads and +writes behind `SettingsRepository`. +Host metrics settings routes now use the same repository boundary for global +monitoring defaults and history retention settings. +ACME SSL settings route also uses the repository boundary for its persisted +configuration key. +Terminal route session settings and command history global flag now read/write +through `SettingsRepository`. +Tailscale device route now reads its API key through `SettingsRepository`. +Guacamole route and WebSocket server now read `guac_url` through the current +settings repository boundary. +Authentication token expiry and terminal session timeout reads now use the +current settings repository boundary. +Open tabs, TOTP, and LDAP auth routes now read their settings through the +current settings repository boundary. +Host metrics polling now reads global interval and retention settings through +the current settings repository boundary. +Backend startup now reads persisted log level and Guacamole enablement through +the current settings repository boundary. +User deletion cleanup now removes per-user settings through +`SettingsRepository.deleteLike`. +Password reset route now stores reset codes and temporary reset tokens through +`SettingsRepository`. +OIDC utility legacy config fallback now reads `oidc_config` through +`SettingsRepository`. +User route registration/password flags and OIDC config administration now use +the current settings repository boundary. +OIDC authorize/callback temporary state and auto-provision reads now use the +current settings repository boundary. +User login settings reads now use the current settings repository boundary, so +`routes/users.ts` no longer reads or writes `settings` directly. +User encryption metadata in `utils/user-crypto.ts` now reads and writes KEK/DEK +settings through the current settings repository boundary. +Database import/export user unlock and admin checks now use the current user +repository boundary. +Auth login lazy user-field encryption migration now calls the `DataCrypto` +current-runtime migration boundary instead of opening SQLite in +`auth-manager.ts`. +Current user-field encryption migration now resolves SQLite through +`createCurrentUserEncryptionMigrationStore` and the shared current runtime +helper, keeping `DataCrypto` on the migration-store interface. +User, admin, TOTP, OIDC account, and credential migration explicit saves now +use `DatabaseSaveTrigger` instead of importing the SQLite snapshot save function +from routes. +Current SQLite snapshot save trigger now initializes after database startup +regardless of file-encryption mode, and backend shutdown uses that save +boundary. +Direct SQLite snapshot save-function imports are now isolated inside +`database/db/index.ts`; current user-field migration saves through +`DatabaseSaveTrigger`. +Database import SQLite foreign-key toggling is isolated behind the +`withSqliteForeignKeysDisabled` runtime boundary and restores constraints in a +`finally` path; the current variant resolves SQLite through the shared current +runtime helper. +Database import now uses the current SQLite foreign-key boundary without +importing `getDb()` in the route handler. +Settings, user, host, API key, session, trusted device, audit log, user +preference, open tab, dismissed alert, homepage layout/item, and network +topology, dashboard service link, session recording, command history, recent +activity, transfer recent, and file-manager bookmark current repository +factories plus C2S tunnel preset, tmux session tag, OPKSSH token, Vault +token/profile, SSH credential usage, role, SSO provider, host folder, alert, +host health, host metrics preference/history, credential, host resolution, +snippet, RBAC access, shared credential, Termix ID identity/CA, and user data +export current repository factories now share `current-repository-runtime` for +SQLite context and write-save hooks. +Database startup and schema migration defaults in `database/db/index.ts` now use +local raw settings helpers instead of scattered settings SQL. +Database import/export settings handling in `database/database.ts` now uses +local helper boundaries for export filtering and admin import upserts. +Those import/export settings helpers now use the current settings repository +boundary. +Session repository now has a current-runtime factory and write-save hook, and +core `auth-manager.ts` session create/read/update/revoke/list paths have started +using it. +Remaining `auth-manager.ts` session cleanup/middleware/logout paths and +`user-session-routes.ts` single-session lookup now use the current session +repository boundary. +User repository now has a current-runtime factory and write-save hook, and +`user-admin-routes.ts` list/admin promotion/admin removal/admin-create user +paths now use it for `users` table reads and writes. +Low-risk `routes/users.ts` current-user lookup and admin gate checks now use the +current user repository boundary. +User registration, self-delete, password change hash updates, and admin +delete-user lookup paths in `routes/users.ts` now use the current user +repository boundary while preserving the first-user admin transaction inside +`UserRepository`. +Traditional login username lookup and `auth-manager.ts` admin user checks now +use the current user repository boundary. +Credential list, folder list, detail reads, update lookup/readback, host route +credential resolution reads, folder rename writes, apply usage writes, shared +credential source row reads, credential delete lookup/delete, and +credential-host list reads now use current credential and host resolution +repository boundaries. +Database export host and credential read/decryption paths now use current host +and credential repository boundaries. +Database import host and credential duplicate checks plus encrypted creates now +use current host and credential repository boundaries. +Database import/export file-manager recent/pinned/shortcut target reads and +writes now use the current file-manager bookmark repository boundary. +Credential create/update encrypted writes now use the current credential +repository boundary, including system-key copies for shared credentials. +Credential delete host cleanup and apply-to-host writes now use the current host +repository boundary. +Credential system-key copy backfill migration now uses current credential and +shared credential repository boundaries. +Legacy user field-encryption migration SQL is now centralized behind +`RawSqliteUserEncryptionMigrationStore`. +Legacy unencrypted SQLite copy/verification during encrypted-file migration is +now centralized behind `LegacySqliteDatabaseCopyStore`. +Termix ID credential lookup, generated credential persistence, and generated +credential cleanup now use the current credential repository boundary. +Termix ID identity handle CRUD/resolution, public key publish/list/update/delete, +linked credential lookup, and certificate target key lookup now use the current +Termix ID repository boundary. +Termix ID CA public lookup, encrypted private-key create/rotate/delete, and +certificate signing reads now use the current Termix ID CA repository boundary. +Host route update readback, single-host fetch, password-field fetch, and host +export reads now use the current host resolution repository boundary. +Host route create/update encrypted writes now use the current host repository +boundary. +Host route update-state and delete-audit host reads now use the current host +resolution repository boundary. +Host route delete final host-row writes and audit actor username lookups now use +the current host and user repository boundaries. +Host route own/shared list assembly reads now use the current host resolution +repository boundary while preserving route-level own-host decryption. +Host bulk-update state reads, non-sensitive bulk flag/config writes, and JSON +plus SSH-config import encrypted create/update writes now use the current host +repository boundary, while host bulk import overwrite lookup plus credential +fallback reads use current host resolution and credential repository boundaries. +Host autostart enable, disable, status, and endpoint-host resolution paths now +use the current host repository boundary. +Guacamole host token host and protocol credential reads now use the current +host resolution repository boundary while preserving request-user decryption +behavior for credential fallback. +Snippet folder list/create/metadata/rename/delete, owned lookup, visible-list +owned reads, reorder, create/update/delete, export reads, and bulk import now +use the current snippet repository boundary. +User cleanup and password-reset data-discard host, credential, snippet, snippet +folder, and host folder deletes now use current repository boundaries. +GitHub and standard OIDC callback user lookup/create/rollback/profile/admin +sync writes now use the current user repository boundary, removing direct +Drizzle `users` table access from `routes/users.ts`, `user-admin-routes.ts`, +and `auth-manager.ts`. +User setup/count/db-health checks, password-login TOTP guard reads, and +last-admin delete guard reads now use the current user repository boundary. +API key create/list/delete and API key authentication last-used updates now use +the current API key repository boundary. +Trusted device check/add/remove and TOTP trusted-device cleanup now use the +current trusted device repository boundary. +User session routes now use the current user repository boundary for user/admin +lookups and admin session username enrichment. +Vault admin checks, Termix ID audit actor username lookup, permission manager +admin checks, and user data export user lookup now use the current user +repository boundary. +SSH credential OIDC username expansion and tmux monitor audit actor username +lookup now use the current user repository boundary. +User settings route admin checks and audit actor username lookups now use the +current user repository boundary, removing direct DB/schema imports from that +route module. +ACME SSL route admin checks and audit actor username lookups now use the current +user repository boundary, removing direct DB/schema imports from that route +module. +Audit log route admin checks now use the current user repository boundary, +removing direct `users` access from that route module. +OIDC account link/unlink route user lookups and OIDC field updates now use the +current user repository boundary, removing direct `users` access from that route +module. +Password reset route user lookups, password hash updates, TOTP reset fields, and +per-user encrypted-data cleanup now use current repository boundaries. +User deletion helper now removes sessions, API keys, trusted devices, dashboard +links, homepage layout/items, host health checks/history, host metrics +preferences, C2S presets, Vault tokens/profiles, roles, alert data, audit logs, +Termix ID identity/CA, tmux session tags, encrypted data, UI state, and related +per-user rows through current repository boundaries before deleting the final +user record. +Snippet create/update/delete audit actor username lookups now use the current +user repository boundary; the remaining snippet `users` usage is the shared +snippet owner join. +LDAP login first-user provisioning, admin-group user creation, existing-user +lookup, encryption rollback delete, admin sync, and display-name sync now use +the current user repository boundary. +TOTP setup/enable/disable/backup-code/login verification user updates and +session revocation now use the current user/session repository boundaries. +RBAC host sharing, role assignment, and snippet sharing target-user existence +checks now use the current user repository boundary while retaining existing +RBAC/snippet owner username joins. +RBAC role list/create/update/delete, user-role assignment/removal/listing, and +shared host/snippet role-id lookups now use the current role repository +boundary while retaining existing RBAC/share credential joins in the route. +Permission manager role permission aggregation, role-id lookups for shared host +access, and admin role checks now use the current role repository boundary. +RBAC host/snippet access-list read models now use the current RBAC access +repository boundary, and snippet route shared-access role-id lookups now use the +current role repository boundary. +RBAC shared host/shared snippet read models and the main snippet shared-snippet +read model now use the current RBAC access repository boundary. +RBAC route host/snippet owner checks and direct host-access credential existence +reads now use current host, snippet, and credential repository boundaries. +RBAC host/snippet access grant, revoke, and direct host-access credential +override writes now use the current RBAC access repository boundary while +retaining shared credential material creation in the existing manager. +Shared credential material create/update/delete, pending re-encryption, and +user cleanup persistence now use the current shared credential repository +boundary while keeping encryption/decryption logic in the existing manager. +Permission manager host-access expiration cleanup, shared host-access lookup, +and last-access timestamp updates now use the current RBAC access repository +boundary. +RBAC role assignment now reads role-shared host credential sources through the +current RBAC access repository boundary while keeping shared credential material +creation in the existing manager. +Snippet single-item shared-access checks now use the current RBAC access +repository boundary. +Shared credential manager host-access lookups for role-member credential +creation, shared credential reads, and pending re-encryption owner discovery now +use the current RBAC access repository boundary. +Host route host-access cleanup writes for credential removal and host deletion +now use the current RBAC access repository boundary. +Host route shared-host list access checks now use the current role and RBAC +access repository boundaries while host row loading stays local to the host +route. +Credential deletion, folder deletion, and user deletion host-access cleanup +writes now use the current RBAC access repository boundary. +Shared credential manager role-member lookups now use the current role +repository boundary. +User deletion role-assignment cleanup now uses the current role repository +boundary. +User admin route role sync and admin-created default role assignment now use +the current role repository boundary. +LDAP login default role assignment and admin-role sync now use the current role +repository boundary. +Local, GitHub OIDC, and standard OIDC user default role assignment plus OIDC +admin-group role sync now use the current role repository boundary. +SSO provider listing, management, OIDC config loading, and LDAP provider +validation now use the current SSO provider repository boundary. +Audit log writes, filtered reads, action lists, and user cleanup now use the +current audit log repository boundary. +User preferences read/write and user cleanup now use the current user +preference repository boundary. +Open tab restore/upsert/sync/update/delete and user cleanup now use the current +open tab repository boundary. +Dismissed alert read/dismiss/undismiss/export and user cleanup now use the +current dismissed alert repository boundary. +Homepage layout read/write now uses the current homepage layout repository +boundary. +Homepage item list/create/update/delete now uses the current homepage item +repository boundary. +Network topology read/write and user cleanup now use the current network +topology repository boundary. +Dashboard service link list/create/update/delete now uses the current dashboard +service link repository boundary. +Session recording create/list/read/content/delete/prune and host/folder/user +cleanup now uses the current session recording repository boundary. +Command history save/list/delete and host/user cleanup now use the current +command history repository boundary. +Dashboard recent activity list/log/trim/reset and host/user cleanup now use the +current recent activity repository boundary, with host ownership/share checks +using current host resolution, role, and RBAC access repository boundaries. +SSH credential usage writes and host/user cleanup now use the current SSH +credential usage repository boundary. +Transfer recent list/upsert/prune/export and host/folder/user cleanup now use +the current transfer recent repository boundary. +File manager recent/pinned/shortcut list/create/delete/export and +host/folder/user/password-reset cleanup now use the current file manager +bookmark repository boundary. +C2S tunnel preset list/create/update/delete now uses the current C2S tunnel +preset repository boundary. +Tmux session tag list/rename/delete/replace now uses the current tmux session +tag repository boundary. +OPKSSH token upsert/read/touch/delete and user cleanup now use the current +OPKSSH token repository boundary. +Database import/export dismissed alert reads and writes now use the current +dismissed alert repository boundary. +Database export SSH credential usage reads now use the current SSH credential +usage repository boundary. +Vault token upsert/read/touch/delete now uses the current Vault token repository +boundary. +Vault profile list/create/update/delete and profile lookup now use the current +Vault profile repository boundary. +Host metrics layout preference read/upsert and statsConfig widget sync now use the current host metrics +preference repository boundary. +Host health check config and history read/write now use the current host health +repository boundary. +Host metrics history record/prune/query now uses the current host metrics +history repository boundary. +Alert notification channels, rules, linked channels, firings, and alert engine +persistence reads/writes now use the current alert repository boundary. +User data export host and credential read models now use the current user data +export repository boundary. +SSH folder list, metadata upsert, rename, and folder host deletion writes now +use the current host folder repository boundary. +Host, jump-host, Docker SSH, Proxmox discovery, Docker console jump-host, +file-manager activity, host metrics, terminal SSH auth, and tunnel endpoint +credential plus credential deployment and command history host-flag resolution +plus snippet execution, terminal OPKSSH/activity, Vault OIDC profile, and +Wake-on-LAN host, internal host list, host-key verification metadata, +credential, and shared override read/write models now use the current host +resolution repository boundary. +Host metrics, host metrics viewer, tmux monitor, Docker, tunnel, and Proxmox +unlock gates now use `DataCrypto` directly instead of `SimpleDBOps`. +Legacy `SimpleDBOps` compatibility helper has been removed after migrated route +and utility paths stopped importing it. +Permission manager host owner checks now use the current host resolution +repository boundary. + +Gray rollout status: the branch is expanding the repository boundary slice while +keeping every migrated domain behind `DATABASE_LAYER_REPOSITORY_ROLLOUT`, which +supports `all`, `off`, and a comma-separated allowlist for controlled gray +targets. + +## 1. Background + +Termix currently uses an encrypted SQLite snapshot model: + +```text +db.sqlite.encrypted + -> decrypt on startup + -> open as in-memory SQLite + -> runtime reads/writes memory database + -> serialize whole database + -> encrypt and write snapshot back to disk +``` + +This works well for small single-instance deployments, but it creates several long-term problems: + +- Recent writes can be lost if the process crashes before the snapshot save runs. +- Direct database writes can forget to call `DatabaseSaveTrigger`, causing data to exist only in memory. +- Saving requires serializing and encrypting the whole database, so cost grows with database size. +- The architecture cannot safely support multiple backend instances. +- It blocks clean PostgreSQL/MySQL support because business code depends directly on SQLite and Drizzle SQLite tables. + +The refactor should keep Termix secure while moving persistence to real database writes. + +## 2. Goals + +- Support persistent database backends: + - SQLite + - PostgreSQL + - MySQL/MariaDB +- Keep sensitive data encrypted at the field level. +- Remove dependence on full in-memory database snapshots. +- Introduce a database adapter boundary. +- Move direct database access behind repositories/services. +- Provide a safe upgrade path from the existing encrypted SQLite snapshot. +- Preserve existing user data, encryption keys, credentials, hosts, settings, audit records, and feature data. +- Keep runtime-only state in memory: + - SSH clients + - WebSocket sessions + - tunnels + - metrics polling sessions + - transfer runtime state +- Make migrations deterministic, idempotent, and rollback-safe. + +## 3. Non-Goals + +- Do not rewrite the whole backend at once. +- Do not force existing users to configure PostgreSQL or MySQL during normal upgrade. +- Do not remove SQLite support. +- Do not store SSH live sessions, WebSocket handles, or tunnel process handles in the database. +- Do not encrypt every field blindly. Searchable and sortable metadata should stay queryable unless it is sensitive. +- Do not change user-facing behavior unless required by the storage model. + +## 4. Current Architecture + +### 4.1 Database Runtime + +- Database initialization lives in `src/backend/database/db/index.ts`. +- `actualDbPath` is `:memory:`. +- Encrypted database files are decrypted into memory on startup. +- Runtime database access uses Drizzle over `better-sqlite3`. +- On save, the whole memory database is serialized and encrypted. + +### 4.2 Save Behavior + +- Historical `SimpleDBOps.insert/update/delete` writes used to trigger + `DatabaseSaveTrigger`; migrated write paths now use repository save hooks. +- Some routes call `getDb().insert/update/delete` or raw SQLite directly. +- Direct writes must manually call `DatabaseSaveTrigger.triggerSave()`. +- This creates a persistence gap when direct writes forget to trigger saves. + +### 4.3 Runtime State + +The following state is intentionally memory-only today and should remain memory-only: + +- Terminal sessions in `terminal-session-manager.ts` +- File manager sessions in `file-manager.ts` +- Metrics sessions and caches in `host-metrics-sessions.ts` and `host-metrics-state.ts` +- Tunnel runtime maps in `tunnel.ts` +- Pending OPKSSH/Vault/OIDC authentication sessions + +### 4.4 Schema State + +- Schema is declared with `sqliteTable` in `src/backend/database/db/schema.ts`. +- Additional schema setup is done manually through `CREATE TABLE IF NOT EXISTS`. +- Schema migration uses many `addColumnIfNotExists` calls. +- This is tightly coupled to SQLite. + +## 5. Target Architecture + +```text +Backend Feature Code + -> Service Layer + -> Repository Layer + -> Database Adapter + -> SQLite + -> PostgreSQL + -> MySQL/MariaDB + -> Field Encryption Boundary + -> Persistent Database +``` + +### 5.1 Database Adapter + +Introduce a database runtime module responsible for: + +- Reading database configuration. +- Creating the correct database client. +- Running migrations. +- Exposing a typed query context. +- Handling database-specific transaction behavior. +- Hiding dialect-specific differences from business logic. + +Proposed configuration: + +```env +DB_TYPE=sqlite +DATABASE_URL=file:/app/data/termix.sqlite + +# or +DB_TYPE=postgres +DATABASE_URL=postgres://termix:password@postgres:5432/termix + +# or +DB_TYPE=mysql +DATABASE_URL=mysql://termix:password@mysql:3306/termix + +SYSTEM_KEY=... +``` + +SQLite should remain the default. + +### 5.2 Repository Layer + +Business code should stop calling `getDb()` directly. + +Examples: + +```ts +hostRepository.getById(userId, hostId); +hostRepository.create(userId, input); +credentialRepository.resolveForHost(userId, hostId); +sessionRepository.create(userId, sessionData); +auditRepository.write(event); +settingsRepository.get(key); +``` + +Repositories should own: + +- Query construction +- Data mapping +- Field encryption/decryption +- Stable record-id enforcement before encrypting field values +- Permission-aware reads where appropriate +- Transaction participation +- Database-specific compatibility + +### 5.3 Service Layer + +Services should own business workflows: + +- Host creation/update/delete +- Credential resolution +- User/session lifecycle +- RBAC operations +- Import/export +- Migration orchestration + +Services can call multiple repositories inside one transaction. + +### 5.4 Runtime State Boundary + +Runtime state should remain in process memory and should not be migrated into the database: + +- SSH connection objects +- PTY streams +- SFTP handles +- WebSocket instances +- Timers +- Child processes +- Pending MFA/auth prompts + +The database should store only durable metadata: + +- session recording metadata +- audit events +- user open tabs, if still needed +- tunnel definitions, not live tunnel handles +- metrics history, not active polling state + +## 6. Security Model + +### 6.1 Keep Field-Level Encryption + +Sensitive fields must be encrypted before entering any persistent database: + +- SSH passwords +- SSH private keys +- SSH key passphrases +- credential secrets +- TOTP secrets and backup codes +- OAuth/OIDC client secrets +- Vault tokens +- OPKSSH tokens/cert cache secrets +- API tokens or token material +- RDP/VNC/Telnet passwords + +### 6.2 Keep Queryable Metadata Plain + +Some data should remain queryable: + +- numeric IDs +- user IDs +- host names +- folders +- tags +- enable flags +- timestamps +- connection type +- port numbers +- status/config flags + +For fields like host IP/domain, decide explicitly: + +- Plaintext improves search, sorting, and connection setup. +- Encrypted improves confidentiality. +- If encrypted, consider additional blind indexes for search. + +### 6.3 Envelope Encryption + +Recommended model: + +```text +SYSTEM_KEY + -> wraps application/database keys + -> wraps per-user data keys + -> per-user data keys encrypt user-owned secrets +``` + +Each encrypted field should include enough metadata for future rotation: + +```json +{ + "v": 2, + "alg": "aes-256-gcm", + "kid": "user-key-id", + "iv": "...", + "tag": "...", + "ct": "..." +} +``` + +### 6.4 Key Rotation + +The new design should support: + +- Detecting encryption version. +- Reading old ciphertext. +- Writing new ciphertext. +- Lazy migration on write. +- Optional explicit re-encryption job. + +### 6.5 Database-Level Encryption + +Field-level encryption is required for all database types. + +Optional database/file encryption: + +- SQLite can optionally use encrypted file storage later. +- PostgreSQL/MySQL should rely on field encryption plus deployment-level disk encryption/TLS. +- Do not require full database encryption for correctness. + +## 7. Database Support Strategy + +### 7.1 SQLite + +SQLite remains the default for simple self-hosted installs. + +Requirements: + +- Direct persistent SQLite file, not in-memory snapshot. +- WAL mode if safe for the deployment model. +- Proper shutdown handling. +- No full database serialize on every save. + +### 7.2 PostgreSQL + +PostgreSQL should be the preferred production multi-user backend. + +Requirements: + +- Connection pool. +- Transaction support. +- Native boolean/timestamp handling. +- JSONB where useful. +- Migration support. +- TLS support through connection string options. + +### 7.3 MySQL/MariaDB + +MySQL/MariaDB support should be implemented after SQLite and PostgreSQL are stable. + +Requirements: + +- Connection pool. +- Compatible migrations. +- JSON/text behavior reviewed. +- Timestamp/default behavior reviewed. +- `RETURNING` differences handled at repository/adapter level. + +### 7.4 Dialect Differences to Handle + +- Boolean storage +- Auto-increment IDs +- Timestamp defaults +- JSON columns +- `RETURNING` +- Upsert syntax +- Case sensitivity and collations +- Foreign key enforcement +- Transaction isolation +- Raw SQL fragments + +## 8. Migration Strategy + +### 8.1 Default Upgrade Path + +Default user upgrade should not require external database setup. + +Recommended default: + +```text +old encrypted SQLite snapshot + -> decrypt with existing logic + -> normalize old schema + -> migrate to new persistent SQLite + -> keep old snapshot as backup +``` + +PostgreSQL/MySQL migration should be explicit: + +```bash +termix migrate-db --from sqlite --to postgres +termix migrate-db --from sqlite --to mysql +``` + +or later through an admin UI. + +### 8.2 Backup Rules + +Before migration: + +- Verify source database can be decrypted. +- Verify target database can be connected. +- Verify target schema can be migrated. +- Create a timestamped backup of the old encrypted database. +- Do not delete the old encrypted snapshot automatically. + +Suggested backup naming: + +```text +db.sqlite.encrypted.pre-db-refactor-YYYYMMDD-HHmmss +``` + +### 8.3 Migration Marker + +New databases should store migration state: + +```text +schema_migrations + id + version + name + applied_at + checksum + +system_migrations + key + value + applied_at +``` + +The old snapshot migration should have a one-shot marker: + +```text +legacy_snapshot_migrated = true +legacy_snapshot_source_hash = ... +legacy_snapshot_migrated_at = ... +``` + +### 8.4 Idempotency + +Migration must be safe to rerun after interruption. + +Rules: + +- Never destroy the source before full success. +- Use unique keys or deterministic IDs where possible. +- Commit in table-level or batch-level transactions. +- Write migration markers only after successful commit. +- On startup, detect partial migration and either resume or fail safely. + +### 8.5 Dry Run + +Add a dry-run mode: + +```bash +termix migrate-db --dry-run +``` + +Dry run should report: + +- Source database detected +- Source schema version +- Target database type +- Target reachability +- Tables to migrate +- Row counts +- Unsupported old schema issues +- Estimated warnings + +### 8.6 Old Version Compatibility + +Users may upgrade from older Termix versions. + +Migration must: + +- Run old schema normalization first. +- Add missing columns in the legacy reader if needed. +- Handle absent optional tables. +- Handle older ciphertext formats. +- Handle old settings keys. + +### 8.7 Failure Behavior + +If migration fails: + +- New app should not start with an empty database. +- Old encrypted snapshot should remain untouched. +- Database-layer gray startup creates a one-time pre-upgrade backup before + opening the existing SQLite snapshot, and fails closed if that backup cannot + be written unless `DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP=1` is explicitly set. +- Error logs should identify the failed stage. +- User should get recovery instructions. +- Retrying should be safe. + +## 9. Implementation Phases + +### Phase 0: Audit and Design Lock + +Estimated time: 2-3 days + +Tasks: + +- Inventory all database tables. +- Inventory all `getDb()`, `getSqlite()`, raw SQL, and `DatabaseSaveTrigger` usage. +- Classify tables by domain: + - auth/users + - hosts/credentials + - RBAC/sharing + - terminal/session logs + - file manager + - snippets + - metrics/alerts + - homepage/preferences + - tunnels/proxmox/guacamole/vault/opkssh +- Mark sensitive fields. +- Decide SQLite/PostgreSQL/MySQL type mapping. +- Decide migration framework. + +Deliverables: + +- Database access inventory. +- Sensitive field map. +- Final adapter/repository interface proposal. + +### Phase 1: Adapter Foundation + +Estimated time: 3-5 days + +Tasks: + +- Introduce database config parser. +- Introduce adapter interface. +- Add SQLite persistent adapter. +- Add migration table. +- Add transaction helper. +- Keep old code path running. +- Add tests for config and adapter initialization. + +Deliverables: + +- `DB_TYPE=sqlite` persistent adapter works in isolation. +- No business routes migrated yet. + +### Phase 2: Repository Skeleton + +Estimated time: 4-6 days + +Tasks: + +- Create repository interfaces. +- Implement initial SQLite-backed repositories. +- Add encryption boundary helpers. +- Add repository tests with SQLite. +- Prevent new direct `getDb()` usage in new code. + +Initial repositories: + +- settings +- users +- sessions +- hosts +- credentials +- audit + +Deliverables: + +- Core repositories compile and pass tests. +- Existing routes can still run through compatibility shims. + +### Phase 3: Core Vertical Slice + +Estimated time: 1-2 weeks + +Tasks: + +- Migrate user/session auth flows. +- Migrate settings routes. +- Migrate hosts CRUD. +- Migrate credentials CRUD and credential resolution. +- Migrate host resolver and permission-critical reads. +- Add transaction coverage for multi-table operations. +- Verify field encryption behavior. + +Acceptance criteria: + +- User login/logout/session validation works. +- Host create/update/delete works. +- Credential create/update/delete/resolve works. +- Existing encrypted user data remains readable. +- No snapshot save trigger needed for migrated core paths. + +### Phase 4: Feature Data Migration + +Estimated time: 1-2 weeks + +Tasks: + +- RBAC and sharing +- folders/tags +- snippets and snippet access +- file manager bookmarks/recent/pinned/shortcuts +- dashboard/homepage/preferences/open tabs +- alerts and notification channels +- metrics preferences/history/health checks +- tunnels and C2S presets +- Proxmox config +- Guacamole config +- OPKSSH/Vault records +- audit logs +- API keys + +Acceptance criteria: + +- Direct feature routes use repositories or domain services. +- Growth tables have retention strategy where appropriate. +- Runtime-only data remains memory-only. + +### Phase 5: Multi-Database Support + +Estimated time: 1-2 weeks + +Tasks: + +- Add PostgreSQL adapter. +- Add MySQL/MariaDB adapter. +- Add dialect-specific schema/migrations. +- Add CI matrix for SQLite/PostgreSQL/MySQL if feasible. +- Replace SQLite-only raw SQL in migrated paths. +- Add Docker compose examples. + +Acceptance criteria: + +- Fresh install works on SQLite. +- Fresh install works on PostgreSQL. +- Fresh install works on MySQL/MariaDB. +- Core CRUD tests pass across supported databases. + +### Phase 6: Legacy Snapshot Migration + +Estimated time: 1 week + +Tasks: + +- Implement legacy encrypted snapshot reader. +- Normalize legacy schema in memory. +- Migrate legacy snapshot to new SQLite. +- Keep backup of old encrypted snapshot. +- Add dry-run. +- Add migration logs. +- Add recovery documentation. + +Acceptance criteria: + +- Existing encrypted SQLite snapshot migrates to new persistent SQLite. +- Migration can be retried safely. +- Failed migration leaves old data untouched. + +### Phase 7: External Database Migration Tool + +Estimated time: 3-5 days + +Tasks: + +- Add CLI or admin-only migration command. +- Support SQLite to PostgreSQL. +- Support SQLite to MySQL/MariaDB. +- Add dry-run and validation. +- Add clear rollback instructions. + +Acceptance criteria: + +- Existing SQLite install can be migrated to PostgreSQL/MySQL explicitly. +- Migration is not automatic unless user opts in. + +### Phase 8: Cleanup and Enforcement + +Estimated time: 3-5 days + +Tasks: + +- Remove in-memory snapshot runtime path. +- Remove `DatabaseSaveTrigger` from normal persistence. +- Keep only legacy import compatibility. +- Add lint/test guard against direct database access outside approved modules. +- Update docs. + +Acceptance criteria: + +- Business modules no longer import `getDb()` directly except approved infrastructure/repository files. +- `DatabaseSaveTrigger` is no longer required for normal data durability. +- Old snapshot code is isolated to legacy migration/import. + +## 10. Testing Plan + +### 10.1 Unit Tests + +- Encryption/decryption helpers +- Repository mapping +- Adapter config parsing +- Migration idempotency +- Legacy ciphertext compatibility +- Dialect-specific value conversion + +### 10.2 Integration Tests + +Run core flows against: + +- SQLite +- PostgreSQL +- MySQL/MariaDB + +Core flows: + +- user create/login/logout/session validation +- host CRUD +- credential CRUD and host resolution +- RBAC host sharing +- snippet CRUD +- settings update +- audit write/read + +### 10.3 Migration Tests + +Fixtures: + +- latest legacy encrypted snapshot +- older legacy snapshot with missing columns +- snapshot with no optional feature tables +- snapshot with encrypted credentials +- snapshot with large metrics/audit tables + +Checks: + +- row counts match +- sensitive fields decrypt after migration +- target app starts +- rerun does not duplicate data +- failed migration preserves source + +### 10.4 Manual QA + +- Fresh SQLite install +- Fresh PostgreSQL install +- Fresh MySQL install +- Upgrade from existing encrypted SQLite install +- Switch to external DB through explicit migration +- Docker deployment +- Electron/local deployment if supported + +## 11. Rollout Plan + +### Version N + +- Add new architecture behind SQLite default. +- Auto-migrate legacy encrypted snapshot to new persistent SQLite. +- PostgreSQL/MySQL marked experimental or manual. +- Keep legacy snapshot backup. + +### Version N+1 + +- Stabilize PostgreSQL/MySQL. +- Add admin UI or CLI migration tooling. +- Improve docs and diagnostics. + +### Version N+2 + +- Remove old snapshot runtime path. +- Keep legacy import compatibility only. + +## 12. User Upgrade Behavior + +Default upgrade should be boring: + +1. User updates Termix. +2. App detects old encrypted snapshot. +3. App creates backup. +4. App migrates to new persistent SQLite. +5. App starts normally. +6. Old snapshot remains available for recovery. + +External DB migration should be opt-in: + +1. User configures target database. +2. User runs dry-run. +3. User confirms migration. +4. App migrates data. +5. App writes new DB config marker. +6. App starts on external DB. + +## 13. Operational Considerations + +### 13.1 Backups + +- SQLite: backup file copy with app stopped, or online backup API. +- PostgreSQL/MySQL: recommend native backup tooling. +- Sensitive fields remain encrypted in backups. + +### 13.2 Observability + +Add logs for: + +- adapter selected +- migration start/end +- migration table/version +- row counts per table +- legacy snapshot backup path +- encryption version warnings +- failed migration stage + +### 13.3 Performance + +Expected improvements: + +- No full database serialization on every save. +- Large audit/metrics tables no longer increase snapshot save cost. +- External DBs can handle larger multi-user installs. + +New costs: + +- Network latency for external DBs. +- Connection pool tuning. +- More complex migrations. + +### 13.4 Deployment + +Docker should support: + +- default SQLite volume +- PostgreSQL compose example +- MySQL/MariaDB compose example +- clear `DATABASE_URL` examples + +## 14. Risk Register + +| Risk | Severity | Mitigation | +| ---------------------------------------------- | -------: | ----------------------------------------------------------- | +| User data cannot decrypt after migration | Critical | Preserve key model, add fixtures, dry-run decryption checks | +| Migration partially writes target DB | High | Transactions, migration markers, idempotent batches | +| Old snapshot overwritten or deleted | Critical | Never delete source automatically, backup before migration | +| Direct DB access remains scattered | High | Repository enforcement and lint guard | +| Dialect differences break features | High | Adapter tests and DB matrix | +| Sensitive fields accidentally stored plaintext | Critical | Central encryption boundary and sensitive field map | +| Large migrations block startup too long | Medium | Progress logs, batching, optional preflight | +| External DB configuration confuses users | Medium | Keep SQLite default, make external DB opt-in | +| Runtime state accidentally persisted | Medium | Explicit runtime/persistent boundary | + +## 15. Initial Work Breakdown + +Recommended first PRs: + +1. Add database access inventory and sensitive field map. (Started in `DATABASE-LAYER-PHASE-0-AUDIT.md`) +2. Add adapter interfaces and SQLite persistent adapter skeleton. +3. Add repository skeleton for settings/users/sessions/hosts/credentials. +4. Add field encryption boundary tests. (Started) +5. Migrate settings as the first low-risk vertical slice. (Started) +6. Migrate users/sessions. +7. Migrate hosts/credentials. +8. Add legacy snapshot migration dry-run. + +## 16. Open Decisions + +- Should host IP/domain be encrypted, plaintext, or plaintext with optional privacy mode? +- Should metrics history stay in the main database or support separate retention storage? +- Should session log content remain file-based with DB metadata only? +- Should PostgreSQL be considered stable before MySQL/MariaDB? +- Should external DB migration be CLI-only first, or include admin UI from the start? +- Should old encrypted snapshot import remain forever as an import feature? + +## 17. Estimated Total Effort + +Conservative estimate for full implementation: + +- Minimum: 3 weeks with reduced scope and SQLite/PostgreSQL focus. +- Realistic: 4-6 weeks for SQLite/PostgreSQL/MySQL, migration, tests, docs. +- Safer production-grade rollout: 6-8 weeks including extensive legacy fixtures and external DB QA. + +Recommended planning estimate: 5 weeks. + +## 18. Recommended Starting Point + +Do not start by replacing every `getDb()` call. + +Start with a vertical slice: + +```text +settings -> users/sessions -> hosts -> credentials +``` + +This validates: + +- adapter shape +- repository shape +- transaction shape +- field encryption +- migration pattern +- test strategy + +After that slice works, migrate the rest domain by domain. diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index 70733745..3c73f542 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -1,19 +1,15 @@ import express from "express"; import cookieParser from "cookie-parser"; import { createCorsMiddleware } from "./utils/cors-config.js"; -import { getDb } from "./database/db/index.js"; -import { - recentActivity, - hosts, - hostAccess, - userRoles, -} from "./database/db/schema.js"; -import { eq, and, desc, inArray, or, isNull, gte, sql } from "drizzle-orm"; import { dashboardLogger } from "./utils/logger.js"; -import { SimpleDBOps } from "./utils/simple-db-ops.js"; import { AuthManager } from "./utils/auth-manager.js"; import type { AuthenticatedRequest } from "../types/index.js"; import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js"; +import { createCurrentHostResolutionRepository } from "./database/repositories/current-host-resolution-repository.js"; +import { createCurrentRbacAccessRepository } from "./database/repositories/current-rbac-access-repository.js"; +import { createCurrentRecentActivityRepository } from "./database/repositories/current-recent-activity-repository.js"; +import { createCurrentRoleRepository } from "./database/repositories/current-role-repository.js"; +import { DataCrypto } from "./utils/data-crypto.js"; const app = express(); const authManager = AuthManager.getInstance(); @@ -23,6 +19,10 @@ const serverStartTime = Date.now(); const activityRateLimiter = new Map(); const RATE_LIMIT_MS = 1000; +function isUserDataUnlocked(userId: string): boolean { + return DataCrypto.getUserDataKey(userId) !== null; +} + app.use(createCorsMiddleware()); app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); @@ -103,7 +103,7 @@ app.get("/activity/recent", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (!isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -112,16 +112,8 @@ app.get("/activity/recent", async (req, res) => { const limit = Number(req.query.limit) || 20; - const activities = await SimpleDBOps.select( - getDb() - .select() - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)) - .limit(limit), - "recent_activity", - userId, - ); + const activities = + await createCurrentRecentActivityRepository().listByUserId(userId, limit); res.json(activities); } catch (err) { @@ -168,7 +160,7 @@ app.post("/activity/log", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (!isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -223,97 +215,45 @@ app.post("/activity/log", async (req, res) => { entriesToDelete.forEach((key) => activityRateLimiter.delete(key)); } - const ownedHosts = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); + const isOwnedHost = + await createCurrentHostResolutionRepository().isHostOwnedByUser( + hostId, + userId, + ); - if (ownedHosts.length === 0) { - const userRoleIds = await getDb() - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); - - const now = new Date().toISOString(); - const sharedHosts = await getDb() - .select() - .from(hostAccess) - .where( - and( - eq(hostAccess.hostId, hostId), - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? sql`${hostAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), + if (!isOwnedHost) { + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const sharedHosts = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + userId, + roleIds, ); + const hasSharedAccess = sharedHosts.some( + (access) => access.hostId === hostId, + ); - if (sharedHosts.length === 0) { + if (!hasSharedAccess) { return res .status(404) .json({ error: "Host not found or access denied" }); } } - const result = (await SimpleDBOps.insert( - recentActivity, - "recent_activity", - { - userId, - type, - hostId, - hostName, - }, + const result = await createCurrentRecentActivityRepository().create({ userId, - )) as unknown as { id: number }; + type, + hostId, + hostName, + }); // Best-effort trim of old activity entries; failures here should not // cause the primary /activity/log request to 500. try { - const allActivities = await SimpleDBOps.select<{ - id: number; - timestamp: string; - }>( - getDb() - .select({ - id: recentActivity.id, - timestamp: recentActivity.timestamp, - }) - .from(recentActivity) - .where(eq(recentActivity.userId, userId)) - .orderBy(desc(recentActivity.timestamp)), - "recent_activity", + await createCurrentRecentActivityRepository().trimUserActivity( userId, + 100, ); - - if (allActivities.length > 100) { - const idsToDelete = allActivities - .slice(100) - .map((a) => a.id) - .filter((id) => typeof id === "number"); - - if (idsToDelete.length > 0) { - await SimpleDBOps.delete( - recentActivity, - "recent_activity", - and( - eq(recentActivity.userId, userId), - inArray(recentActivity.id, idsToDelete), - ), - ); - } - } } catch (trimErr) { dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", { operation: "trim_recent_activity", @@ -349,18 +289,14 @@ app.delete("/activity/reset", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (!isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } - await SimpleDBOps.delete( - recentActivity, - "recent_activity", - eq(recentActivity.userId, userId), - ); + await createCurrentRecentActivityRepository().deleteByUserId(userId); dashboardLogger.success("Recent activity cleared", { operation: "reset_recent_activity", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index c2e0997d..88c19bf2 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -34,27 +34,24 @@ import { DatabaseFileEncryption } from "../utils/database-file-encryption.js"; import { DatabaseMigration } from "../utils/database-migration.js"; import { UserDataExport } from "../utils/user-data-export.js"; import { AutoSSLSetup } from "../utils/auto-ssl-setup.js"; -import { eq, and } from "drizzle-orm"; +import { createCurrentCredentialRepository } from "./repositories/current-credential-repository.js"; +import { createCurrentDismissedAlertRepository } from "./repositories/current-dismissed-alert-repository.js"; +import { createCurrentFileManagerBookmarkRepository } from "./repositories/current-file-manager-bookmark-repository.js"; +import { createCurrentHostRepository } from "./repositories/current-host-repository.js"; +import { createCurrentSettingsRepository } from "./repositories/current-settings-repository.js"; +import { createCurrentSshCredentialUsageRepository } from "./repositories/current-ssh-credential-usage-repository.js"; +import { createCurrentUserRepository } from "./repositories/current-user-repository.js"; +import { getRepositoryRolloutStatus } from "./repositories/repository-rollout.js"; +import { withCurrentSqliteForeignKeysDisabled } from "./runtime/sqlite-foreign-key-boundary.js"; import { parseUserAgent } from "../utils/user-agent-parser.js"; import { getProxyAgent } from "../utils/proxy-agent.js"; -import { - users, - hosts, - sshCredentials, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - dismissedAlerts, - sshCredentialUsage, - settings, -} from "./db/schema.js"; import type { CacheEntry, GitHubRelease, GitHubAPIResponse, AuthenticatedRequest, } from "../../types/index.js"; -import { getDb, DatabaseSaveTrigger } from "./db/index.js"; +import { DatabaseSaveTrigger } from "./db/index.js"; import Database from "better-sqlite3"; import { fileURLToPath } from "url"; @@ -70,6 +67,45 @@ const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); app.use(createCorsMiddleware()); +type SettingData = { + key: string; + value: string; +}; + +function shouldExportSetting(key: string): boolean { + return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_"); +} + +async function getExportableSettings(): Promise { + const settingsRows = await createCurrentSettingsRepository().listAll(); + + return settingsRows.filter((setting) => shouldExportSetting(setting.key)); +} + +function writeSettingsToExportDatabase( + exportDb: Database.Database, + settingsRows: SettingData[], +): void { + const insertSetting = exportDb.prepare(` + INSERT INTO settings (key, value) + VALUES (?, ?) + `); + + for (const setting of settingsRows) { + insertSetting.run(setting.key, setting.value); + } +} + +function readImportedSettings(importDb: Database.Database): SettingData[] { + return importDb + .prepare("SELECT key, value FROM settings") + .all() as SettingData[]; +} + +async function upsertImportedSetting(setting: SettingData): Promise { + await createCurrentSettingsRepository().upsert(setting.key, setting.value); +} + const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads"); const storage = multer.diskStorage({ @@ -621,12 +657,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const deviceInfo = parseUserAgent(req); - const user = await getDb().select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRepository = createCurrentUserRepository(); + const user = await userRepository.findById(userId); + if (!user) { return res.status(404).json({ error: "User not found" }); } - const isOidcUser = !!user[0].isOidc; + const isOidcUser = !!user.isOidc; if (!DataCrypto.getUserDataKey(userId)) { if (isOidcUser) { @@ -867,22 +904,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => { userRecord.totpBackupCodes || null, ); - const sshHosts = await getDb() - .select() - .from(hosts) - .where(eq(hosts.userId, userId)); + const sshHosts = + await createCurrentHostRepository().listDecryptedByUserId(userId); const insertHost = exportDb.prepare(` INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - for (const host of sshHosts) { - const decrypted = DataCrypto.decryptRecord( - "ssh_data", - host, - userId, - userDataKey, - ); + for (const decrypted of sshHosts) { insertHost.run( decrypted.id, decrypted.userId, @@ -940,22 +969,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); } - const credentials = await getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)); + const credentials = + await createCurrentCredentialRepository().listDecryptedByUserId(userId); const insertCred = exportDb.prepare(` INSERT INTO ssh_credentials (id, user_id, name, description, folder, tags, auth_type, username, password, key, private_key, public_key, key_password, key_type, detected_key_type, usage_count, last_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - for (const cred of credentials) { - const decrypted = DataCrypto.decryptRecord( - "ssh_credentials", - cred, - userId, - userDataKey, - ); + for (const decrypted of credentials) { insertCred.run( decrypted.id, decrypted.userId, @@ -979,19 +1000,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); } + const fileManagerRepository = + createCurrentFileManagerBookmarkRepository(); const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([ - getDb() - .select() - .from(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)), - getDb() - .select() - .from(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)), - getDb() - .select() - .from(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)), + fileManagerRepository.listRecentByUserId(userId), + fileManagerRepository.listPinnedByUserId(userId), + fileManagerRepository.listShortcutsByUserId(userId), ]); const insertRecent = exportDb.prepare(` @@ -1039,10 +1053,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); } - const alerts = await getDb() - .select() - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); + const dismissedAlertRepository = createCurrentDismissedAlertRepository(); + const alerts = await dismissedAlertRepository.listByUserId(userId); const insertAlert = exportDb.prepare(` INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at) VALUES (?, ?, ?, ?) @@ -1056,10 +1068,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); } - const usage = await getDb() - .select() - .from(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, userId)); + const sshCredentialUsageRepository = + createCurrentSshCredentialUsageRepository(); + const usage = await sshCredentialUsageRepository.listByUserId(userId); const insertUsage = exportDb.prepare(` INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at) VALUES (?, ?, ?, ?, ?) @@ -1074,20 +1085,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => { ); } - const settingsData = await getDb().select().from(settings); - const insertSetting = exportDb.prepare(` - INSERT INTO settings (key, value) - VALUES (?, ?) - `); - for (const setting of settingsData) { - if ( - setting.key.startsWith("reset_code_") || - setting.key.startsWith("temp_reset_token_") - ) { - continue; - } - insertSetting.run(setting.key, setting.value); - } + writeSettingsToExportDatabase(exportDb, await getExportableSettings()); } finally { exportDb.close(); } @@ -1182,19 +1180,16 @@ app.post( } const userId = (req as AuthenticatedRequest).userId; - const mainDb = getDb(); const deviceInfo = parseUserAgent(req); - const userRecords = await mainDb - .select() - .from(users) - .where(eq(users.id, userId)); + const userRepository = createCurrentUserRepository(); + const userRecord = await userRepository.findById(userId); - if (!userRecords || userRecords.length === 0) { + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const isOidcUser = !!userRecords[0].isOidc; + const isOidcUser = !!userRecord.isOidc; if (!DataCrypto.getUserDataKey(userId)) { if (isOidcUser) { @@ -1276,332 +1271,287 @@ app.post( }; try { - mainDb.$client.exec("PRAGMA foreign_keys = OFF"); - try { - const importedHosts = importDb - .prepare("SELECT * FROM ssh_data") - .all(); - for (const host of importedHosts) { - try { - const existing = await mainDb - .select() - .from(hosts) - .where( - and( - eq(hosts.userId, userId), - eq(hosts.ip, host.ip), - eq(hosts.port, host.port), - eq(hosts.username, host.username), - ), - ); - - if (existing.length > 0) { - result.summary.skippedItems++; - continue; - } - - const hostData = { - userId: userId, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder, - tags: host.tags, - pin: Boolean(host.pin), - authType: host.auth_type, - forceKeyboardInteractive: host.force_keyboard_interactive, - password: host.password, - key: host.key, - keyPassword: host.key_password, - keyType: host.key_type, - sudoPassword: host.sudo_password, - autostartPassword: host.autostart_password, - autostartKey: host.autostart_key, - autostartKeyPassword: host.autostart_key_password, - credentialId: host.credential_id || null, - overrideCredentialUsername: Boolean( - host.override_credential_username, - ), - enableTerminal: Boolean(host.enable_terminal), - enableTunnel: Boolean(host.enable_tunnel), - tunnelConnections: host.tunnel_connections, - jumpHosts: host.jump_hosts, - enableFileManager: Boolean(host.enable_file_manager), - enableDocker: Boolean(host.enable_docker), - showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar), - showFileManagerInSidebar: Boolean( - host.show_file_manager_in_sidebar, - ), - showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar), - showDockerInSidebar: Boolean(host.show_docker_in_sidebar), - showServerStatsInSidebar: Boolean( - host.show_server_stats_in_sidebar, - ), - defaultPath: host.default_path, - statsConfig: host.stats_config, - terminalConfig: host.terminal_config, - quickActions: host.quick_actions, - notes: host.notes, - useSocks5: Boolean(host.use_socks5), - socks5Host: host.socks5_host, - socks5Port: host.socks5_port, - socks5Username: host.socks5_username, - socks5Password: host.socks5_password, - socks5ProxyChain: host.socks5_proxy_chain, - createdAt: host.created_at || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - const encrypted = DataCrypto.encryptRecord( - "ssh_data", - hostData, - userId, - userDataKey, - ); - await mainDb.insert(hosts).values(encrypted); - result.summary.sshHostsImported++; - } catch (hostError) { - result.summary.errors.push( - `SSH host import error: ${hostError.message}`, - ); - } - } - } catch { - apiLogger.info("ssh_data table not found in import file, skipping"); - } - - try { - const importedCreds = importDb - .prepare("SELECT * FROM ssh_credentials") - .all(); - for (const cred of importedCreds) { - try { - const existing = await mainDb - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.userId, userId), - eq(sshCredentials.name, cred.name), - eq(sshCredentials.username, cred.username), - ), - ); - - if (existing.length > 0) { - result.summary.skippedItems++; - continue; - } - - const credData = { - userId: userId, - name: cred.name, - description: cred.description, - folder: cred.folder, - tags: cred.tags, - authType: cred.auth_type, - username: cred.username, - password: cred.password, - key: cred.key, - privateKey: cred.private_key, - publicKey: cred.public_key, - keyPassword: cred.key_password, - keyType: cred.key_type, - detectedKeyType: cred.detected_key_type, - usageCount: cred.usage_count || 0, - lastUsed: cred.last_used, - createdAt: cred.created_at || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - const encrypted = DataCrypto.encryptRecord( - "ssh_credentials", - credData, - userId, - userDataKey, - ); - await mainDb.insert(sshCredentials).values(encrypted); - result.summary.sshCredentialsImported++; - } catch (credError) { - result.summary.errors.push( - `SSH credential import error: ${credError.message}`, - ); - } - } - } catch { - apiLogger.info( - "ssh_credentials table not found in import file, skipping", - ); - } - - const fileManagerTables = [ - { - table: "file_manager_recent", - schema: fileManagerRecent, - key: "fileManagerItemsImported", - }, - { - table: "file_manager_pinned", - schema: fileManagerPinned, - key: "fileManagerItemsImported", - }, - { - table: "file_manager_shortcuts", - schema: fileManagerShortcuts, - key: "fileManagerItemsImported", - }, - ]; - - for (const { table, schema, key } of fileManagerTables) { + await withCurrentSqliteForeignKeysDisabled(async () => { try { - const importedItems = importDb - .prepare(`SELECT * FROM ${table}`) + const importedHosts = importDb + .prepare("SELECT * FROM ssh_data") .all(); - for (const item of importedItems) { + for (const host of importedHosts) { try { - const existing = await mainDb - .select() - .from(schema) - .where( - and( - eq(schema.userId, userId), - eq(schema.path, item.path), - eq(schema.name, item.name), - ), - ); + const hostRepository = createCurrentHostRepository(); + const exists = await hostRepository.existsForImportIdentity( + userId, + host.ip, + host.port, + host.username, + ); - if (existing.length > 0) { + if (exists) { result.summary.skippedItems++; continue; } - const itemData = { + const hostData = { userId: userId, - hostId: item.host_id, - name: item.name, - path: item.path, - ...(table === "file_manager_recent" && { - lastOpened: item.last_opened, - }), - ...(table === "file_manager_pinned" && { - pinnedAt: item.pinned_at, - }), - ...(table === "file_manager_shortcuts" && { - createdAt: item.created_at, - }), + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder, + tags: host.tags, + pin: Boolean(host.pin), + authType: host.auth_type, + forceKeyboardInteractive: host.force_keyboard_interactive, + password: host.password, + key: host.key, + keyPassword: host.key_password, + keyType: host.key_type, + sudoPassword: host.sudo_password, + autostartPassword: host.autostart_password, + autostartKey: host.autostart_key, + autostartKeyPassword: host.autostart_key_password, + credentialId: host.credential_id || null, + overrideCredentialUsername: Boolean( + host.override_credential_username, + ), + enableTerminal: Boolean(host.enable_terminal), + enableTunnel: Boolean(host.enable_tunnel), + tunnelConnections: host.tunnel_connections, + jumpHosts: host.jump_hosts, + enableFileManager: Boolean(host.enable_file_manager), + enableDocker: Boolean(host.enable_docker), + showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar), + showFileManagerInSidebar: Boolean( + host.show_file_manager_in_sidebar, + ), + showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar), + showDockerInSidebar: Boolean(host.show_docker_in_sidebar), + showServerStatsInSidebar: Boolean( + host.show_server_stats_in_sidebar, + ), + defaultPath: host.default_path, + statsConfig: host.stats_config, + terminalConfig: host.terminal_config, + quickActions: host.quick_actions, + notes: host.notes, + useSocks5: Boolean(host.use_socks5), + socks5Host: host.socks5_host, + socks5Port: host.socks5_port, + socks5Username: host.socks5_username, + socks5Password: host.socks5_password, + socks5ProxyChain: host.socks5_proxy_chain, + createdAt: host.created_at || new Date().toISOString(), + updatedAt: new Date().toISOString(), }; - await mainDb.insert(schema).values(itemData); - result.summary[key]++; - } catch (itemError) { + await hostRepository.createEncryptedForUser(userId, hostData); + result.summary.sshHostsImported++; + } catch (hostError) { result.summary.errors.push( - `${table} import error: ${itemError.message}`, + `SSH host import error: ${hostError.message}`, ); } } } catch { - apiLogger.info(`${table} table not found in import file, skipping`); + apiLogger.info("ssh_data table not found in import file, skipping"); } - } - try { - const importedAlerts = importDb - .prepare("SELECT * FROM dismissed_alerts") - .all(); - for (const alert of importedAlerts) { - try { - const existing = await mainDb - .select() - .from(dismissedAlerts) - .where( - and( - eq(dismissedAlerts.userId, userId), - eq(dismissedAlerts.alertId, alert.alert_id), - ), + try { + const importedCreds = importDb + .prepare("SELECT * FROM ssh_credentials") + .all(); + for (const cred of importedCreds) { + try { + const credentialRepository = + createCurrentCredentialRepository(); + const exists = + await credentialRepository.existsForImportIdentity( + userId, + cred.name, + cred.username, + ); + + if (exists) { + result.summary.skippedItems++; + continue; + } + + const credData = { + userId: userId, + name: cred.name, + description: cred.description, + folder: cred.folder, + tags: cred.tags, + authType: cred.auth_type, + username: cred.username, + password: cred.password, + key: cred.key, + privateKey: cred.private_key, + publicKey: cred.public_key, + keyPassword: cred.key_password, + keyType: cred.key_type, + detectedKeyType: cred.detected_key_type, + usageCount: cred.usage_count || 0, + lastUsed: cred.last_used, + createdAt: cred.created_at || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await credentialRepository.createEncryptedForUser( + userId, + credData, + ); + result.summary.sshCredentialsImported++; + } catch (credError) { + result.summary.errors.push( + `SSH credential import error: ${credError.message}`, ); - - if (existing.length > 0) { - result.summary.skippedItems++; - continue; } + } + } catch { + apiLogger.info( + "ssh_credentials table not found in import file, skipping", + ); + } - await mainDb.insert(dismissedAlerts).values({ - userId: userId, - alertId: alert.alert_id, - dismissedAt: alert.dismissed_at || new Date().toISOString(), - }); - result.summary.dismissedAlertsImported++; - } catch (alertError) { - result.summary.errors.push( - `Dismissed alert import error: ${alertError.message}`, + const fileManagerTables = [ + { + table: "file_manager_recent", + key: "fileManagerItemsImported", + }, + { + table: "file_manager_pinned", + key: "fileManagerItemsImported", + }, + { + table: "file_manager_shortcuts", + key: "fileManagerItemsImported", + }, + ]; + + const fileManagerRepository = + createCurrentFileManagerBookmarkRepository(); + + for (const { table, key } of fileManagerTables) { + try { + const importedItems = importDb + .prepare(`SELECT * FROM ${table}`) + .all(); + for (const item of importedItems) { + try { + const bookmark = { + hostId: item.host_id, + name: item.name, + path: item.path, + }; + const created = + table === "file_manager_recent" + ? await fileManagerRepository.createRecentForImport( + userId, + bookmark, + item.last_opened, + ) + : table === "file_manager_pinned" + ? await fileManagerRepository.createPinnedForImport( + userId, + bookmark, + item.pinned_at, + ) + : await fileManagerRepository.createShortcutForImport( + userId, + bookmark, + item.created_at, + ); + + if (created) { + result.summary[key]++; + } else { + result.summary.skippedItems++; + } + } catch (itemError) { + result.summary.errors.push( + `${table} import error: ${itemError.message}`, + ); + } + } + } catch { + apiLogger.info( + `${table} table not found in import file, skipping`, ); } } - } catch { - apiLogger.info( - "dismissed_alerts table not found in import file, skipping", - ); - } - const targetUser = await mainDb - .select() - .from(users) - .where(eq(users.id, userId)); - if (targetUser.length > 0 && targetUser[0].isAdmin) { + const dismissedAlertRepository = + createCurrentDismissedAlertRepository(); + try { - const importedSettings = importDb - .prepare("SELECT * FROM settings") + const importedAlerts = importDb + .prepare("SELECT * FROM dismissed_alerts") .all(); - for (const setting of importedSettings) { + for (const alert of importedAlerts) { try { - const existing = await mainDb - .select() - .from(settings) - .where(eq(settings.key, setting.key)); - - if (existing.length > 0) { - await mainDb - .update(settings) - .set({ value: setting.value }) - .where(eq(settings.key, setting.key)); - result.summary.settingsImported++; + const created = await dismissedAlertRepository.createForImport( + userId, + alert.alert_id, + alert.dismissed_at, + ); + if (created) { + result.summary.dismissedAlertsImported++; } else { - await mainDb.insert(settings).values({ - key: setting.key, - value: setting.value, - }); - result.summary.settingsImported++; + result.summary.skippedItems++; } - } catch (settingError) { + } catch (alertError) { result.summary.errors.push( - `Setting import error (${setting.key}): ${settingError.message}`, + `Dismissed alert import error: ${alertError.message}`, ); } } } catch { - apiLogger.info("settings table not found in import file, skipping"); + apiLogger.info( + "dismissed_alerts table not found in import file, skipping", + ); } - } else { - apiLogger.info( - "Settings import skipped - only admin users can import settings", - ); - } - mainDb.$client.exec("PRAGMA foreign_keys = ON"); - result.success = true; + const targetUser = await userRepository.findById(userId); + if (targetUser?.isAdmin) { + try { + const importedSettings = readImportedSettings(importDb); + for (const setting of importedSettings) { + try { + await upsertImportedSetting(setting); + result.summary.settingsImported++; + } catch (settingError) { + result.summary.errors.push( + `Setting import error (${setting.key}): ${settingError.message}`, + ); + } + } + } catch { + apiLogger.info( + "settings table not found in import file, skipping", + ); + } + } else { + apiLogger.info( + "Settings import skipped - only admin users can import settings", + ); + } - try { - await DatabaseSaveTrigger.forceSave("database_import"); - } catch (saveError) { - apiLogger.error( - "Failed to persist imported data to disk", - saveError, - { - operation: "import_force_save_failed", - userId, - }, - ); - } + result.success = true; + + try { + await DatabaseSaveTrigger.forceSave("database_import"); + } catch (saveError) { + apiLogger.error( + "Failed to persist imported data to disk", + saveError, + { + operation: "import_force_save_failed", + userId, + }, + ); + } + }); } finally { if (importDb) { importDb.close(); @@ -1945,6 +1895,7 @@ app.get( res.json({ migrationStatus: status, + repositoryRollout: getRepositoryRolloutStatus(), files: { unencryptedDbSize: unencryptedSize, encryptedDbSize: encryptedSize, diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index b0cb41c5..e427907f 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -25,6 +25,28 @@ let memoryDatabase: Database.Database; let isNewDatabase = false; let sqlite: Database.Database; +function getRawSettingValue(key: string): string | null { + const row = sqlite + .prepare("SELECT value FROM settings WHERE key = ?") + .get(key) as { value?: string } | undefined; + + return row?.value ?? null; +} + +function setRawSettingValue(key: string, value: string): void { + sqlite + .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") + .run(key, value); +} + +function ensureRawSettingDefault(key: string, value: string): void { + if (getRawSettingValue(key) === null) { + sqlite + .prepare("INSERT INTO settings (key, value) VALUES (?, ?)") + .run(key, value); + } +} + async function initializeDatabaseAsync(): Promise { const systemCrypto = SystemCrypto.getInstance(); @@ -615,16 +637,7 @@ async function initializeCompleteDatabase(): Promise { migrateSchema(); try { - const row = sqlite - .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") - .get(); - if (!row) { - sqlite - .prepare( - "INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')", - ) - .run(); - } + ensureRawSettingDefault("allow_registration", "true"); } catch (e) { databaseLogger.warn("Could not initialize default settings", { operation: "db_init", @@ -633,16 +646,7 @@ async function initializeCompleteDatabase(): Promise { } try { - const row = sqlite - .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") - .get(); - if (!row) { - sqlite - .prepare( - "INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')", - ) - .run(); - } + ensureRawSettingDefault("allow_password_login", "true"); } catch (e) { databaseLogger.warn("Could not initialize allow_password_login setting", { operation: "db_init", @@ -651,16 +655,7 @@ async function initializeCompleteDatabase(): Promise { } try { - const row = sqlite - .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") - .get(); - if (!row) { - sqlite - .prepare( - "INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')", - ) - .run(); - } + ensureRawSettingDefault("guac_enabled", "true"); } catch (e) { databaseLogger.warn("Could not initialize guac_enabled setting", { operation: "db_init", @@ -669,16 +664,7 @@ async function initializeCompleteDatabase(): Promise { } try { - const row = sqlite - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get(); - if (!row) { - sqlite - .prepare( - "INSERT INTO settings (key, value) VALUES ('guac_url', ?)", - ) - .run(getDefaultGuacdUrl()); - } + ensureRawSettingDefault("guac_url", getDefaultGuacdUrl()); } catch (e) { databaseLogger.warn("Could not initialize guac_url setting", { operation: "db_init", @@ -1937,31 +1923,30 @@ const migrateSchema = () => { // Migrate legacy single oidc_config settings blob into sso_providers table try { - const migrationDone = sqlite - .prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'") - .get(); + const migrationDone = getRawSettingValue("sso_migration_v1"); if (!migrationDone) { const providerCount = ( - sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as { c: number } + sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as { + c: number; + } ).c; if (providerCount === 0) { - const legacyRow = sqlite - .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") - .get() as { value: string } | undefined; - if (legacyRow) { + const legacyConfig = getRawSettingValue("oidc_config"); + if (legacyConfig) { sqlite .prepare( "INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)", ) - .run("OIDC", legacyRow.value); - databaseLogger.info("Migrated legacy oidc_config into sso_providers table", { - operation: "sso_migration_v1", - }); + .run("OIDC", legacyConfig); + databaseLogger.info( + "Migrated legacy oidc_config into sso_providers table", + { + operation: "sso_migration_v1", + }, + ); } } - sqlite - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')") - .run(); + setRawSettingValue("sso_migration_v1", "true"); } } catch (e) { databaseLogger.warn("Failed to run SSO migration v1", { @@ -2100,19 +2085,15 @@ const migrateSchema = () => { // Seed default metrics history retention setting try { - const retentionRow = sqlite - .prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'") - .get(); - if (!retentionRow) { - sqlite - .prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')") - .run(); - } + ensureRawSettingDefault("metrics_history_retention_days", "7"); } catch (e) { - databaseLogger.warn("Could not initialize metrics_history_retention_days setting", { - operation: "schema_migration", - error: e, - }); + databaseLogger.warn( + "Could not initialize metrics_history_retention_days setting", + { + operation: "schema_migration", + error: e, + }, + ); } // --- homepage begin --- @@ -2203,21 +2184,23 @@ async function saveMemoryDatabaseToFile(): Promise { } async function handlePostInitFileEncryption() { - if (!enableFileEncryption) return; - try { if (memoryDatabase) { - await saveMemoryDatabaseToFile(); + DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile); + + if (enableFileEncryption) { + await saveMemoryDatabaseToFile(); + } setInterval(() => { if (DatabaseSaveTrigger.isDirty) { saveMemoryDatabaseToFile(); } }, 5 * 60 * 1000); - - DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile); } + if (!enableFileEncryption) return; + try { const migration = new DatabaseMigration(dataDir); migration.cleanupOldBackups(); diff --git a/src/backend/database/repositories/alert-repository.test.ts b/src/backend/database/repositories/alert-repository.test.ts new file mode 100644 index 00000000..e4f6f044 --- /dev/null +++ b/src/backend/database/repositories/alert-repository.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { AlertRepository } from "./alert-repository.js"; + +describe("AlertRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL + ); + + CREATE TABLE alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + trigger_type TEXT NOT NULL, + threshold_value REAL, + threshold_duration_seconds INTEGER, + cooldown_minutes INTEGER NOT NULL DEFAULT 15, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE notification_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + config TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE alert_rule_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER NOT NULL, + channel_id INTEGER NOT NULL + ); + + CREATE TABLE alert_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + rule_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + host_name TEXT NOT NULL, + fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TEXT, + value REAL, + message TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + acknowledged INTEGER NOT NULL DEFAULT 0 + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip) + VALUES (1, 'user-1', 'alpha', '127.0.0.1'); + `); + + return new AlertRepository(context, onWrite); + } + + it("manages notification channels", async () => { + let writes = 0; + const repo = await createRepository(() => { + writes += 1; + }); + + const created = await repo.createNotificationChannel({ + userId: "user-1", + name: "Ops", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + + expect(created).toMatchObject({ + user_id: "user-1", + name: "Ops", + type: "webhook", + enabled: 1, + }); + + const updated = await repo.updateNotificationChannel(created.id, "user-1", { + name: "Ops disabled", + enabled: false, + }); + expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 }); + + expect(await repo.listNotificationChannels("user-1")).toHaveLength(1); + expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe( + false, + ); + expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe( + true, + ); + expect(await repo.listNotificationChannels("user-1")).toHaveLength(0); + expect(writes).toBe(3); + }); + + it("manages alert rules and linked channels", async () => { + const repo = await createRepository(); + const ownedChannel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Owned", + type: "ntfy", + config: '{"url":"https://ntfy.test","topic":"termix"}', + enabled: true, + }); + const foreignChannel = await repo.createNotificationChannel({ + userId: "user-2", + name: "Foreign", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + + const created = await repo.createAlertRule({ + userId: "user-1", + hostId: null, + name: "CPU high", + enabled: true, + triggerType: "cpu_threshold", + thresholdValue: 80, + thresholdDurationSeconds: 30, + cooldownMinutes: 5, + channels: [ownedChannel.id, foreignChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + + expect(created).toMatchObject({ + user_id: "user-1", + host_id: null, + name: "CPU high", + trigger_type: "cpu_threshold", + threshold_value: 80, + channels: [ownedChannel.id], + }); + + const updated = await repo.updateAlertRule(created.id, "user-1", { + name: "CPU very high", + hostId: 1, + channels: [], + now: "2026-01-02T00:00:00.000Z", + }); + expect(updated).toMatchObject({ + name: "CPU very high", + host_id: 1, + channels: [], + updated_at: "2026-01-02T00:00:00.000Z", + }); + + const rules = await repo.listAlertRules("user-1"); + expect(rules).toHaveLength(1); + expect(rules[0].channels).toEqual([]); + expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false); + expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true); + }); + + it("lists, acknowledges, and prunes firings", async () => { + const repo = await createRepository(); + const rule = await repo.createAlertRule({ + userId: "user-1", + hostId: 1, + name: "Host offline", + enabled: true, + triggerType: "host_offline", + thresholdValue: null, + thresholdDurationSeconds: null, + cooldownMinutes: 15, + channels: [], + now: "2026-01-01T00:00:00.000Z", + }); + + await repo.createFiring({ + userId: "user-1", + ruleId: rule.id, + hostId: 1, + hostName: "alpha", + value: null, + message: "down", + severity: "critical", + }); + + const listed = await repo.listAlertFirings({ + userId: "user-1", + limit: 10, + offset: 0, + }); + expect(listed.total).toBe(1); + expect(listed.firings[0]).toMatchObject({ + rule_id: rule.id, + host_name: "alpha", + acknowledged: 0, + rule_name: "Host offline", + }); + + await repo.acknowledgeFiring(listed.firings[0].id, "user-1"); + const unacknowledged = await repo.listAlertFirings({ + userId: "user-1", + acknowledged: false, + limit: 10, + offset: 0, + }); + expect(unacknowledged.total).toBe(0); + + await repo.acknowledgeAllFirings("user-1"); + repo.pruneFiringsOlderThan("user-1", 0); + }); + + it("loads enabled rules and notification channels for the alert engine", async () => { + const repo = await createRepository(); + const channel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Ops", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + const disabledChannel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Disabled", + type: "webhook", + config: '{"url":"https://disabled.test"}', + enabled: false, + }); + const rule = await repo.createAlertRule({ + userId: "user-1", + hostId: null, + name: "CPU high", + enabled: true, + triggerType: "cpu_threshold", + thresholdValue: 90, + thresholdDurationSeconds: 0, + cooldownMinutes: 15, + channels: [channel.id, disabledChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + + expect(await repo.listEnabledRulesForHost(1)).toMatchObject([ + { + id: rule.id, + userId: "user-1", + triggerType: "cpu_threshold", + enabled: true, + }, + ]); + expect(await repo.findRuleById(rule.id)).toMatchObject({ + id: rule.id, + cooldownMinutes: 15, + }); + expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([ + { + id: channel.id, + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }, + ]); + }); + + it("loads host display names for alert payloads", async () => { + const repo = await createRepository(); + + expect(await repo.getHostDisplayName(1)).toBe("alpha"); + expect(await repo.getHostDisplayName(999)).toBeNull(); + }); + + it("deletes all alert data for a user", async () => { + let writes = 0; + const repo = await createRepository(() => { + writes += 1; + }); + + const userChannel = await repo.createNotificationChannel({ + userId: "user-1", + name: "Ops", + type: "webhook", + config: '{"url":"https://example.test"}', + enabled: true, + }); + const otherChannel = await repo.createNotificationChannel({ + userId: "user-2", + name: "Other", + type: "webhook", + config: '{"url":"https://other.test"}', + enabled: true, + }); + const userRule = await repo.createAlertRule({ + userId: "user-1", + hostId: 1, + name: "CPU high", + enabled: true, + triggerType: "cpu_threshold", + thresholdValue: 80, + thresholdDurationSeconds: 30, + cooldownMinutes: 5, + channels: [userChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + const otherRule = await repo.createAlertRule({ + userId: "user-2", + hostId: null, + name: "Memory high", + enabled: true, + triggerType: "memory_threshold", + thresholdValue: 90, + thresholdDurationSeconds: 60, + cooldownMinutes: 10, + channels: [otherChannel.id], + now: "2026-01-01T00:00:00.000Z", + }); + await repo.createFiring({ + userId: "user-1", + ruleId: userRule.id, + hostId: 1, + hostName: "alpha", + value: 95, + message: "high", + severity: "warning", + }); + await repo.createFiring({ + userId: "user-2", + ruleId: otherRule.id, + hostId: 1, + hostName: "alpha", + value: 91, + message: "other", + severity: "warning", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toEqual({ + firingsDeleted: 1, + ruleLinksDeleted: 1, + rulesDeleted: 1, + channelsDeleted: 1, + }); + await expect(repo.deleteByUserId("missing")).resolves.toEqual({ + firingsDeleted: 0, + ruleLinksDeleted: 0, + rulesDeleted: 0, + channelsDeleted: 0, + }); + + expect(await repo.listNotificationChannels("user-1")).toEqual([]); + expect(await repo.listAlertRules("user-1")).toEqual([]); + expect( + await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }), + ).toEqual({ firings: [], total: 0 }); + expect(await repo.listNotificationChannels("user-2")).toHaveLength(1); + expect(await repo.listAlertRules("user-2")).toHaveLength(1); + expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1); + expect(writes).toBe(7); + }); +}); diff --git a/src/backend/database/repositories/alert-repository.ts b/src/backend/database/repositories/alert-repository.ts new file mode 100644 index 00000000..8bb99dd2 --- /dev/null +++ b/src/backend/database/repositories/alert-repository.ts @@ -0,0 +1,622 @@ +import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm"; +import { + alertFirings, + alertRuleChannels, + alertRules, + hosts, + notificationChannels, +} from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +type AlertRuleRecord = typeof alertRules.$inferSelect; +type NotificationChannelRecord = typeof notificationChannels.$inferSelect; +type AlertFiringRecord = typeof alertFirings.$inferSelect; + +export interface NotificationChannelRow { + id: number; + user_id: string; + name: string; + type: string; + config: string; + enabled: number; + created_at: string; +} + +export interface AlertRuleRow { + id: number; + user_id: string; + host_id: number | null; + name: string; + enabled: number; + trigger_type: string; + threshold_value: number | null; + threshold_duration_seconds: number | null; + cooldown_minutes: number; + created_at: string; + updated_at: string; +} + +export interface AlertRuleWithChannelsRow extends AlertRuleRow { + channels: number[]; +} + +export interface AlertFiringRow { + id: number; + user_id: string; + rule_id: number; + host_id: number; + host_name: string; + fired_at: string; + resolved_at: string | null; + value: number | null; + message: string; + severity: string; + acknowledged: number; + rule_name: string | null; +} + +export interface AlertEngineRule { + id: number; + userId: string; + hostId: number | null; + name: string; + enabled: boolean; + triggerType: string; + thresholdValue: number | null; + thresholdDurationSeconds: number | null; + cooldownMinutes: number; +} + +export interface AlertEngineChannel { + id: number; + type: string; + config: string; + enabled: boolean; +} + +export class AlertRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listNotificationChannels( + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(notificationChannels) + .where(eq(notificationChannels.userId, userId)) + .orderBy(notificationChannels.id); + + return rows.map(mapChannelRow); + } + + async findNotificationChannelForUser( + id: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(notificationChannels) + .where( + and( + eq(notificationChannels.id, id), + eq(notificationChannels.userId, userId), + ), + ) + .limit(1); + + return rows[0] ? mapChannelRow(rows[0]) : null; + } + + async createNotificationChannel(input: { + userId: string; + name: string; + type: string; + config: string; + enabled: boolean; + }): Promise { + const [created] = await this.context.drizzle + .insert(notificationChannels) + .values({ + userId: input.userId, + name: input.name, + type: input.type, + config: input.config, + enabled: input.enabled, + }) + .returning(); + + await this.afterWrite(); + return mapChannelRow(created); + } + + async updateNotificationChannel( + id: number, + userId: string, + input: { + name?: string; + type?: string; + config?: string; + enabled?: boolean; + }, + ): Promise { + if (Object.keys(input).length === 0) { + return this.findNotificationChannelForUser(id, userId); + } + + const [updated] = await this.context.drizzle + .update(notificationChannels) + .set(input) + .where( + and( + eq(notificationChannels.id, id), + eq(notificationChannels.userId, userId), + ), + ) + .returning(); + + if (!updated) return null; + await this.afterWrite(); + return mapChannelRow(updated); + } + + async deleteNotificationChannel( + id: number, + userId: string, + ): Promise { + const deleted = await this.context.drizzle + .delete(notificationChannels) + .where( + and( + eq(notificationChannels.id, id), + eq(notificationChannels.userId, userId), + ), + ) + .returning({ id: notificationChannels.id }); + + if (deleted.length === 0) return false; + await this.afterWrite(); + return true; + } + + async listAlertRules(userId: string): Promise { + const rules = await this.context.drizzle + .select() + .from(alertRules) + .where(eq(alertRules.userId, userId)) + .orderBy(alertRules.id); + + const result: AlertRuleWithChannelsRow[] = []; + for (const rule of rules) { + result.push({ + ...mapRuleRow(rule), + channels: await this.listChannelIdsForRule(rule.id), + }); + } + return result; + } + + async createAlertRule(input: { + userId: string; + hostId: number | null; + name: string; + enabled: boolean; + triggerType: string; + thresholdValue: number | null; + thresholdDurationSeconds: number | null; + cooldownMinutes: number; + channels: number[]; + now: string; + }): Promise { + const [created] = await this.context.drizzle + .insert(alertRules) + .values({ + userId: input.userId, + hostId: input.hostId, + name: input.name, + enabled: input.enabled, + triggerType: input.triggerType, + thresholdValue: input.thresholdValue, + thresholdDurationSeconds: input.thresholdDurationSeconds, + cooldownMinutes: input.cooldownMinutes, + createdAt: input.now, + updatedAt: input.now, + }) + .returning(); + + const channels = await this.replaceRuleChannels( + created.id, + input.userId, + input.channels, + ); + await this.afterWrite(); + return { ...mapRuleRow(created), channels }; + } + + async findAlertRuleForUser( + id: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(alertRules) + .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))) + .limit(1); + + return rows[0] ? mapRuleRow(rows[0]) : null; + } + + async updateAlertRule( + id: number, + userId: string, + input: { + name?: string; + hostId?: number | null; + enabled?: boolean; + triggerType?: string; + thresholdValue?: number | null; + thresholdDurationSeconds?: number | null; + cooldownMinutes?: number; + channels?: number[]; + now: string; + }, + ): Promise { + const [updated] = await this.context.drizzle + .update(alertRules) + .set({ + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.hostId !== undefined ? { hostId: input.hostId } : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + ...(input.triggerType !== undefined + ? { triggerType: input.triggerType } + : {}), + ...(input.thresholdValue !== undefined + ? { thresholdValue: input.thresholdValue } + : {}), + ...(input.thresholdDurationSeconds !== undefined + ? { thresholdDurationSeconds: input.thresholdDurationSeconds } + : {}), + ...(input.cooldownMinutes !== undefined + ? { cooldownMinutes: input.cooldownMinutes } + : {}), + updatedAt: input.now, + }) + .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))) + .returning(); + + if (!updated) return null; + + const channels = + input.channels === undefined + ? await this.listChannelIdsForRule(id) + : await this.replaceRuleChannels(id, userId, input.channels); + + await this.afterWrite(); + return { ...mapRuleRow(updated), channels }; + } + + async deleteAlertRule(id: number, userId: string): Promise { + const deleted = await this.context.drizzle + .delete(alertRules) + .where(and(eq(alertRules.id, id), eq(alertRules.userId, userId))) + .returning({ id: alertRules.id }); + + if (deleted.length === 0) return false; + await this.afterWrite(); + return true; + } + + async listAlertFirings(input: { + userId: string; + acknowledged?: boolean; + limit: number; + offset: number; + }): Promise<{ firings: AlertFiringRow[]; total: number }> { + const filters = [eq(alertFirings.userId, input.userId)]; + if (input.acknowledged !== undefined) { + filters.push(eq(alertFirings.acknowledged, input.acknowledged)); + } + + const where = and(...filters); + const rows = await this.context.drizzle + .select({ + firing: alertFirings, + ruleName: alertRules.name, + }) + .from(alertFirings) + .leftJoin(alertRules, eq(alertRules.id, alertFirings.ruleId)) + .where(where) + .orderBy(desc(alertFirings.firedAt)) + .limit(input.limit) + .offset(input.offset); + + const totalRows = await this.context.drizzle + .select({ total: count() }) + .from(alertFirings) + .where(where); + + return { + firings: rows.map((row) => mapFiringRow(row.firing, row.ruleName)), + total: totalRows[0]?.total ?? 0, + }; + } + + async acknowledgeFiring(id: number, userId: string): Promise { + await this.context.drizzle + .update(alertFirings) + .set({ acknowledged: true }) + .where(and(eq(alertFirings.id, id), eq(alertFirings.userId, userId))); + await this.afterWrite(); + } + + async acknowledgeAllFirings(userId: string): Promise { + await this.context.drizzle + .update(alertFirings) + .set({ acknowledged: true }) + .where(eq(alertFirings.userId, userId)); + await this.afterWrite(); + } + + async listEnabledRulesForHost(hostId: number): Promise { + const rows = await this.context.drizzle + .select() + .from(alertRules) + .where( + and( + eq(alertRules.enabled, true), + or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)), + ), + ); + return rows.map(mapEngineRule); + } + + async listEnabledRulesForHostUser( + hostId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(alertRules) + .where( + and( + eq(alertRules.enabled, true), + eq(alertRules.userId, userId), + or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)), + ), + ); + return rows.map(mapEngineRule); + } + + async findRuleById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(alertRules) + .where(eq(alertRules.id, id)) + .limit(1); + return rows[0] ? mapEngineRule(rows[0]) : null; + } + + async createFiring(input: { + userId: string; + ruleId: number; + hostId: number; + hostName: string; + value: number | null; + message: string; + severity: string; + }): Promise { + await this.context.drizzle.insert(alertFirings).values(input); + await this.afterWrite(); + } + + pruneFiringsOlderThan(userId: string, days: number): void { + this.context.sqlite + ?.prepare( + "DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)", + ) + .run(userId, `-${days} days`); + } + + async deleteByUserId(userId: string): Promise<{ + firingsDeleted: number; + ruleLinksDeleted: number; + rulesDeleted: number; + channelsDeleted: number; + }> { + const ruleIds = ( + await this.context.drizzle + .select({ id: alertRules.id }) + .from(alertRules) + .where(eq(alertRules.userId, userId)) + ).map((row) => row.id); + const channelIds = ( + await this.context.drizzle + .select({ id: notificationChannels.id }) + .from(notificationChannels) + .where(eq(notificationChannels.userId, userId)) + ).map((row) => row.id); + + const firingRows = await this.context.drizzle + .delete(alertFirings) + .where(eq(alertFirings.userId, userId)) + .returning({ id: alertFirings.id }); + + const linkFilters = [ + ...(ruleIds.length > 0 + ? [inArray(alertRuleChannels.ruleId, ruleIds)] + : []), + ...(channelIds.length > 0 + ? [inArray(alertRuleChannels.channelId, channelIds)] + : []), + ]; + const linkRows = + linkFilters.length === 0 + ? [] + : await this.context.drizzle + .delete(alertRuleChannels) + .where(or(...linkFilters)) + .returning({ id: alertRuleChannels.id }); + + const ruleRows = await this.context.drizzle + .delete(alertRules) + .where(eq(alertRules.userId, userId)) + .returning({ id: alertRules.id }); + const channelRows = await this.context.drizzle + .delete(notificationChannels) + .where(eq(notificationChannels.userId, userId)) + .returning({ id: notificationChannels.id }); + + if ( + firingRows.length > 0 || + linkRows.length > 0 || + ruleRows.length > 0 || + channelRows.length > 0 + ) { + await this.afterWrite(); + } + + return { + firingsDeleted: firingRows.length, + ruleLinksDeleted: linkRows.length, + rulesDeleted: ruleRows.length, + channelsDeleted: channelRows.length, + }; + } + + async listEnabledChannelsForRule( + ruleId: number, + ): Promise { + const rows = await this.context.drizzle + .select({ + id: notificationChannels.id, + type: notificationChannels.type, + config: notificationChannels.config, + enabled: notificationChannels.enabled, + }) + .from(notificationChannels) + .innerJoin( + alertRuleChannels, + eq(alertRuleChannels.channelId, notificationChannels.id), + ) + .where( + and( + eq(alertRuleChannels.ruleId, ruleId), + eq(notificationChannels.enabled, true), + ), + ); + + return rows; + } + + async getHostDisplayName(hostId: number): Promise { + const rows = await this.context.drizzle + .select({ name: hosts.name, ip: hosts.ip }) + .from(hosts) + .where(eq(hosts.id, hostId)) + .limit(1); + + const row = rows[0]; + return row ? row.name || row.ip : null; + } + + private async replaceRuleChannels( + ruleId: number, + userId: string, + channelIds: number[], + ): Promise { + await this.context.drizzle + .delete(alertRuleChannels) + .where(eq(alertRuleChannels.ruleId, ruleId)); + + const linked: number[] = []; + for (const channelId of channelIds) { + const channel = await this.findNotificationChannelForUser( + channelId, + userId, + ); + if (!channel) continue; + await this.context.drizzle + .insert(alertRuleChannels) + .values({ ruleId, channelId }); + linked.push(channelId); + } + return linked; + } + + private async listChannelIdsForRule(ruleId: number): Promise { + const rows = await this.context.drizzle + .select({ channelId: alertRuleChannels.channelId }) + .from(alertRuleChannels) + .where(eq(alertRuleChannels.ruleId, ruleId)); + + return rows.map((row) => row.channelId); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} + +function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow { + return { + id: row.id, + user_id: row.userId, + name: row.name, + type: row.type, + config: row.config, + enabled: row.enabled ? 1 : 0, + created_at: row.createdAt, + }; +} + +function mapRuleRow(row: AlertRuleRecord): AlertRuleRow { + return { + id: row.id, + user_id: row.userId, + host_id: row.hostId, + name: row.name, + enabled: row.enabled ? 1 : 0, + trigger_type: row.triggerType, + threshold_value: row.thresholdValue, + threshold_duration_seconds: row.thresholdDurationSeconds, + cooldown_minutes: row.cooldownMinutes, + created_at: row.createdAt, + updated_at: row.updatedAt, + }; +} + +function mapFiringRow( + row: AlertFiringRecord, + ruleName: string | null, +): AlertFiringRow { + return { + id: row.id, + user_id: row.userId, + rule_id: row.ruleId, + host_id: row.hostId, + host_name: row.hostName, + fired_at: row.firedAt, + resolved_at: row.resolvedAt, + value: row.value, + message: row.message, + severity: row.severity, + acknowledged: row.acknowledged ? 1 : 0, + rule_name: ruleName, + }; +} + +function mapEngineRule(row: AlertRuleRecord): AlertEngineRule { + return { + id: row.id, + userId: row.userId, + hostId: row.hostId, + name: row.name, + enabled: row.enabled, + triggerType: row.triggerType, + thresholdValue: row.thresholdValue, + thresholdDurationSeconds: row.thresholdDurationSeconds, + cooldownMinutes: row.cooldownMinutes, + }; +} diff --git a/src/backend/database/repositories/api-key-repository.test.ts b/src/backend/database/repositories/api-key-repository.test.ts new file mode 100644 index 00000000..29fc64c1 --- /dev/null +++ b/src/backend/database/repositories/api-key-repository.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { ApiKeyRepository } from "./api-key-repository.js"; + +describe("ApiKeyRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite?: () => void): Promise<{ + apiKeys: ApiKeyRepository; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT, + last_used_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'admin', 'hash'), + ('user-2', 'target', 'hash'); + `); + + return { + apiKeys: new ApiKeyRepository(context, onWrite), + }; + } + + it("creates, lists, finds, updates last used time, and deletes keys", async () => { + const repo = await createRepository(); + + await repo.apiKeys.create({ + id: "key-1", + userId: "user-2", + name: "deploy", + tokenHash: "hash", + tokenPrefix: "tmx_12345678", + createdAt: "2026-06-26T00:00:00.000Z", + expiresAt: null, + lastUsedAt: null, + isActive: true, + }); + + expect((await repo.apiKeys.findById("key-1"))?.name).toBe("deploy"); + expect( + (await repo.apiKeys.listActiveByTokenPrefix("tmx_12345678")).map( + (key) => key.id, + ), + ).toEqual(["key-1"]); + expect(await repo.apiKeys.listAllWithUsers()).toMatchObject([ + { + id: "key-1", + userId: "user-2", + username: "target", + tokenPrefix: "tmx_12345678", + }, + ]); + + await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z"); + expect((await repo.apiKeys.findById("key-1"))?.lastUsedAt).toBe( + "2026-06-26T01:00:00.000Z", + ); + + expect((await repo.apiKeys.delete("key-1"))?.name).toBe("deploy"); + expect(await repo.apiKeys.findById("key-1")).toBeNull(); + }); + + it("runs the write hook after key writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.apiKeys.create({ + id: "key-1", + userId: "user-1", + name: "ops", + tokenHash: "hash", + tokenPrefix: "tmx_87654321", + isActive: true, + }); + await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z"); + await repo.apiKeys.delete("key-1"); + + expect(writeCount).toBe(3); + }); + + it("deletes all API keys for a user", async () => { + const repo = await createRepository(); + + await repo.apiKeys.create({ + id: "key-1", + userId: "user-2", + name: "deploy", + tokenHash: "hash-1", + tokenPrefix: "tmx_11111111", + isActive: true, + }); + await repo.apiKeys.create({ + id: "key-2", + userId: "user-2", + name: "ops", + tokenHash: "hash-2", + tokenPrefix: "tmx_22222222", + isActive: true, + }); + await repo.apiKeys.create({ + id: "key-3", + userId: "user-1", + name: "admin", + tokenHash: "hash-3", + tokenPrefix: "tmx_33333333", + isActive: true, + }); + + await expect(repo.apiKeys.deleteByUserId("user-2")).resolves.toBe(2); + + expect(await repo.apiKeys.findById("key-1")).toBeNull(); + expect(await repo.apiKeys.findById("key-2")).toBeNull(); + expect((await repo.apiKeys.findById("key-3"))?.userId).toBe("user-1"); + }); +}); diff --git a/src/backend/database/repositories/api-key-repository.ts b/src/backend/database/repositories/api-key-repository.ts new file mode 100644 index 00000000..eec17088 --- /dev/null +++ b/src/backend/database/repositories/api-key-repository.ts @@ -0,0 +1,103 @@ +import { eq, and } from "drizzle-orm"; +import { apiKeys, users } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type ApiKeyRecord = typeof apiKeys.$inferSelect; +export type NewApiKeyRecord = typeof apiKeys.$inferInsert; + +export interface ApiKeyListRecord { + id: string; + name: string; + userId: string; + username: string | null; + tokenPrefix: string; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + isActive: boolean; +} + +export class ApiKeyRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(apiKey: NewApiKeyRecord): Promise { + const rows = await this.context.drizzle + .insert(apiKeys) + .values(apiKey) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async listAllWithUsers(): Promise { + return this.context.drizzle + .select({ + id: apiKeys.id, + name: apiKeys.name, + userId: apiKeys.userId, + username: users.username, + tokenPrefix: apiKeys.tokenPrefix, + createdAt: apiKeys.createdAt, + expiresAt: apiKeys.expiresAt, + lastUsedAt: apiKeys.lastUsedAt, + isActive: apiKeys.isActive, + }) + .from(apiKeys) + .leftJoin(users, eq(apiKeys.userId, users.id)) + .orderBy(apiKeys.createdAt); + } + + async findById(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(apiKeys) + .where(eq(apiKeys.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async listActiveByTokenPrefix(tokenPrefix: string): Promise { + return this.context.drizzle + .select() + .from(apiKeys) + .where( + and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)), + ); + } + + async updateLastUsedAt(id: string, lastUsedAt: string): Promise { + await this.context.drizzle + .update(apiKeys) + .set({ lastUsedAt }) + .where(eq(apiKeys.id, id)); + await this.afterWrite(); + } + + async delete(id: string): Promise { + const rows = await this.context.drizzle + .delete(apiKeys) + .where(eq(apiKeys.id, id)) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(apiKeys) + .where(eq(apiKeys.userId, userId)) + .returning({ id: apiKeys.id }); + + await this.afterWrite(); + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/audit-log-repository.test.ts b/src/backend/database/repositories/audit-log-repository.test.ts new file mode 100644 index 00000000..fb97bd09 --- /dev/null +++ b/src/backend/database/repositories/audit-log-repository.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { AuditLogRepository } from "./audit-log-repository.js"; + +describe("AuditLogRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + resource_name TEXT, + details TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER NOT NULL, + error_message TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new AuditLogRepository(context, onWrite); + } + + it("creates, filters, pages, and lists actions", async () => { + const repo = await createRepository(); + + await repo.create({ + userId: "user-1", + username: "alice", + action: "create_host", + resourceType: "host", + resourceId: "1", + success: true, + timestamp: "2026-06-27T00:00:00.000Z", + }); + await repo.create({ + userId: "user-2", + username: "bob", + action: "delete_host", + resourceType: "host", + resourceId: "2", + success: false, + timestamp: "2026-06-27T01:00:00.000Z", + }); + + const page = await repo.listPage({ + filters: { + resourceType: "host", + success: false, + startDate: "2026-06-27T00:30:00.000Z", + }, + limit: 10, + offset: 0, + }); + + expect(page.total).toBe(1); + expect(page.logs[0]).toMatchObject({ + userId: "user-2", + action: "delete_host", + success: false, + }); + expect(await repo.listDistinctActions()).toEqual([ + "create_host", + "delete_host", + ]); + }); + + it("deletes logs by user id and only runs write hook for deleted rows", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create({ + userId: "user-1", + username: "alice", + action: "login", + resourceType: "auth", + success: true, + }); + await repo.create({ + userId: "user-2", + username: "bob", + action: "login", + resourceType: "auth", + success: true, + }); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(2); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(3); + expect( + ( + await repo.listPage({ + filters: {}, + limit: 10, + offset: 0, + }) + ).logs.map((log) => log.userId), + ).toEqual(["user-2"]); + }); +}); diff --git a/src/backend/database/repositories/audit-log-repository.ts b/src/backend/database/repositories/audit-log-repository.ts new file mode 100644 index 00000000..6356674a --- /dev/null +++ b/src/backend/database/repositories/audit-log-repository.ts @@ -0,0 +1,135 @@ +import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"; +import { auditLogs } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type AuditLogRecord = typeof auditLogs.$inferSelect; +export type NewAuditLogRecord = typeof auditLogs.$inferInsert; + +export type AuditLogFilters = { + userId?: string; + action?: string; + resourceType?: string; + success?: boolean; + startDate?: string; + endDate?: string; +}; + +export type AuditLogPage = { + logs: AuditLogRecord[]; + total: number; +}; + +const PRUNE_MAX = 10000; +const PRUNE_TARGET = 9000; + +export class AuditLogRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(entry: NewAuditLogRecord): Promise { + await this.context.drizzle.insert(auditLogs).values(entry); + await this.pruneIfNeeded(); + await this.afterWrite(); + } + + async listPage(input: { + filters: AuditLogFilters; + limit: number; + offset: number; + }): Promise { + const whereClause = this.buildWhere(input.filters); + + const [logs, totalResult] = await Promise.all([ + this.context.drizzle + .select() + .from(auditLogs) + .where(whereClause) + .orderBy(desc(auditLogs.timestamp)) + .limit(input.limit) + .offset(input.offset), + this.context.drizzle + .select({ count: sql`COUNT(*)` }) + .from(auditLogs) + .where(whereClause), + ]); + + return { + logs, + total: totalResult[0]?.count ?? 0, + }; + } + + async listDistinctActions(): Promise { + const rows = await this.context.drizzle + .selectDistinct({ action: auditLogs.action }) + .from(auditLogs) + .orderBy(asc(auditLogs.action)); + + return rows.map((row) => row.action); + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(auditLogs) + .where(eq(auditLogs.userId, userId)) + .returning({ id: auditLogs.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private buildWhere(filters: AuditLogFilters) { + const conditions = []; + + if (filters.userId) conditions.push(eq(auditLogs.userId, filters.userId)); + if (filters.action) conditions.push(eq(auditLogs.action, filters.action)); + if (filters.resourceType) { + conditions.push(eq(auditLogs.resourceType, filters.resourceType)); + } + if (filters.success !== undefined) { + conditions.push(eq(auditLogs.success, filters.success)); + } + if (filters.startDate) { + conditions.push(gte(auditLogs.timestamp, filters.startDate)); + } + if (filters.endDate) { + conditions.push(lte(auditLogs.timestamp, filters.endDate)); + } + + return conditions.length > 0 ? and(...conditions) : undefined; + } + + private async pruneIfNeeded(): Promise { + const countResult = await this.context.drizzle + .select({ count: sql`COUNT(*)` }) + .from(auditLogs); + const count = countResult[0]?.count ?? 0; + + if (count < PRUNE_MAX) { + return; + } + + const deleteCount = count - PRUNE_TARGET; + const rows = await this.context.drizzle + .select({ id: auditLogs.id }) + .from(auditLogs) + .orderBy(asc(auditLogs.timestamp)) + .limit(deleteCount); + const ids = rows.map((row) => row.id); + + if (ids.length > 0) { + await this.context.drizzle + .delete(auditLogs) + .where(inArray(auditLogs.id, ids)); + } + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/c2s-tunnel-preset-repository.test.ts b/src/backend/database/repositories/c2s-tunnel-preset-repository.test.ts new file mode 100644 index 00000000..7c1119d1 --- /dev/null +++ b/src/backend/database/repositories/c2s-tunnel-preset-repository.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js"; + +describe("C2sTunnelPresetRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE c2s_tunnel_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + config TEXT NOT NULL, + platform TEXT, + computer_name TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new C2sTunnelPresetRepository(context, onWrite); + } + + it("creates and lists presets ordered by name", async () => { + const repo = await createRepository(); + + await repo.createForUser("user-1", { + name: "Zulu", + config: "[]", + platform: "linux", + computerName: "workstation", + }); + await repo.createForUser("user-1", { name: "Alpha", config: "[]" }); + await repo.createForUser("user-2", { name: "Other", config: "[]" }); + + const presets = await repo.listByUserId("user-1"); + + expect(presets.map((preset) => preset.name)).toEqual(["Alpha", "Zulu"]); + expect(presets[1]).toMatchObject({ + platform: "linux", + computerName: "workstation", + }); + }); + + it("finds, updates, and deletes user-owned presets", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const preset = await repo.createForUser("user-1", { + name: "Home", + config: "[]", + }); + expect(writeCount).toBe(1); + + expect(await repo.findByIdForUser("user-2", preset.id)).toBeNull(); + expect(await repo.hasNameForUser("user-1", "Home")).toBe(true); + expect(await repo.hasNameForUser("user-1", "Home", preset.id)).toBe(false); + + const updated = await repo.updateForUser("user-1", preset.id, { + name: "Renamed", + platform: "darwin", + }); + expect(updated).toMatchObject({ + id: preset.id, + name: "Renamed", + platform: "darwin", + }); + expect(writeCount).toBe(2); + + expect( + await repo.updateForUser("user-2", preset.id, { name: "Nope" }), + ).toBeNull(); + expect(writeCount).toBe(2); + + expect(await repo.deleteForUser("user-2", preset.id)).toBe(false); + expect(await repo.deleteForUser("user-1", preset.id)).toBe(true); + expect(writeCount).toBe(3); + }); + + it("deletes all presets for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.createForUser("user-1", { name: "One", config: "[]" }); + await repo.createForUser("user-1", { name: "Two", config: "[]" }); + await repo.createForUser("user-2", { name: "Other", config: "[]" }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserId("user-1")).toEqual([]); + expect( + (await repo.listByUserId("user-2")).map((preset) => preset.name), + ).toEqual(["Other"]); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/database/repositories/c2s-tunnel-preset-repository.ts b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts new file mode 100644 index 00000000..1fe3b387 --- /dev/null +++ b/src/backend/database/repositories/c2s-tunnel-preset-repository.ts @@ -0,0 +1,136 @@ +import { and, asc, eq, sql } from "drizzle-orm"; +import { c2sTunnelPresets } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect; + +export interface C2sTunnelPresetCreateInput { + name: string; + config: string; + platform?: string | null; + computerName?: string | null; +} + +export type C2sTunnelPresetUpdateInput = Partial; + +export class C2sTunnelPresetRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(c2sTunnelPresets) + .where(eq(c2sTunnelPresets.userId, userId)) + .orderBy(asc(c2sTunnelPresets.name)); + } + + async findByIdForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(c2sTunnelPresets) + .where( + and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), + ) + .limit(1); + + return rows[0] ?? null; + } + + async hasNameForUser( + userId: string, + name: string, + excludingId?: number, + ): Promise { + const rows = await this.context.drizzle + .select({ id: c2sTunnelPresets.id }) + .from(c2sTunnelPresets) + .where( + and( + eq(c2sTunnelPresets.userId, userId), + eq(c2sTunnelPresets.name, name), + ), + ); + + return rows.some((row) => row.id !== excludingId); + } + + async createForUser( + userId: string, + input: C2sTunnelPresetCreateInput, + ): Promise { + const [created] = await this.context.drizzle + .insert(c2sTunnelPresets) + .values({ + userId, + name: input.name, + config: input.config, + platform: input.platform ?? null, + computerName: input.computerName ?? null, + }) + .returning(); + + await this.afterWrite(); + return created; + } + + async updateForUser( + userId: string, + id: number, + updates: C2sTunnelPresetUpdateInput, + ): Promise { + const [updated] = await this.context.drizzle + .update(c2sTunnelPresets) + .set({ + ...updates, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), + ) + .returning(); + + if (updated) { + await this.afterWrite(); + } + + return updated ?? null; + } + + async deleteForUser(userId: string, id: number): Promise { + const rows = await this.context.drizzle + .delete(c2sTunnelPresets) + .where( + and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), + ) + .returning({ id: c2sTunnelPresets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(c2sTunnelPresets) + .where(eq(c2sTunnelPresets.userId, userId)) + .returning({ id: c2sTunnelPresets.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/command-history-repository.test.ts b/src/backend/database/repositories/command-history-repository.test.ts new file mode 100644 index 00000000..3f8da3ea --- /dev/null +++ b/src/backend/database/repositories/command-history-repository.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { CommandHistoryRepository } from "./command-history-repository.js"; + +describe("CommandHistoryRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE command_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + command TEXT NOT NULL, + executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + `); + + return new CommandHistoryRepository(context, onWrite); + } + + it("creates and lists unique commands by latest execution", async () => { + const repo = await createRepository(); + + await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z"); + await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z"); + await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z"); + await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z"); + + expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([ + "ls", + "pwd", + ]); + expect(await repo.listCommandsForHost("user-1", 1)).toEqual([ + "ls", + "pwd", + "ls", + ]); + }); + + it("deletes commands by command, host, host list, and user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create("user-1", 1, "ls"); + await repo.create("user-1", 1, "ls"); + await repo.create("user-1", 2, "pwd"); + await repo.create("user-2", 3, "whoami"); + expect(writeCount).toBe(4); + + expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0); + expect(writeCount).toBe(4); + + expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([3])).toBe(1); + expect(writeCount).toBe(7); + + await repo.create("user-1", 1, "date"); + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(9); + }); +}); diff --git a/src/backend/database/repositories/command-history-repository.ts b/src/backend/database/repositories/command-history-repository.ts new file mode 100644 index 00000000..5aa11d78 --- /dev/null +++ b/src/backend/database/repositories/command-history-repository.ts @@ -0,0 +1,161 @@ +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { commandHistory } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type CommandHistoryRecord = typeof commandHistory.$inferSelect; + +export class CommandHistoryRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create( + userId: string, + hostId: number, + command: string, + executedAt = new Date().toISOString(), + ): Promise { + const [created] = await this.context.drizzle + .insert(commandHistory) + .values({ userId, hostId, command, executedAt }) + .returning(); + await this.afterWrite(); + return created; + } + + async listUniqueCommandsForHost( + userId: string, + hostId: number, + limit = 500, + ): Promise { + const rows = await this.context.drizzle + .select({ + command: commandHistory.command, + maxExecutedAt: sql`MAX(${commandHistory.executedAt})`, + }) + .from(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + ), + ) + .groupBy(commandHistory.command) + .orderBy(desc(sql`MAX(${commandHistory.executedAt})`)) + .limit(limit); + + return rows.map((row) => row.command); + } + + async listCommandsForHost( + userId: string, + hostId: number, + limit = 200, + ): Promise { + const rows = await this.context.drizzle + .select({ + id: commandHistory.id, + command: commandHistory.command, + }) + .from(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + ), + ) + .orderBy(desc(commandHistory.executedAt)) + .limit(limit); + + return rows.map((row) => row.command); + } + + async deleteCommandForHost( + userId: string, + hostId: number, + command: string, + ): Promise { + const rows = await this.context.drizzle + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + eq(commandHistory.command, command), + ), + ) + .returning({ id: commandHistory.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByUserAndHost(userId: string, hostId: number): Promise { + const rows = await this.context.drizzle + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + ), + ) + .returning({ id: commandHistory.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(commandHistory) + .where(eq(commandHistory.hostId, hostId)) + .returning({ id: commandHistory.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostIds(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .delete(commandHistory) + .where(inArray(commandHistory.hostId, hostIds)) + .returning({ id: commandHistory.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(commandHistory) + .where(eq(commandHistory.userId, userId)) + .returning({ id: commandHistory.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts new file mode 100644 index 00000000..0c4dfcb7 --- /dev/null +++ b/src/backend/database/repositories/credential-repository.ts @@ -0,0 +1,360 @@ +import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; +import { sshCredentials, sshCredentialUsage } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { SystemCrypto } from "../../utils/system-crypto.js"; + +export type CredentialRecord = typeof sshCredentials.$inferSelect; +export type NewCredentialRecord = typeof sshCredentials.$inferInsert; +export type CredentialUpdate = Partial< + Omit +>; +export type CredentialSystemEncryptionUpdate = Pick< + CredentialUpdate, + "systemPassword" | "systemKey" | "systemKeyPassword" | "updatedAt" +>; + +export class CredentialRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(credential: NewCredentialRecord): Promise { + const rows = await this.context.drizzle + .insert(sshCredentials) + .values(credential) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async createEncryptedForUser( + userId: string, + credential: NewCredentialRecord | Record, + ): Promise { + const userDataKey = DataCrypto.validateUserAccess(userId); + const tempId = credential.id ?? Date.now(); + const dataWithTempId = { ...credential, id: tempId }; + const encryptedCredential = await this.encryptCredentialRecordForWrite( + dataWithTempId, + userId, + userDataKey, + ); + + if (!credential.id) { + delete (encryptedCredential as Partial).id; + } + + const rows = await this.context.drizzle + .insert(sshCredentials) + .values(encryptedCredential as NewCredentialRecord) + .returning(); + + await this.afterWrite(); + return DataCrypto.decryptRecord( + "ssh_credentials", + rows[0], + userId, + userDataKey, + ); + } + + async findByIdForUser( + userId: string, + credentialId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async findById(credentialId: number): Promise { + const rows = await this.context.drizzle + .select() + .from(sshCredentials) + .where(eq(sshCredentials.id, credentialId)) + .limit(1); + + return rows[0] ?? null; + } + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)) + .orderBy(desc(sshCredentials.updatedAt)); + } + + async listMissingSystemEncryptionByUserId( + userId: string, + ): Promise { + return this.context.drizzle + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.userId, userId), + or( + isNull(sshCredentials.systemPassword), + isNull(sshCredentials.systemKey), + isNull(sshCredentials.systemKeyPassword), + ), + ), + ); + } + + async existsForImportIdentity( + userId: string, + name: string, + username: string | null, + ): Promise { + const rows = await this.context.drizzle + .select({ id: sshCredentials.id }) + .from(sshCredentials) + .where( + and( + eq(sshCredentials.userId, userId), + eq(sshCredentials.name, name), + eq(sshCredentials.username, username), + ), + ) + .limit(1); + + return rows.length > 0; + } + + async findDecryptedByIdForUser( + userId: string, + credentialId: number, + ): Promise { + const row = await this.findByIdForUser(userId, credentialId); + return this.decryptOne(row, userId); + } + + async listDecryptedByUserId(userId: string): Promise { + const rows = await this.listByUserId(userId); + return this.decryptMany(rows, userId); + } + + async listFolders(userId: string): Promise { + const rows = await this.context.drizzle + .select({ folder: sshCredentials.folder }) + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)); + + return [...new Set(rows.map((row) => row.folder).filter(Boolean))].sort(); + } + + async renameFolder( + userId: string, + oldName: string, + newName: string, + ): Promise { + const rows = await this.context.drizzle + .update(sshCredentials) + .set({ folder: newName }) + .where( + and( + eq(sshCredentials.userId, userId), + eq(sshCredentials.folder, oldName), + ), + ) + .returning({ id: sshCredentials.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async updateForUser( + userId: string, + credentialId: number, + update: CredentialUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(sshCredentials) + .set(update) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async updateEncryptedForUser( + userId: string, + credentialId: number, + update: CredentialUpdate, + ): Promise { + const userDataKey = DataCrypto.validateUserAccess(userId); + const encryptedUpdate = await this.encryptCredentialRecordForWrite( + update, + userId, + userDataKey, + ); + + const rows = await this.context.drizzle + .update(sshCredentials) + .set(encryptedUpdate) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .returning(); + + await this.afterWrite(); + return this.decryptOne(rows[0] ?? null, userId); + } + + async updateSystemEncryptionForUser( + userId: string, + credentialId: number, + update: CredentialSystemEncryptionUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(sshCredentials) + .set(update) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .returning(); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows[0] ?? null; + } + + async deleteForUser(userId: string, credentialId: number): Promise { + const rows = await this.context.drizzle + .delete(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .returning({ id: sshCredentials.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sshCredentials) + .where(eq(sshCredentials.userId, userId)) + .returning({ id: sshCredentials.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async recordUsage( + userId: string, + credentialId: number, + hostId: number, + usedAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle.insert(sshCredentialUsage).values({ + credentialId, + hostId, + userId, + usedAt, + }); + + await this.context.drizzle + .update(sshCredentials) + .set({ + lastUsed: usedAt, + usageCount: sql`${sshCredentials.usageCount} + 1`, + }) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); + await this.afterWrite(); + } + + private decryptOne>( + record: T | null, + userId: string, + ): T | null { + if (!record) return null; + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) return null; + return DataCrypto.decryptRecord( + "ssh_credentials", + record, + userId, + userDataKey, + ); + } + + private decryptMany>( + records: T[], + userId: string, + ): T[] { + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) return []; + return DataCrypto.decryptRecords( + "ssh_credentials", + records, + userId, + userDataKey, + ); + } + + private async encryptCredentialRecordForWrite< + T extends Record, + >(record: T, userId: string, userDataKey: Buffer): Promise { + const encryptedRecord = DataCrypto.encryptRecord( + "ssh_credentials", + record, + userId, + userDataKey, + ); + const systemKey = + await SystemCrypto.getInstance().getCredentialSharingKey(); + const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( + "ssh_credentials", + record, + systemKey, + ); + + return { ...encryptedRecord, ...systemEncrypted }; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/current-alert-repository.ts b/src/backend/database/repositories/current-alert-repository.ts new file mode 100644 index 00000000..1b83ea97 --- /dev/null +++ b/src/backend/database/repositories/current-alert-repository.ts @@ -0,0 +1,15 @@ +import { AlertRepository } from "./alert-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentAlertRepository(): AlertRepository { + assertRepositoryRolloutDomainEnabled("alerts"); + + return new AlertRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("alert_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-api-key-repository.ts b/src/backend/database/repositories/current-api-key-repository.ts new file mode 100644 index 00000000..8930400d --- /dev/null +++ b/src/backend/database/repositories/current-api-key-repository.ts @@ -0,0 +1,15 @@ +import { ApiKeyRepository } from "./api-key-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentApiKeyRepository(): ApiKeyRepository { + assertRepositoryRolloutDomainEnabled("api_keys"); + + return new ApiKeyRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("api_key_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-audit-log-repository.ts b/src/backend/database/repositories/current-audit-log-repository.ts new file mode 100644 index 00000000..263ebf75 --- /dev/null +++ b/src/backend/database/repositories/current-audit-log-repository.ts @@ -0,0 +1,15 @@ +import { AuditLogRepository } from "./audit-log-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentAuditLogRepository(): AuditLogRepository { + assertRepositoryRolloutDomainEnabled("audit_logs"); + + return new AuditLogRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("audit_log_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-c2s-tunnel-preset-repository.ts b/src/backend/database/repositories/current-c2s-tunnel-preset-repository.ts new file mode 100644 index 00000000..7053b56f --- /dev/null +++ b/src/backend/database/repositories/current-c2s-tunnel-preset-repository.ts @@ -0,0 +1,15 @@ +import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentC2sTunnelPresetRepository(): C2sTunnelPresetRepository { + assertRepositoryRolloutDomainEnabled("c2s_tunnel_presets"); + + return new C2sTunnelPresetRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("c2s_tunnel_preset_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-command-history-repository.ts b/src/backend/database/repositories/current-command-history-repository.ts new file mode 100644 index 00000000..46e67578 --- /dev/null +++ b/src/backend/database/repositories/current-command-history-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { CommandHistoryRepository } from "./command-history-repository.js"; + +export function createCurrentCommandHistoryRepository(): CommandHistoryRepository { + assertRepositoryRolloutDomainEnabled("command_history"); + + return new CommandHistoryRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("command_history_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-credential-repository.ts b/src/backend/database/repositories/current-credential-repository.ts new file mode 100644 index 00000000..46f52fe8 --- /dev/null +++ b/src/backend/database/repositories/current-credential-repository.ts @@ -0,0 +1,15 @@ +import { CredentialRepository } from "./credential-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentCredentialRepository(): CredentialRepository { + assertRepositoryRolloutDomainEnabled("credentials"); + + return new CredentialRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("credential_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-dashboard-service-link-repository.ts b/src/backend/database/repositories/current-dashboard-service-link-repository.ts new file mode 100644 index 00000000..ee423195 --- /dev/null +++ b/src/backend/database/repositories/current-dashboard-service-link-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js"; + +export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository { + assertRepositoryRolloutDomainEnabled("dashboard_service_links"); + + return new DashboardServiceLinkRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-dismissed-alert-repository.ts b/src/backend/database/repositories/current-dismissed-alert-repository.ts new file mode 100644 index 00000000..9d5f0c4a --- /dev/null +++ b/src/backend/database/repositories/current-dismissed-alert-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { DismissedAlertRepository } from "./dismissed-alert-repository.js"; + +export function createCurrentDismissedAlertRepository(): DismissedAlertRepository { + assertRepositoryRolloutDomainEnabled("dismissed_alerts"); + + return new DismissedAlertRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("dismissed_alert_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-file-manager-bookmark-repository.ts b/src/backend/database/repositories/current-file-manager-bookmark-repository.ts new file mode 100644 index 00000000..3171ad7c --- /dev/null +++ b/src/backend/database/repositories/current-file-manager-bookmark-repository.ts @@ -0,0 +1,15 @@ +import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository { + assertRepositoryRolloutDomainEnabled("file_manager_bookmarks"); + + return new FileManagerBookmarkRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-homepage-item-repository.ts b/src/backend/database/repositories/current-homepage-item-repository.ts new file mode 100644 index 00000000..98657d9e --- /dev/null +++ b/src/backend/database/repositories/current-homepage-item-repository.ts @@ -0,0 +1,15 @@ +import { HomepageItemRepository } from "./homepage-item-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentHomepageItemRepository(): HomepageItemRepository { + assertRepositoryRolloutDomainEnabled("homepage_items"); + + return new HomepageItemRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("homepage_item_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-homepage-layout-repository.ts b/src/backend/database/repositories/current-homepage-layout-repository.ts new file mode 100644 index 00000000..f6c223b4 --- /dev/null +++ b/src/backend/database/repositories/current-homepage-layout-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { HomepageLayoutRepository } from "./homepage-layout-repository.js"; + +export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository { + assertRepositoryRolloutDomainEnabled("homepage_layouts"); + + return new HomepageLayoutRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("homepage_layout_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-host-folder-repository.ts b/src/backend/database/repositories/current-host-folder-repository.ts new file mode 100644 index 00000000..0319d9e6 --- /dev/null +++ b/src/backend/database/repositories/current-host-folder-repository.ts @@ -0,0 +1,15 @@ +import { HostFolderRepository } from "./host-folder-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentHostFolderRepository(): HostFolderRepository { + assertRepositoryRolloutDomainEnabled("host_folders"); + + return new HostFolderRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("host_folder_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-host-health-repository.ts b/src/backend/database/repositories/current-host-health-repository.ts new file mode 100644 index 00000000..12f7b809 --- /dev/null +++ b/src/backend/database/repositories/current-host-health-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { HostHealthRepository } from "./host-health-repository.js"; + +export function createCurrentHostHealthRepository(): HostHealthRepository { + assertRepositoryRolloutDomainEnabled("host_health"); + + return new HostHealthRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("host_health_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-host-metrics-history-repository.ts b/src/backend/database/repositories/current-host-metrics-history-repository.ts new file mode 100644 index 00000000..507cdc3f --- /dev/null +++ b/src/backend/database/repositories/current-host-metrics-history-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js"; + +export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository { + assertRepositoryRolloutDomainEnabled("host_metrics_history"); + + return new HostMetricsHistoryRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("host_metrics_history_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-host-metrics-preference-repository.ts b/src/backend/database/repositories/current-host-metrics-preference-repository.ts new file mode 100644 index 00000000..b3ed0d69 --- /dev/null +++ b/src/backend/database/repositories/current-host-metrics-preference-repository.ts @@ -0,0 +1,17 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js"; + +export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository { + assertRepositoryRolloutDomainEnabled("host_metrics_preferences"); + + return new HostMetricsPreferenceRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook( + "host_metrics_preference_repository_write", + ), + ); +} diff --git a/src/backend/database/repositories/current-host-repository.ts b/src/backend/database/repositories/current-host-repository.ts new file mode 100644 index 00000000..f8edfb0a --- /dev/null +++ b/src/backend/database/repositories/current-host-repository.ts @@ -0,0 +1,15 @@ +import { HostRepository } from "./host-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentHostRepository(): HostRepository { + assertRepositoryRolloutDomainEnabled("hosts"); + + return new HostRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("host_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-host-resolution-repository.ts b/src/backend/database/repositories/current-host-resolution-repository.ts new file mode 100644 index 00000000..29239794 --- /dev/null +++ b/src/backend/database/repositories/current-host-resolution-repository.ts @@ -0,0 +1,15 @@ +import { HostResolutionRepository } from "./host-resolution-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentHostResolutionRepository(): HostResolutionRepository { + assertRepositoryRolloutDomainEnabled("host_resolution"); + + return new HostResolutionRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("host_resolution_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-network-topology-repository.ts b/src/backend/database/repositories/current-network-topology-repository.ts new file mode 100644 index 00000000..3d915af1 --- /dev/null +++ b/src/backend/database/repositories/current-network-topology-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { NetworkTopologyRepository } from "./network-topology-repository.js"; + +export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository { + assertRepositoryRolloutDomainEnabled("network_topology"); + + return new NetworkTopologyRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("network_topology_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-open-tab-repository.ts b/src/backend/database/repositories/current-open-tab-repository.ts new file mode 100644 index 00000000..081fc920 --- /dev/null +++ b/src/backend/database/repositories/current-open-tab-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { OpenTabRepository } from "./open-tab-repository.js"; + +export function createCurrentOpenTabRepository(): OpenTabRepository { + assertRepositoryRolloutDomainEnabled("open_tabs"); + + return new OpenTabRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("open_tab_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-opkssh-token-repository.ts b/src/backend/database/repositories/current-opkssh-token-repository.ts new file mode 100644 index 00000000..bc1bd8ed --- /dev/null +++ b/src/backend/database/repositories/current-opkssh-token-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { OpksshTokenRepository } from "./opkssh-token-repository.js"; + +export function createCurrentOpksshTokenRepository(): OpksshTokenRepository { + assertRepositoryRolloutDomainEnabled("opkssh_tokens"); + + return new OpksshTokenRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("opkssh_token_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-rbac-access-repository.ts b/src/backend/database/repositories/current-rbac-access-repository.ts new file mode 100644 index 00000000..78711bdf --- /dev/null +++ b/src/backend/database/repositories/current-rbac-access-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { RbacAccessRepository } from "./rbac-access-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; + +export function createCurrentRbacAccessRepository(): RbacAccessRepository { + assertRepositoryRolloutDomainEnabled("rbac_access"); + + return new RbacAccessRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("rbac_access_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-recent-activity-repository.ts b/src/backend/database/repositories/current-recent-activity-repository.ts new file mode 100644 index 00000000..136a9954 --- /dev/null +++ b/src/backend/database/repositories/current-recent-activity-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { RecentActivityRepository } from "./recent-activity-repository.js"; + +export function createCurrentRecentActivityRepository(): RecentActivityRepository { + assertRepositoryRolloutDomainEnabled("recent_activity"); + + return new RecentActivityRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("recent_activity_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-repository-runtime.ts b/src/backend/database/repositories/current-repository-runtime.ts new file mode 100644 index 00000000..c4f7fa0f --- /dev/null +++ b/src/backend/database/repositories/current-repository-runtime.ts @@ -0,0 +1,21 @@ +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { getDb, getSqlite } from "../db/index.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export function createCurrentRepositoryContext(): DatabaseContext { + return { + dialect: "sqlite", + drizzle: getDb(), + sqlite: getSqlite(), + }; +} + +export function createCurrentRepositoryWriteHook( + reason: string, +): () => Promise { + return () => DatabaseSaveTrigger.forceSave(reason); +} + +export function getCurrentRepositorySqlite() { + return getSqlite(); +} diff --git a/src/backend/database/repositories/current-role-repository.ts b/src/backend/database/repositories/current-role-repository.ts new file mode 100644 index 00000000..94b38d09 --- /dev/null +++ b/src/backend/database/repositories/current-role-repository.ts @@ -0,0 +1,15 @@ +import { RoleRepository } from "./role-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentRoleRepository(): RoleRepository { + assertRepositoryRolloutDomainEnabled("roles"); + + return new RoleRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("role_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-session-recording-repository.ts b/src/backend/database/repositories/current-session-recording-repository.ts new file mode 100644 index 00000000..ce6d21dd --- /dev/null +++ b/src/backend/database/repositories/current-session-recording-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { SessionRecordingRepository } from "./session-recording-repository.js"; + +export function createCurrentSessionRecordingRepository(): SessionRecordingRepository { + assertRepositoryRolloutDomainEnabled("session_recordings"); + + return new SessionRecordingRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("session_recording_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-session-repository.ts b/src/backend/database/repositories/current-session-repository.ts new file mode 100644 index 00000000..c1f1c97f --- /dev/null +++ b/src/backend/database/repositories/current-session-repository.ts @@ -0,0 +1,15 @@ +import { SessionRepository } from "./session-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentSessionRepository(): SessionRepository { + assertRepositoryRolloutDomainEnabled("sessions"); + + return new SessionRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("session_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-settings-repository.ts b/src/backend/database/repositories/current-settings-repository.ts new file mode 100644 index 00000000..6c386028 --- /dev/null +++ b/src/backend/database/repositories/current-settings-repository.ts @@ -0,0 +1,26 @@ +import { SettingsRepository } from "./settings-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, + getCurrentRepositorySqlite, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentSettingsRepository(): SettingsRepository { + assertRepositoryRolloutDomainEnabled("settings"); + + return new SettingsRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("settings_repository_write"), + ); +} + +export function getCurrentSettingValue(key: string): string | null { + assertRepositoryRolloutDomainEnabled("settings"); + + const row = getCurrentRepositorySqlite() + .prepare("SELECT value FROM settings WHERE key = ?") + .get(key) as { value?: string } | undefined; + + return row?.value ?? null; +} diff --git a/src/backend/database/repositories/current-shared-credential-repository.ts b/src/backend/database/repositories/current-shared-credential-repository.ts new file mode 100644 index 00000000..b91336f9 --- /dev/null +++ b/src/backend/database/repositories/current-shared-credential-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { SharedCredentialRepository } from "./shared-credential-repository.js"; + +export function createCurrentSharedCredentialRepository(): SharedCredentialRepository { + assertRepositoryRolloutDomainEnabled("shared_credentials"); + + return new SharedCredentialRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("shared_credential_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-snippet-repository.ts b/src/backend/database/repositories/current-snippet-repository.ts new file mode 100644 index 00000000..023ad6cd --- /dev/null +++ b/src/backend/database/repositories/current-snippet-repository.ts @@ -0,0 +1,15 @@ +import { SnippetRepository } from "./snippet-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentSnippetRepository(): SnippetRepository { + assertRepositoryRolloutDomainEnabled("snippets"); + + return new SnippetRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("snippet_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-ssh-credential-usage-repository.ts b/src/backend/database/repositories/current-ssh-credential-usage-repository.ts new file mode 100644 index 00000000..4c5bfd53 --- /dev/null +++ b/src/backend/database/repositories/current-ssh-credential-usage-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; + +export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository { + assertRepositoryRolloutDomainEnabled("ssh_credential_usage"); + + return new SshCredentialUsageRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("ssh_credential_usage_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-sso-provider-repository.ts b/src/backend/database/repositories/current-sso-provider-repository.ts new file mode 100644 index 00000000..514d1fa0 --- /dev/null +++ b/src/backend/database/repositories/current-sso-provider-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { SsoProviderRepository } from "./sso-provider-repository.js"; + +export function createCurrentSsoProviderRepository(): SsoProviderRepository { + assertRepositoryRolloutDomainEnabled("sso_providers"); + + return new SsoProviderRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("sso_provider_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-termix-identity-ca-repository.ts b/src/backend/database/repositories/current-termix-identity-ca-repository.ts new file mode 100644 index 00000000..d2447420 --- /dev/null +++ b/src/backend/database/repositories/current-termix-identity-ca-repository.ts @@ -0,0 +1,15 @@ +import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository { + assertRepositoryRolloutDomainEnabled("termix_identity_ca"); + + return new TermixIdentityCaRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("termix_identity_ca_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-termix-identity-repository.ts b/src/backend/database/repositories/current-termix-identity-repository.ts new file mode 100644 index 00000000..435dbc57 --- /dev/null +++ b/src/backend/database/repositories/current-termix-identity-repository.ts @@ -0,0 +1,15 @@ +import { TermixIdentityRepository } from "./termix-identity-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentTermixIdentityRepository(): TermixIdentityRepository { + assertRepositoryRolloutDomainEnabled("termix_identity"); + + return new TermixIdentityRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("termix_identity_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-tmux-session-tag-repository.ts b/src/backend/database/repositories/current-tmux-session-tag-repository.ts new file mode 100644 index 00000000..7d0a7bcf --- /dev/null +++ b/src/backend/database/repositories/current-tmux-session-tag-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js"; + +export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepository { + assertRepositoryRolloutDomainEnabled("tmux_session_tags"); + + return new TmuxSessionTagRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("tmux_session_tag_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-transfer-recent-repository.ts b/src/backend/database/repositories/current-transfer-recent-repository.ts new file mode 100644 index 00000000..a9b07eb3 --- /dev/null +++ b/src/backend/database/repositories/current-transfer-recent-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { TransferRecentRepository } from "./transfer-recent-repository.js"; + +export function createCurrentTransferRecentRepository(): TransferRecentRepository { + assertRepositoryRolloutDomainEnabled("transfer_recent"); + + return new TransferRecentRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("transfer_recent_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-trusted-device-repository.ts b/src/backend/database/repositories/current-trusted-device-repository.ts new file mode 100644 index 00000000..10c9e811 --- /dev/null +++ b/src/backend/database/repositories/current-trusted-device-repository.ts @@ -0,0 +1,15 @@ +import { TrustedDeviceRepository } from "./trusted-device-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository { + assertRepositoryRolloutDomainEnabled("trusted_devices"); + + return new TrustedDeviceRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("trusted_device_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-user-data-export-repository.ts b/src/backend/database/repositories/current-user-data-export-repository.ts new file mode 100644 index 00000000..89ab141c --- /dev/null +++ b/src/backend/database/repositories/current-user-data-export-repository.ts @@ -0,0 +1,9 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { createCurrentRepositoryContext } from "./current-repository-runtime.js"; +import { UserDataExportRepository } from "./user-data-export-repository.js"; + +export function createCurrentUserDataExportRepository(): UserDataExportRepository { + assertRepositoryRolloutDomainEnabled("user_data_exports"); + + return new UserDataExportRepository(createCurrentRepositoryContext()); +} diff --git a/src/backend/database/repositories/current-user-preference-repository.ts b/src/backend/database/repositories/current-user-preference-repository.ts new file mode 100644 index 00000000..787dbb3d --- /dev/null +++ b/src/backend/database/repositories/current-user-preference-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { UserPreferenceRepository } from "./user-preference-repository.js"; + +export function createCurrentUserPreferenceRepository(): UserPreferenceRepository { + assertRepositoryRolloutDomainEnabled("user_preferences"); + + return new UserPreferenceRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("user_preference_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-user-repository.ts b/src/backend/database/repositories/current-user-repository.ts new file mode 100644 index 00000000..617faeec --- /dev/null +++ b/src/backend/database/repositories/current-user-repository.ts @@ -0,0 +1,15 @@ +import { UserRepository } from "./user-repository.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; + +export function createCurrentUserRepository(): UserRepository { + assertRepositoryRolloutDomainEnabled("users"); + + return new UserRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("user_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-vault-profile-repository.ts b/src/backend/database/repositories/current-vault-profile-repository.ts new file mode 100644 index 00000000..567dd91d --- /dev/null +++ b/src/backend/database/repositories/current-vault-profile-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { VaultProfileRepository } from "./vault-profile-repository.js"; + +export function createCurrentVaultProfileRepository(): VaultProfileRepository { + assertRepositoryRolloutDomainEnabled("vault_profiles"); + + return new VaultProfileRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("vault_profile_repository_write"), + ); +} diff --git a/src/backend/database/repositories/current-vault-token-repository.ts b/src/backend/database/repositories/current-vault-token-repository.ts new file mode 100644 index 00000000..8fbac47e --- /dev/null +++ b/src/backend/database/repositories/current-vault-token-repository.ts @@ -0,0 +1,15 @@ +import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js"; +import { + createCurrentRepositoryContext, + createCurrentRepositoryWriteHook, +} from "./current-repository-runtime.js"; +import { VaultTokenRepository } from "./vault-token-repository.js"; + +export function createCurrentVaultTokenRepository(): VaultTokenRepository { + assertRepositoryRolloutDomainEnabled("vault_tokens"); + + return new VaultTokenRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("vault_token_repository_write"), + ); +} diff --git a/src/backend/database/repositories/dashboard-service-link-repository.test.ts b/src/backend/database/repositories/dashboard-service-link-repository.test.ts new file mode 100644 index 00000000..3f35ede0 --- /dev/null +++ b/src/backend/database/repositories/dashboard-service-link-repository.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js"; + +describe("DashboardServiceLinkRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE dashboard_service_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + label TEXT NOT NULL, + url TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new DashboardServiceLinkRepository(context, onWrite); + } + + it("creates and lists links ordered by order then id", async () => { + const repo = await createRepository(); + + const first = await repo.createForUser( + "user-1", + { label: "Docs", url: "https://docs.example.com" }, + "2026-06-27T00:00:00.000Z", + ); + const second = await repo.createForUser("user-1", { + label: "Status", + url: "https://status.example.com", + }); + await repo.createForUser("user-2", { + label: "Other", + url: "https://other.example.com", + }); + + expect(first).toMatchObject({ + userId: "user-1", + label: "Docs", + order: 0, + createdAt: "2026-06-27T00:00:00.000Z", + }); + expect(second.order).toBe(1); + expect( + (await repo.listByUserId("user-1")).map((link) => link.label), + ).toEqual(["Docs", "Status"]); + }); + + it("finds, updates, and deletes a user-owned link", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const link = await repo.createForUser("user-1", { + label: "Docs", + url: "https://docs.example.com", + }); + expect(writeCount).toBe(1); + + expect(await repo.findByIdForUser("user-2", link.id)).toBeNull(); + const updated = await repo.updateForUser("user-1", link.id, { + label: "Docs renamed", + }); + expect(updated).toMatchObject({ + id: link.id, + label: "Docs renamed", + url: "https://docs.example.com", + }); + expect(writeCount).toBe(2); + + expect(await repo.updateForUser("user-2", link.id, { label: "Nope" })).toBe( + null, + ); + expect(writeCount).toBe(2); + + expect(await repo.deleteForUser("user-2", link.id)).toBe(false); + expect(await repo.deleteForUser("user-1", link.id)).toBe(true); + expect(writeCount).toBe(3); + }); + + it("deletes all dashboard links for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.createForUser("user-1", { + label: "Docs", + url: "https://docs.example.com", + }); + await repo.createForUser("user-1", { + label: "Status", + url: "https://status.example.com", + }); + await repo.createForUser("user-2", { + label: "Other", + url: "https://other.example.com", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserId("user-1")).toEqual([]); + expect( + (await repo.listByUserId("user-2")).map((link) => link.label), + ).toEqual(["Other"]); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/database/repositories/dashboard-service-link-repository.ts b/src/backend/database/repositories/dashboard-service-link-repository.ts new file mode 100644 index 00000000..10683adc --- /dev/null +++ b/src/backend/database/repositories/dashboard-service-link-repository.ts @@ -0,0 +1,129 @@ +import { and, asc, eq } from "drizzle-orm"; +import { dashboardServiceLinks } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type DashboardServiceLinkRecord = + typeof dashboardServiceLinks.$inferSelect; + +export type DashboardServiceLinkUpdate = Partial<{ + label: string; + url: string; +}>; + +export class DashboardServiceLinkRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(dashboardServiceLinks) + .where(eq(dashboardServiceLinks.userId, userId)) + .orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id)); + } + + async createForUser( + userId: string, + input: { label: string; url: string }, + createdAt = new Date().toISOString(), + ): Promise { + const existing = await this.context.drizzle + .select({ order: dashboardServiceLinks.order }) + .from(dashboardServiceLinks) + .where(eq(dashboardServiceLinks.userId, userId)) + .orderBy(asc(dashboardServiceLinks.order)); + const nextOrder = + existing.length > 0 ? existing[existing.length - 1].order + 1 : 0; + + const [created] = await this.context.drizzle + .insert(dashboardServiceLinks) + .values({ + userId, + label: input.label, + url: input.url, + order: nextOrder, + createdAt, + }) + .returning(); + await this.afterWrite(); + return created; + } + + async findByIdForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(dashboardServiceLinks) + .where( + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async updateForUser( + userId: string, + id: number, + updates: DashboardServiceLinkUpdate, + ): Promise { + const [updated] = await this.context.drizzle + .update(dashboardServiceLinks) + .set(updates) + .where( + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ) + .returning(); + + if (updated) { + await this.afterWrite(); + } + + return updated ?? null; + } + + async deleteForUser(userId: string, id: number): Promise { + const rows = await this.context.drizzle + .delete(dashboardServiceLinks) + .where( + and( + eq(dashboardServiceLinks.id, id), + eq(dashboardServiceLinks.userId, userId), + ), + ) + .returning({ id: dashboardServiceLinks.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(dashboardServiceLinks) + .where(eq(dashboardServiceLinks.userId, userId)) + .returning({ id: dashboardServiceLinks.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/dismissed-alert-repository.test.ts b/src/backend/database/repositories/dismissed-alert-repository.test.ts new file mode 100644 index 00000000..5528a706 --- /dev/null +++ b/src/backend/database/repositories/dismissed-alert-repository.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { DismissedAlertRepository } from "./dismissed-alert-repository.js"; + +describe("DismissedAlertRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE dismissed_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + alert_id TEXT NOT NULL, + dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new DismissedAlertRepository(context, onWrite); + } + + it("creates, lists, and finds dismissed alerts by user", async () => { + const repo = await createRepository(); + + await repo.create("user-1", "alert-1"); + await repo.create("user-1", "alert-2"); + await repo.create("user-2", "alert-3"); + + expect(await repo.listAlertIdsByUserId("user-1")).toEqual([ + "alert-1", + "alert-2", + ]); + expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe( + "alert-1", + ); + expect(await repo.findForUser("user-1", "alert-3")).toBeNull(); + expect( + (await repo.listByUserId("user-1")).map((row) => row.alertId), + ).toEqual(["alert-1", "alert-2"]); + }); + + it("creates import alerts without duplicating user alert pairs", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect( + repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"), + ).resolves.toBe(true); + await expect( + repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"), + ).resolves.toBe(false); + + const alerts = await repo.listByUserId("user-1"); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toMatchObject({ + alertId: "alert-1", + dismissedAt: "2026-01-01T00:00:00.000Z", + }); + expect(writeCount).toBe(1); + }); + + it("deletes dismissed alerts and only triggers writes for changed rows", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create("user-1", "alert-1"); + await repo.create("user-1", "alert-2"); + await repo.create("user-2", "alert-3"); + expect(writeCount).toBe(3); + + expect(await repo.deleteForUser("user-1", "missing")).toBe(false); + expect(writeCount).toBe(3); + + expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true); + expect(writeCount).toBe(4); + expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(5); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(writeCount).toBe(5); + }); +}); diff --git a/src/backend/database/repositories/dismissed-alert-repository.ts b/src/backend/database/repositories/dismissed-alert-repository.ts new file mode 100644 index 00000000..41d0c168 --- /dev/null +++ b/src/backend/database/repositories/dismissed-alert-repository.ts @@ -0,0 +1,108 @@ +import { and, eq } from "drizzle-orm"; +import { dismissedAlerts } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect; + +export class DismissedAlertRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + } + + async listAlertIdsByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .select({ alertId: dismissedAlerts.alertId }) + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + return rows.map((row) => row.alertId); + } + + async findForUser( + userId: string, + alertId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(dismissedAlerts) + .where( + and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async create(userId: string, alertId: string): Promise { + await this.context.drizzle.insert(dismissedAlerts).values({ + userId, + alertId, + }); + await this.afterWrite(); + } + + async createForImport( + userId: string, + alertId: string, + dismissedAt = new Date().toISOString(), + ): Promise { + const existing = await this.findForUser(userId, alertId); + if (existing) { + return false; + } + + await this.context.drizzle.insert(dismissedAlerts).values({ + userId, + alertId, + dismissedAt, + }); + await this.afterWrite(); + return true; + } + + async deleteForUser(userId: string, alertId: string): Promise { + const rows = await this.context.drizzle + .delete(dismissedAlerts) + .where( + and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId), + ), + ) + .returning({ id: dismissedAlerts.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)) + .returning({ id: dismissedAlerts.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/field-encryption-boundary.test.ts b/src/backend/database/repositories/field-encryption-boundary.test.ts new file mode 100644 index 00000000..81acdd00 --- /dev/null +++ b/src/backend/database/repositories/field-encryption-boundary.test.ts @@ -0,0 +1,105 @@ +import crypto from "crypto"; +import { describe, expect, it } from "vitest"; +import { FieldEncryptionBoundary } from "./field-encryption-boundary.js"; + +describe("FieldEncryptionBoundary", () => { + const userDataKey = crypto.randomBytes(32); + + it("encrypts sensitive host fields while leaving queryable metadata plaintext", () => { + const host = { + id: 42, + userId: "user-1", + name: "prod-db", + ip: "10.0.0.5", + username: "root", + password: "secret", + rdpPassword: "rdp-secret", + }; + + const encrypted = FieldEncryptionBoundary.encryptRecord( + "ssh_data", + host, + userDataKey, + ); + + expect(encrypted.password).not.toBe("secret"); + expect(encrypted.rdpPassword).not.toBe("rdp-secret"); + expect(encrypted.ip).toBe("10.0.0.5"); + expect(encrypted.name).toBe("prod-db"); + + const decrypted = FieldEncryptionBoundary.decryptRecord( + "ssh_data", + encrypted, + userDataKey, + ); + expect(decrypted).toMatchObject(host); + }); + + it("encrypts credential system secret fields that the legacy map did not cover", () => { + const credential = { + id: 7, + userId: "user-1", + name: "system credential", + authType: "key", + systemPassword: "system-password", + systemKey: "system-key", + systemKeyPassword: "system-key-password", + }; + + const encrypted = FieldEncryptionBoundary.encryptRecord( + "ssh_credentials", + credential, + userDataKey, + ); + + expect(encrypted.systemPassword).not.toBe("system-password"); + expect(encrypted.systemKey).not.toBe("system-key"); + expect(encrypted.systemKeyPassword).not.toBe("system-key-password"); + expect(encrypted.name).toBe("system credential"); + + expect( + FieldEncryptionBoundary.decryptRecord( + "ssh_credentials", + encrypted, + userDataKey, + ), + ).toMatchObject(credential); + }); + + it("keeps empty and non-string sensitive values unchanged", () => { + const encrypted = FieldEncryptionBoundary.encryptRecord( + "ssh_data", + { + id: 1, + password: "", + key: null, + }, + userDataKey, + ); + + expect(encrypted.password).toBe(""); + expect(encrypted.key).toBeNull(); + }); + + it("requires a stable record id instead of inventing a temporary encryption context", () => { + expect(() => + FieldEncryptionBoundary.encryptRecord( + "ssh_data", + { password: "secret" }, + userDataKey, + ), + ).toThrow(/stable record id/); + }); + + it("classifies sensitive, plaintext, and unknown fields", () => { + expect(FieldEncryptionBoundary.classifyField("ssh_data", "password")).toBe( + "sensitive", + ); + expect(FieldEncryptionBoundary.classifyField("ssh_data", "ip")).toBe( + "plaintext", + ); + expect(FieldEncryptionBoundary.classifyField("ssh_data", "newField")).toBe( + "unknown", + ); + }); +}); diff --git a/src/backend/database/repositories/field-encryption-boundary.ts b/src/backend/database/repositories/field-encryption-boundary.ts new file mode 100644 index 00000000..1c4027bc --- /dev/null +++ b/src/backend/database/repositories/field-encryption-boundary.ts @@ -0,0 +1,161 @@ +import { FieldCrypto } from "../../utils/field-crypto.js"; +import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; + +const FIELD_ENCRYPTION_POLICY = { + users: { + sensitive: new Set([ + "passwordHash", + "clientSecret", + "totpSecret", + "totpBackupCodes", + "oidcIdentifier", + ]), + plaintext: new Set(["id", "username", "isAdmin", "isOidc"]), + }, + ssh_data: { + sensitive: new Set([ + "password", + "key", + "keyPassword", + "sudoPassword", + "autostartPassword", + "autostartKey", + "autostartKeyPassword", + "socks5Password", + "rdpPassword", + "vncPassword", + "telnetPassword", + ]), + plaintext: new Set([ + "id", + "userId", + "connectionType", + "name", + "ip", + "port", + "username", + "folder", + "tags", + "authType", + "credentialId", + ]), + }, + ssh_credentials: { + sensitive: new Set([ + "password", + "key", + "privateKey", + "publicKey", + "keyPassword", + "systemPassword", + "systemKey", + "systemKeyPassword", + ]), + plaintext: new Set([ + "id", + "userId", + "name", + "description", + "folder", + "tags", + "authType", + "username", + "keyType", + "detectedKeyType", + "usageCount", + "lastUsed", + ]), + }, + opkssh_tokens: { + sensitive: new Set(["sshCert", "privateKey"]), + plaintext: new Set(["id", "userId", "hostId", "createdAt", "expiresAt"]), + }, + termix_identity_ca: { + sensitive: new Set(["privateKey"]), + plaintext: new Set(["id", "publicKey", "createdAt", "updatedAt"]), + }, + vault_tokens: { + sensitive: new Set(["sshCert", "privateKey"]), + plaintext: new Set(["id", "userId", "profileId", "expiresAt"]), + }, +} as const; + +type PolicyTable = keyof typeof FIELD_ENCRYPTION_POLICY; +export type FieldClassification = "sensitive" | "plaintext" | "unknown"; + +export class FieldEncryptionBoundary { + static classifyField( + tableName: string, + fieldName: string, + ): FieldClassification { + const policy = this.getPolicy(tableName); + if (!policy) return "unknown"; + if (policy.sensitive.has(fieldName)) return "sensitive"; + if (policy.plaintext.has(fieldName)) return "plaintext"; + return "unknown"; + } + + static getSensitiveFields(tableName: string): string[] { + const policy = this.getPolicy(tableName); + return policy ? [...policy.sensitive].sort() : []; + } + + static encryptRecord>( + tableName: string, + record: T, + userDataKey: Buffer, + recordId = record.id, + ): T { + const id = this.requireRecordId(recordId); + const encryptedRecord: Record = { ...record }; + + for (const fieldName of this.getSensitiveFields(tableName)) { + const value = encryptedRecord[fieldName]; + if (typeof value === "string" && value) { + encryptedRecord[fieldName] = FieldCrypto.encryptField( + value, + userDataKey, + id, + fieldName, + ); + } + } + + return encryptedRecord as T; + } + + static decryptRecord>( + tableName: string, + record: T, + userDataKey: Buffer, + recordId = record.id, + ): T { + const id = this.requireRecordId(recordId); + const decryptedRecord: Record = { ...record }; + + for (const fieldName of this.getSensitiveFields(tableName)) { + const value = decryptedRecord[fieldName]; + if (typeof value === "string" && value) { + decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue( + value, + userDataKey, + id, + fieldName, + ); + } + } + + return decryptedRecord as T; + } + + private static getPolicy(tableName: string) { + return FIELD_ENCRYPTION_POLICY[tableName as PolicyTable]; + } + + private static requireRecordId(recordId: unknown): string { + if (recordId === null || recordId === undefined || recordId === "") { + throw new Error("Field encryption requires a stable record id."); + } + return String(recordId); + } +} diff --git a/src/backend/database/repositories/file-manager-bookmark-repository.test.ts b/src/backend/database/repositories/file-manager-bookmark-repository.test.ts new file mode 100644 index 00000000..d0875071 --- /dev/null +++ b/src/backend/database/repositories/file-manager-bookmark-repository.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js"; + +describe("FileManagerBookmarkRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE file_manager_recent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE file_manager_pinned ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE file_manager_shortcuts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + `); + + return new FileManagerBookmarkRepository(context, onWrite); + } + + it("upserts and lists recent files by last-opened time", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertRecent( + "user-1", + { hostId: 1, path: "/var/log/app.log" }, + "2026-01-01T00:00:00.000Z", + ); + await repo.upsertRecent( + "user-1", + { hostId: 1, path: "/opt/app/config.json", name: "Config" }, + "2026-01-02T00:00:00.000Z", + ); + await repo.upsertRecent( + "user-1", + { hostId: 1, path: "/var/log/app.log" }, + "2026-01-03T00:00:00.000Z", + ); + + const recent = await repo.listRecentForHost("user-1", 1); + + expect(recent.map((entry) => entry.path)).toEqual([ + "/var/log/app.log", + "/opt/app/config.json", + ]); + expect(recent[0].lastOpened).toBe("2026-01-03T00:00:00.000Z"); + expect(recent[0].name).toBe("app.log"); + expect(writeCount).toBe(3); + + expect( + await repo.deleteRecentForHostPath("user-1", { + hostId: 1, + path: "/var/log/app.log", + }), + ).toBe(1); + expect(writeCount).toBe(4); + }); + + it("creates pinned files and shortcuts without duplicating paths", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.createPinned( + "user-1", + { hostId: 1, path: "/srv/www", name: "Web" }, + "2026-01-01T00:00:00.000Z", + ), + ).toBe(true); + expect( + await repo.createPinned("user-1", { hostId: 1, path: "/srv/www" }), + ).toBe(false); + expect((await repo.listPinnedForHost("user-1", 1))[0]).toMatchObject({ + name: "Web", + path: "/srv/www", + }); + + expect( + await repo.createShortcut( + "user-1", + { hostId: 1, path: "/etc/nginx" }, + "2026-01-02T00:00:00.000Z", + ), + ).toBe(true); + expect( + await repo.createShortcut("user-1", { hostId: 1, path: "/etc/nginx" }), + ).toBe(false); + expect((await repo.listShortcutsForHost("user-1", 1))[0]).toMatchObject({ + name: "nginx", + path: "/etc/nginx", + }); + expect(writeCount).toBe(2); + + expect( + await repo.deletePinnedForHostPath("user-1", { + hostId: 1, + path: "/srv/www", + }), + ).toBe(1); + expect( + await repo.deleteShortcutForHostPath("user-1", { + hostId: 1, + path: "/etc/nginx", + }), + ).toBe(1); + expect(writeCount).toBe(4); + }); + + it("creates import bookmarks without duplicating user path/name pairs", async () => { + const repo = await createRepository(); + + await expect( + repo.createRecentForImport( + "user-1", + { hostId: 1, path: "/tmp/a.txt", name: "A" }, + "2026-01-01T00:00:00.000Z", + ), + ).resolves.toBe(true); + await expect( + repo.createRecentForImport("user-1", { + hostId: 2, + path: "/tmp/a.txt", + name: "A", + }), + ).resolves.toBe(false); + + await expect( + repo.createPinnedForImport("user-1", { + hostId: 1, + path: "/srv/www", + name: "Web", + }), + ).resolves.toBe(true); + await expect( + repo.createPinnedForImport("user-1", { + hostId: 2, + path: "/srv/www", + name: "Web", + }), + ).resolves.toBe(false); + + await expect( + repo.createShortcutForImport("user-1", { + hostId: 1, + path: "/etc/nginx", + name: "Nginx", + }), + ).resolves.toBe(true); + await expect( + repo.createShortcutForImport("user-1", { + hostId: 2, + path: "/etc/nginx", + name: "Nginx", + }), + ).resolves.toBe(false); + }); + + it("deletes bookmarks by user, host, and host list", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertRecent("user-1", { hostId: 1, path: "/one" }); + await repo.createPinned("user-1", { hostId: 2, path: "/two" }); + await repo.createShortcut("user-2", { hostId: 3, path: "/three" }); + expect(writeCount).toBe(3); + + expect(await repo.deleteByHostId(1)).toBe(1); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2])).toBe(1); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByUserId("user-2")).toBe(0); + expect(writeCount).toBe(6); + }); +}); diff --git a/src/backend/database/repositories/file-manager-bookmark-repository.ts b/src/backend/database/repositories/file-manager-bookmark-repository.ts new file mode 100644 index 00000000..4f3bcdb4 --- /dev/null +++ b/src/backend/database/repositories/file-manager-bookmark-repository.ts @@ -0,0 +1,533 @@ +import { and, desc, eq, inArray } from "drizzle-orm"; +import { + fileManagerPinned, + fileManagerRecent, + fileManagerShortcuts, +} from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect; +export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect; +export type FileManagerShortcutRecord = + typeof fileManagerShortcuts.$inferSelect; + +export interface FileManagerBookmarkInput { + hostId: number; + path: string; + name?: string | null; +} + +function resolveBookmarkName(path: string, name?: string | null): string { + return name || path.split("/").pop() || "Unknown"; +} + +export class FileManagerBookmarkRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listRecentForHost( + userId: string, + hostId: number, + limit = 20, + ): Promise { + return this.context.drizzle + .select() + .from(fileManagerRecent) + .where( + and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.hostId, hostId), + ), + ) + .orderBy(desc(fileManagerRecent.lastOpened)) + .limit(limit); + } + + async listRecentByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(fileManagerRecent) + .where(eq(fileManagerRecent.userId, userId)); + } + + async upsertRecent( + userId: string, + input: FileManagerBookmarkInput, + lastOpened = new Date().toISOString(), + ): Promise { + const [existing] = await this.context.drizzle + .select({ id: fileManagerRecent.id }) + .from(fileManagerRecent) + .where( + and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.hostId, input.hostId), + eq(fileManagerRecent.path, input.path), + ), + ) + .limit(1); + + if (existing) { + await this.context.drizzle + .update(fileManagerRecent) + .set({ lastOpened }) + .where(eq(fileManagerRecent.id, existing.id)); + } else { + await this.context.drizzle.insert(fileManagerRecent).values({ + userId, + hostId: input.hostId, + path: input.path, + name: resolveBookmarkName(input.path, input.name), + lastOpened, + }); + } + + await this.afterWrite(); + } + + async createRecentForImport( + userId: string, + input: FileManagerBookmarkInput, + lastOpened = new Date().toISOString(), + ): Promise { + const exists = await this.existsRecentImportItem(userId, input); + if (exists) { + return false; + } + + await this.context.drizzle.insert(fileManagerRecent).values({ + userId, + hostId: input.hostId, + path: input.path, + name: resolveBookmarkName(input.path, input.name), + lastOpened, + }); + await this.afterWrite(); + return true; + } + + async deleteRecentForHostPath( + userId: string, + input: Pick, + ): Promise { + const rows = await this.context.drizzle + .delete(fileManagerRecent) + .where( + and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.hostId, input.hostId), + eq(fileManagerRecent.path, input.path), + ), + ) + .returning({ id: fileManagerRecent.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async listPinnedForHost( + userId: string, + hostId: number, + ): Promise { + return this.context.drizzle + .select() + .from(fileManagerPinned) + .where( + and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.hostId, hostId), + ), + ) + .orderBy(desc(fileManagerPinned.pinnedAt)); + } + + async listPinnedByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(fileManagerPinned) + .where(eq(fileManagerPinned.userId, userId)); + } + + async createPinned( + userId: string, + input: FileManagerBookmarkInput, + pinnedAt = new Date().toISOString(), + ): Promise { + const exists = await this.existsPinned(userId, input.hostId, input.path); + if (exists) { + return false; + } + + await this.context.drizzle.insert(fileManagerPinned).values({ + userId, + hostId: input.hostId, + path: input.path, + name: resolveBookmarkName(input.path, input.name), + pinnedAt, + }); + await this.afterWrite(); + return true; + } + + async createPinnedForImport( + userId: string, + input: FileManagerBookmarkInput, + pinnedAt = new Date().toISOString(), + ): Promise { + const exists = await this.existsPinnedImportItem(userId, input); + if (exists) { + return false; + } + + await this.context.drizzle.insert(fileManagerPinned).values({ + userId, + hostId: input.hostId, + path: input.path, + name: resolveBookmarkName(input.path, input.name), + pinnedAt, + }); + await this.afterWrite(); + return true; + } + + async deletePinnedForHostPath( + userId: string, + input: Pick, + ): Promise { + const rows = await this.context.drizzle + .delete(fileManagerPinned) + .where( + and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.hostId, input.hostId), + eq(fileManagerPinned.path, input.path), + ), + ) + .returning({ id: fileManagerPinned.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async listShortcutsForHost( + userId: string, + hostId: number, + ): Promise { + return this.context.drizzle + .select() + .from(fileManagerShortcuts) + .where( + and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.hostId, hostId), + ), + ) + .orderBy(desc(fileManagerShortcuts.createdAt)); + } + + async listShortcutsByUserId( + userId: string, + ): Promise { + return this.context.drizzle + .select() + .from(fileManagerShortcuts) + .where(eq(fileManagerShortcuts.userId, userId)); + } + + async createShortcut( + userId: string, + input: FileManagerBookmarkInput, + createdAt = new Date().toISOString(), + ): Promise { + const exists = await this.existsShortcut(userId, input.hostId, input.path); + if (exists) { + return false; + } + + await this.context.drizzle.insert(fileManagerShortcuts).values({ + userId, + hostId: input.hostId, + path: input.path, + name: resolveBookmarkName(input.path, input.name), + createdAt, + }); + await this.afterWrite(); + return true; + } + + async createShortcutForImport( + userId: string, + input: FileManagerBookmarkInput, + createdAt = new Date().toISOString(), + ): Promise { + const exists = await this.existsShortcutImportItem(userId, input); + if (exists) { + return false; + } + + await this.context.drizzle.insert(fileManagerShortcuts).values({ + userId, + hostId: input.hostId, + path: input.path, + name: resolveBookmarkName(input.path, input.name), + createdAt, + }); + await this.afterWrite(); + return true; + } + + async deleteShortcutForHostPath( + userId: string, + input: Pick, + ): Promise { + const rows = await this.context.drizzle + .delete(fileManagerShortcuts) + .where( + and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.hostId, input.hostId), + eq(fileManagerShortcuts.path, input.path), + ), + ) + .returning({ id: fileManagerShortcuts.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByUserId(userId: string): Promise { + const total = + (await this.deleteRecentByUserId(userId)) + + (await this.deletePinnedByUserId(userId)) + + (await this.deleteShortcutsByUserId(userId)); + + if (total > 0) { + await this.afterWrite(); + } + + return total; + } + + async deleteByHostId(hostId: number): Promise { + const total = + (await this.deleteRecentByHostId(hostId)) + + (await this.deletePinnedByHostId(hostId)) + + (await this.deleteShortcutsByHostId(hostId)); + + if (total > 0) { + await this.afterWrite(); + } + + return total; + } + + async deleteByHostIds(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const total = + (await this.deleteRecentByHostIds(hostIds)) + + (await this.deletePinnedByHostIds(hostIds)) + + (await this.deleteShortcutsByHostIds(hostIds)); + + if (total > 0) { + await this.afterWrite(); + } + + return total; + } + + private async existsPinned( + userId: string, + hostId: number, + path: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: fileManagerPinned.id }) + .from(fileManagerPinned) + .where( + and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.hostId, hostId), + eq(fileManagerPinned.path, path), + ), + ) + .limit(1); + + return rows.length > 0; + } + + private async existsRecentImportItem( + userId: string, + input: FileManagerBookmarkInput, + ): Promise { + const rows = await this.context.drizzle + .select({ id: fileManagerRecent.id }) + .from(fileManagerRecent) + .where( + and( + eq(fileManagerRecent.userId, userId), + eq(fileManagerRecent.path, input.path), + eq( + fileManagerRecent.name, + resolveBookmarkName(input.path, input.name), + ), + ), + ) + .limit(1); + + return rows.length > 0; + } + + private async existsPinnedImportItem( + userId: string, + input: FileManagerBookmarkInput, + ): Promise { + const rows = await this.context.drizzle + .select({ id: fileManagerPinned.id }) + .from(fileManagerPinned) + .where( + and( + eq(fileManagerPinned.userId, userId), + eq(fileManagerPinned.path, input.path), + eq( + fileManagerPinned.name, + resolveBookmarkName(input.path, input.name), + ), + ), + ) + .limit(1); + + return rows.length > 0; + } + + private async existsShortcutImportItem( + userId: string, + input: FileManagerBookmarkInput, + ): Promise { + const rows = await this.context.drizzle + .select({ id: fileManagerShortcuts.id }) + .from(fileManagerShortcuts) + .where( + and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.path, input.path), + eq( + fileManagerShortcuts.name, + resolveBookmarkName(input.path, input.name), + ), + ), + ) + .limit(1); + + return rows.length > 0; + } + + private async existsShortcut( + userId: string, + hostId: number, + path: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: fileManagerShortcuts.id }) + .from(fileManagerShortcuts) + .where( + and( + eq(fileManagerShortcuts.userId, userId), + eq(fileManagerShortcuts.hostId, hostId), + eq(fileManagerShortcuts.path, path), + ), + ) + .limit(1); + + return rows.length > 0; + } + + private async deleteRecentByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(fileManagerRecent) + .where(eq(fileManagerRecent.userId, userId)) + .returning({ id: fileManagerRecent.id }); + return rows.length; + } + + private async deletePinnedByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(fileManagerPinned) + .where(eq(fileManagerPinned.userId, userId)) + .returning({ id: fileManagerPinned.id }); + return rows.length; + } + + private async deleteShortcutsByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(fileManagerShortcuts) + .where(eq(fileManagerShortcuts.userId, userId)) + .returning({ id: fileManagerShortcuts.id }); + return rows.length; + } + + private async deleteRecentByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(fileManagerRecent) + .where(eq(fileManagerRecent.hostId, hostId)) + .returning({ id: fileManagerRecent.id }); + return rows.length; + } + + private async deletePinnedByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(fileManagerPinned) + .where(eq(fileManagerPinned.hostId, hostId)) + .returning({ id: fileManagerPinned.id }); + return rows.length; + } + + private async deleteShortcutsByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(fileManagerShortcuts) + .where(eq(fileManagerShortcuts.hostId, hostId)) + .returning({ id: fileManagerShortcuts.id }); + return rows.length; + } + + private async deleteRecentByHostIds(hostIds: number[]): Promise { + const rows = await this.context.drizzle + .delete(fileManagerRecent) + .where(inArray(fileManagerRecent.hostId, hostIds)) + .returning({ id: fileManagerRecent.id }); + return rows.length; + } + + private async deletePinnedByHostIds(hostIds: number[]): Promise { + const rows = await this.context.drizzle + .delete(fileManagerPinned) + .where(inArray(fileManagerPinned.hostId, hostIds)) + .returning({ id: fileManagerPinned.id }); + return rows.length; + } + + private async deleteShortcutsByHostIds(hostIds: number[]): Promise { + const rows = await this.context.drizzle + .delete(fileManagerShortcuts) + .where(inArray(fileManagerShortcuts.hostId, hostIds)) + .returning({ id: fileManagerShortcuts.id }); + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/homepage-item-repository.test.ts b/src/backend/database/repositories/homepage-item-repository.test.ts new file mode 100644 index 00000000..299aec92 --- /dev/null +++ b/src/backend/database/repositories/homepage-item-repository.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { HomepageItemRepository } from "./homepage-item-repository.js"; + +describe("HomepageItemRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE homepage_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + type_id TEXT NOT NULL, + title TEXT, + config TEXT NOT NULL DEFAULT '{}', + folder_id INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new HomepageItemRepository(context, onWrite); + } + + it("creates and lists homepage items by id", async () => { + const repo = await createRepository(); + + const first = await repo.createForUser( + "user-1", + { typeId: "clock", title: "Clock", config: "{}" }, + "2026-06-27T00:00:00.000Z", + ); + const second = await repo.createForUser("user-1", { + typeId: "terminal", + title: null, + config: '{"hostId":1}', + }); + await repo.createForUser("user-2", { + typeId: "other", + title: "Other", + config: "{}", + }); + + expect(first).toMatchObject({ + userId: "user-1", + typeId: "clock", + title: "Clock", + config: "{}", + createdAt: "2026-06-27T00:00:00.000Z", + }); + expect((await repo.listByUserId("user-1")).map((item) => item.id)).toEqual([ + first.id, + second.id, + ]); + }); + + it("finds, updates, and deletes user-owned homepage items", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const item = await repo.createForUser("user-1", { + typeId: "clock", + title: "Clock", + config: "{}", + }); + expect(writeCount).toBe(1); + + expect(await repo.findByIdForUser("user-2", item.id)).toBeNull(); + const updated = await repo.updateForUser( + "user-1", + item.id, + { title: "Clock renamed", config: '{"timezone":"UTC"}' }, + "2026-06-27T01:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: item.id, + title: "Clock renamed", + config: '{"timezone":"UTC"}', + updatedAt: "2026-06-27T01:00:00.000Z", + }); + expect(writeCount).toBe(2); + + expect( + await repo.updateForUser("user-2", item.id, { title: "Nope" }), + ).toBeNull(); + expect(writeCount).toBe(2); + + expect(await repo.deleteForUser("user-2", item.id)).toBe(false); + expect(await repo.deleteForUser("user-1", item.id)).toBe(true); + expect(writeCount).toBe(3); + }); + + it("deletes all homepage items for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.createForUser("user-1", { + typeId: "clock", + title: "Clock", + config: "{}", + }); + await repo.createForUser("user-1", { + typeId: "terminal", + title: "Terminal", + config: "{}", + }); + await repo.createForUser("user-2", { + typeId: "other", + title: "Other", + config: "{}", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserId("user-1")).toEqual([]); + expect( + (await repo.listByUserId("user-2")).map((item) => item.title), + ).toEqual(["Other"]); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/database/repositories/homepage-item-repository.ts b/src/backend/database/repositories/homepage-item-repository.ts new file mode 100644 index 00000000..b3e1f025 --- /dev/null +++ b/src/backend/database/repositories/homepage-item-repository.ts @@ -0,0 +1,114 @@ +import { and, asc, eq } from "drizzle-orm"; +import { homepageItems } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type HomepageItemRecord = typeof homepageItems.$inferSelect; + +export interface HomepageItemCreateInput { + typeId: string; + title: string | null; + config: string; +} + +export type HomepageItemUpdateInput = Partial<{ + title: string | null; + config: string; +}>; + +export class HomepageItemRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(homepageItems) + .where(eq(homepageItems.userId, userId)) + .orderBy(asc(homepageItems.id)); + } + + async createForUser( + userId: string, + input: HomepageItemCreateInput, + now = new Date().toISOString(), + ): Promise { + const [created] = await this.context.drizzle + .insert(homepageItems) + .values({ + userId, + typeId: input.typeId, + title: input.title, + config: input.config, + createdAt: now, + updatedAt: now, + }) + .returning(); + + await this.afterWrite(); + return created; + } + + async findByIdForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(homepageItems) + .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + async updateForUser( + userId: string, + id: number, + updates: HomepageItemUpdateInput, + updatedAt = new Date().toISOString(), + ): Promise { + const [updated] = await this.context.drizzle + .update(homepageItems) + .set({ ...updates, updatedAt }) + .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) + .returning(); + + if (updated) { + await this.afterWrite(); + } + + return updated ?? null; + } + + async deleteForUser(userId: string, id: number): Promise { + const rows = await this.context.drizzle + .delete(homepageItems) + .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) + .returning({ id: homepageItems.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(homepageItems) + .where(eq(homepageItems.userId, userId)) + .returning({ id: homepageItems.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/homepage-layout-repository.test.ts b/src/backend/database/repositories/homepage-layout-repository.test.ts new file mode 100644 index 00000000..49709b36 --- /dev/null +++ b/src/backend/database/repositories/homepage-layout-repository.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { HomepageLayoutRepository } from "./homepage-layout-repository.js"; + +describe("HomepageLayoutRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE homepage_layouts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + layout TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new HomepageLayoutRepository(context, onWrite); + } + + it("finds, creates, and updates a layout by user id", async () => { + const repo = await createRepository(); + + expect(await repo.findByUserId("user-1")).toBeNull(); + + const created = await repo.upsertForUser( + "user-1", + JSON.stringify({ entries: [{ id: "w1" }], zoom: 1 }), + "2026-06-27T00:00:00.000Z", + ); + expect(created).toMatchObject({ + userId: "user-1", + layout: '{"entries":[{"id":"w1"}],"zoom":1}', + updatedAt: "2026-06-27T00:00:00.000Z", + }); + + const updated = await repo.upsertForUser( + "user-1", + JSON.stringify({ entries: [], zoom: 1.25 }), + "2026-06-27T01:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: created.id, + userId: "user-1", + layout: '{"entries":[],"zoom":1.25}', + updatedAt: "2026-06-27T01:00:00.000Z", + }); + }); + + it("triggers writes after create and update", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForUser("user-1", "{}"); + await repo.upsertForUser("user-1", '{"zoom":2}'); + + expect(writeCount).toBe(2); + }); + + it("deletes a layout for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForUser("user-1", '{"zoom":1}'); + await repo.upsertForUser("user-2", '{"zoom":2}'); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findByUserId("user-1")).toBeNull(); + expect((await repo.findByUserId("user-2"))?.layout).toBe('{"zoom":2}'); + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/database/repositories/homepage-layout-repository.ts b/src/backend/database/repositories/homepage-layout-repository.ts new file mode 100644 index 00000000..453dd882 --- /dev/null +++ b/src/backend/database/repositories/homepage-layout-repository.ts @@ -0,0 +1,64 @@ +import { eq } from "drizzle-orm"; +import { homepageLayouts } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect; + +export class HomepageLayoutRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .select() + .from(homepageLayouts) + .where(eq(homepageLayouts.userId, userId)) + .limit(1); + + return rows[0] ?? null; + } + + async upsertForUser( + userId: string, + layout: string, + updatedAt = new Date().toISOString(), + ): Promise { + const existing = await this.findByUserId(userId); + + if (!existing) { + const [created] = await this.context.drizzle + .insert(homepageLayouts) + .values({ userId, layout, updatedAt }) + .returning(); + await this.afterWrite(); + return created; + } + + const [updated] = await this.context.drizzle + .update(homepageLayouts) + .set({ layout, updatedAt }) + .where(eq(homepageLayouts.userId, userId)) + .returning(); + await this.afterWrite(); + return updated; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(homepageLayouts) + .where(eq(homepageLayouts.userId, userId)) + .returning({ id: homepageLayouts.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/host-credential-repositories.test.ts b/src/backend/database/repositories/host-credential-repositories.test.ts new file mode 100644 index 00000000..8282d2d5 --- /dev/null +++ b/src/backend/database/repositories/host-credential-repositories.test.ts @@ -0,0 +1,757 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { CredentialRepository } from "./credential-repository.js"; +import { HostRepository } from "./host-repository.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { SystemCrypto } from "../../utils/system-crypto.js"; + +describe("HostRepository and CredentialRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + vi.restoreAllMocks(); + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepositories( + onCredentialWrite?: () => void, + onHostWrite?: () => void, + ): Promise<{ + credentials: CredentialRepository; + hosts: HostRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + auth_type TEXT NOT NULL, + username TEXT, + password TEXT, + key TEXT, + private_key TEXT, + public_key TEXT, + key_password TEXT, + key_type TEXT, + detected_key_type TEXT, + cert_public_key TEXT, + system_password TEXT, + system_key TEXT, + system_key_password TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER, + FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (override_credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL + ); + + CREATE TABLE ssh_credential_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'user', 'hash'), + ('user-2', 'other', 'hash'); + `); + + return { + credentials: new CredentialRepository(context, onCredentialWrite), + hosts: new HostRepository(context, onHostWrite), + sqlite: context.sqlite!, + }; + } + + it("creates, finds, updates, lists, and deletes credentials", async () => { + const repo = await createRepositories(); + + const created = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + folder: "prod", + }); + + expect(created.id).toBeGreaterThan(0); + expect(await repo.credentials.listFolders("user-1")).toEqual(["prod"]); + expect( + (await repo.credentials.findByIdForUser("user-1", created.id))?.name, + ).toBe("primary"); + expect((await repo.credentials.findById(created.id))?.name).toBe("primary"); + + const updated = await repo.credentials.updateForUser("user-1", created.id, { + folder: "ops", + tags: "linux,admin", + }); + expect(updated?.folder).toBe("ops"); + + expect( + await repo.credentials.findByIdForUser("user-2", created.id), + ).toBeNull(); + expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe( + true, + ); + expect( + await repo.credentials.findByIdForUser("user-1", created.id), + ).toBeNull(); + }); + + it("deletes user credentials through the cleanup boundary", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(onWrite); + + await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + }); + await repo.credentials.create({ + userId: "user-1", + name: "secondary", + authType: "key", + }); + await repo.credentials.create({ + userId: "user-2", + name: "other", + authType: "password", + }); + onWrite.mockClear(); + + await expect(repo.credentials.deleteByUserId("user-1")).resolves.toBe(2); + + expect(await repo.credentials.listByUserId("user-1")).toEqual([]); + expect((await repo.credentials.listByUserId("user-2")).length).toBe(1); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("loads credentials through the decryption boundary", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "decryptRecords").mockImplementation( + (_tableName, records) => records, + ); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => record, + ); + + const created = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + folder: "prod", + }); + + await expect( + repo.credentials.listDecryptedByUserId("user-1"), + ).resolves.toMatchObject([{ id: created.id, password: "secret" }]); + await expect( + repo.credentials.findDecryptedByIdForUser("user-1", created.id), + ).resolves.toMatchObject({ id: created.id, password: "secret" }); + expect(DataCrypto.decryptRecords).toHaveBeenCalledWith( + "ssh_credentials", + expect.arrayContaining([expect.objectContaining({ id: created.id })]), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ id: created.id }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("encrypts credential writes with user and system keys", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "encryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + password: "user-encrypted-password", + }) as typeof record, + ); + vi.spyOn(DataCrypto, "encryptRecordWithSystemKey").mockResolvedValue({ + systemPassword: "system-encrypted-password", + }); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => record, + ); + vi.spyOn( + SystemCrypto.getInstance(), + "getCredentialSharingKey", + ).mockResolvedValue(Buffer.from("system-key")); + + const created = await repo.credentials.createEncryptedForUser("user-1", { + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + }); + + const raw = repo.sqlite + .prepare( + "SELECT password, system_password FROM ssh_credentials WHERE id = ?", + ) + .get(created.id) as { password: string; system_password: string }; + + expect(raw.password).toBe("user-encrypted-password"); + expect(raw.system_password).toBe("system-encrypted-password"); + + await repo.credentials.updateEncryptedForUser("user-1", created.id, { + password: "updated-secret", + }); + + const updatedRaw = repo.sqlite + .prepare( + "SELECT password, system_password FROM ssh_credentials WHERE id = ?", + ) + .get(created.id) as { password: string; system_password: string }; + + expect(updatedRaw.password).toBe("user-encrypted-password"); + expect(updatedRaw.system_password).toBe("system-encrypted-password"); + expect(DataCrypto.encryptRecordWithSystemKey).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ password: "updated-secret" }), + Buffer.from("system-key"), + ); + }); + + it("finds and updates credential system encryption migration rows", async () => { + const repo = await createRepositories(); + + const complete = await repo.credentials.create({ + userId: "user-1", + name: "complete", + authType: "password", + password: "encrypted-password", + systemPassword: "system-password", + systemKey: "system-key", + systemKeyPassword: "system-key-password", + }); + const missing = await repo.credentials.create({ + userId: "user-1", + name: "missing", + authType: "key", + key: "encrypted-key", + systemPassword: null, + systemKey: null, + systemKeyPassword: null, + }); + await repo.credentials.create({ + userId: "user-2", + name: "other", + authType: "password", + systemPassword: null, + }); + + await expect( + repo.credentials.listMissingSystemEncryptionByUserId("user-1"), + ).resolves.toMatchObject([{ id: missing.id }]); + + await repo.credentials.updateSystemEncryptionForUser("user-1", missing.id, { + systemPassword: "new-system-password", + systemKey: "new-system-key", + systemKeyPassword: "new-system-key-password", + }); + + await expect( + repo.credentials.listMissingSystemEncryptionByUserId("user-1"), + ).resolves.toEqual([]); + await expect( + repo.credentials.findByIdForUser("user-1", complete.id), + ).resolves.toMatchObject({ systemPassword: "system-password" }); + }); + + it("checks credential import identity", async () => { + const repo = await createRepositories(); + + await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + }); + + await expect( + repo.credentials.existsForImportIdentity("user-1", "primary", "root"), + ).resolves.toBe(true); + await expect( + repo.credentials.existsForImportIdentity("user-1", "primary", "admin"), + ).resolves.toBe(false); + }); + + it("renames credential folders through the write boundary", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(onWrite); + + await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + folder: "prod", + }); + await repo.credentials.create({ + userId: "user-1", + name: "secondary", + authType: "key", + folder: "prod", + }); + await repo.credentials.create({ + userId: "user-2", + name: "other", + authType: "password", + folder: "prod", + }); + onWrite.mockClear(); + + await expect( + repo.credentials.renameFolder("user-1", "prod", "ops"), + ).resolves.toBe(2); + + expect(await repo.credentials.listFolders("user-1")).toEqual(["ops"]); + expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("returns empty credential reads when user data is locked", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(null); + + const created = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + username: "root", + password: "secret", + }); + + await expect( + repo.credentials.listDecryptedByUserId("user-1"), + ).resolves.toEqual([]); + await expect( + repo.credentials.findDecryptedByIdForUser("user-1", created.id), + ).resolves.toBeNull(); + }); + + it("creates, finds, updates, lists, and deletes hosts", async () => { + const repo = await createRepositories(); + + const host = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + }); + + expect(host.id).toBeGreaterThan(0); + expect((await repo.hosts.findById(host.id))?.name).toBe("web-1"); + expect( + (await repo.hosts.listByUserId("user-1")).map((item) => item.id), + ).toEqual([host.id]); + + const updated = await repo.hosts.updateForUser("user-1", host.id, { + name: "web-1-renamed", + folder: "prod", + }); + expect(updated?.name).toBe("web-1-renamed"); + expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull(); + + expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); + expect(await repo.hosts.findById(host.id)).toBeNull(); + }); + + it("encrypts host writes through the repository boundary", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "encryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + password: "encrypted-host-password", + }) as typeof record, + ); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => record, + ); + + const created = await repo.hosts.createEncryptedForUser("user-1", { + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + password: "secret", + }); + + const raw = repo.sqlite + .prepare("SELECT password FROM ssh_data WHERE id = ?") + .get(created.id) as { password: string }; + + expect(raw.password).toBe("encrypted-host-password"); + + await repo.hosts.updateEncryptedForUser("user-1", created.id, { + password: "updated-secret", + }); + + const updatedRaw = repo.sqlite + .prepare("SELECT password FROM ssh_data WHERE id = ?") + .get(created.id) as { password: string }; + + expect(updatedRaw.password).toBe("encrypted-host-password"); + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ password: "updated-secret" }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("loads hosts through the decryption boundary", async () => { + const repo = await createRepositories(); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "decryptRecords").mockImplementation( + (_tableName, records) => records, + ); + + const host = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + password: "secret", + }); + + await expect( + repo.hosts.listDecryptedByUserId("user-1"), + ).resolves.toMatchObject([{ id: host.id, password: "secret" }]); + expect(DataCrypto.decryptRecords).toHaveBeenCalledWith( + "ssh_data", + expect.arrayContaining([expect.objectContaining({ id: host.id })]), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("checks host import identity", async () => { + const repo = await createRepositories(); + + await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + }); + + await expect( + repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 22, "root"), + ).resolves.toBe(true); + await expect( + repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 2222, "root"), + ).resolves.toBe(false); + }); + + it("deletes user hosts through the cleanup boundary", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(undefined, onWrite); + + await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + }); + await repo.hosts.create({ + userId: "user-1", + name: "web-2", + ip: "10.0.0.11", + port: 22, + username: "root", + authType: "password", + }); + await repo.hosts.create({ + userId: "user-2", + name: "other", + ip: "10.0.0.12", + port: 22, + username: "root", + authType: "password", + }); + onWrite.mockClear(); + + await expect(repo.hosts.deleteByUserId("user-1")).resolves.toBe(2); + + expect(await repo.hosts.listByUserId("user-1")).toEqual([]); + expect((await repo.hosts.listByUserId("user-2")).length).toBe(1); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("lists bulk update state and updates multiple owned hosts", async () => { + const onWrite = vi.fn(); + const repo = await createRepositories(undefined, onWrite); + + const first = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "password", + statsConfig: JSON.stringify({ cpu: true }), + }); + const second = await repo.hosts.create({ + userId: "user-1", + name: "web-2", + ip: "10.0.0.11", + port: 22, + username: "root", + authType: "password", + }); + const other = await repo.hosts.create({ + userId: "user-2", + name: "other", + ip: "10.0.0.12", + port: 22, + username: "root", + authType: "password", + }); + onWrite.mockClear(); + + const states = await repo.hosts.listBulkUpdateState("user-1", [ + first.id, + second.id, + other.id, + ]); + expect(states.map((state) => state.id)).toEqual([first.id, second.id]); + + await expect( + repo.hosts.updateManyForUser("user-1", [first.id, second.id, other.id], { + folder: "ops", + }), + ).resolves.toBe(2); + expect((await repo.hosts.findById(first.id))?.folder).toBe("ops"); + expect((await repo.hosts.findById(other.id))?.folder).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("records credential usage and increments usage counters", async () => { + const repo = await createRepositories(); + const credential = await repo.credentials.create({ + userId: "user-1", + name: "primary", + authType: "password", + }); + const host = await repo.hosts.create({ + userId: "user-1", + name: "web-1", + ip: "10.0.0.10", + port: 22, + username: "root", + authType: "credential", + credentialId: credential.id, + }); + + await repo.credentials.recordUsage( + "user-1", + credential.id, + host.id, + "2026-06-26T00:00:00.000Z", + ); + + const updated = await repo.credentials.findByIdForUser( + "user-1", + credential.id, + ); + expect(updated?.usageCount).toBe(1); + expect(updated?.lastUsed).toBe("2026-06-26T00:00:00.000Z"); + }); + + it("cleans host access before deleting a host", async () => { + const repo = await createRepositories(); + const host = await repo.hosts.create({ + userId: "user-1", + name: "shared-host", + ip: "10.0.0.20", + port: 22, + username: "root", + authType: "password", + }); + + repo.sqlite + .prepare( + "INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)", + ) + .run(host.id, "user-2", "user-1"); + + expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1); + expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); + }); +}); diff --git a/src/backend/database/repositories/host-folder-repository.test.ts b/src/backend/database/repositories/host-folder-repository.test.ts new file mode 100644 index 00000000..a4f9c826 --- /dev/null +++ b/src/backend/database/repositories/host-folder-repository.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { HostFolderRepository } from "./host-folder-repository.js"; + +describe("HostFolderRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise<{ + repository: HostFolderRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT, + auth_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type) + VALUES + (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'), + (2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'prod / api', 'password'), + (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'prod', 'password'); + INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type) + VALUES + (1, 'user-1', 'cred-one', 'prod', 'password'), + (2, 'user-1', 'cred-two', 'prod / api', 'password'), + (3, 'user-2', 'cred-other', 'prod', 'password'); + INSERT INTO ssh_folders (id, user_id, name, color, icon) + VALUES + (1, 'user-1', 'prod', '#111111', 'server'), + (2, 'user-1', 'prod / api', '#222222', 'box'), + (3, 'user-2', 'prod', '#333333', 'user'); + `); + + return { + repository: new HostFolderRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("renames folders across hosts, credentials, and folder records", async () => { + let writes = 0; + const { repository, sqlite } = await createRepository(() => { + writes += 1; + }); + + await expect( + repository.renameFolder( + "user-1", + "prod", + "ops", + "2026-01-01T00:00:00.000Z", + ), + ).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 }); + + expect( + sqlite + .prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id") + .all("user-1"), + ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); + expect( + sqlite + .prepare( + "SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id", + ) + .all("user-1"), + ).toEqual([{ folder: "ops" }, { folder: "ops / api" }]); + expect( + sqlite + .prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id") + .all("user-1"), + ).toEqual([{ name: "ops" }, { name: "ops / api" }]); + expect(writes).toBe(1); + }); + + it("lists folders and upserts metadata", async () => { + let writes = 0; + const { repository } = await createRepository(() => { + writes += 1; + }); + + await expect(repository.listFolders("user-1")).resolves.toHaveLength(2); + await expect( + repository.upsertMetadata( + "user-1", + "prod", + "#abcdef", + "folder", + "2026-02-01T00:00:00.000Z", + ), + ).resolves.toMatchObject({ + created: false, + folder: { color: "#abcdef", icon: "folder" }, + }); + await expect( + repository.upsertMetadata( + "user-1", + "new", + null, + null, + "2026-03-01T00:00:00.000Z", + ), + ).resolves.toMatchObject({ + created: true, + folder: { name: "new" }, + }); + expect(writes).toBe(2); + }); + + it("lists and deletes hosts and folder records in a folder tree", async () => { + let writes = 0; + const { repository, sqlite } = await createRepository(() => { + writes += 1; + }); + + const hostsToDelete = await repository.listHostsInFolder("user-1", "prod"); + expect(hostsToDelete.map((host) => host.id)).toEqual([1, 2]); + + await repository.deleteHostsAndFolderRecords("user-1", "prod"); + + expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( + [{ id: 3 }], + ); + expect( + sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + ).toEqual([{ id: 3 }]); + expect(writes).toBe(1); + }); + + it("deletes folder records for a user", async () => { + let writes = 0; + const { repository, sqlite } = await createRepository(() => { + writes += 1; + }); + + await expect(repository.deleteByUserId("user-1")).resolves.toBe(2); + + expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual( + [{ id: 1 }, { id: 2 }, { id: 3 }], + ); + expect( + sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(), + ).toEqual([{ id: 3 }]); + expect(writes).toBe(1); + }); +}); diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts new file mode 100644 index 00000000..00feaa13 --- /dev/null +++ b/src/backend/database/repositories/host-folder-repository.ts @@ -0,0 +1,168 @@ +import { and, eq, like, or, sql } from "drizzle-orm"; +import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; +import { hosts, sshCredentials, sshFolders } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type HostFolderRecord = typeof sshFolders.$inferSelect; +export type HostFolderHostRecord = typeof hosts.$inferSelect; + +export interface RenameFolderResult { + updatedHosts: number; + updatedCredentials: number; +} + +export class HostFolderRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async renameFolder( + userId: string, + oldName: string, + newName: string, + now = new Date().toISOString(), + ): Promise { + const oldPrefix = `${oldName} / `; + const newPrefix = `${newName} / `; + const childLike = `${oldPrefix}%`; + const renameExpr = (col: SQLiteColumn) => + sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`; + const folderMatch = (col: SQLiteColumn) => + or(eq(col, oldName), like(col, childLike)); + + const updatedHosts = await this.context.drizzle + .update(hosts) + .set({ folder: renameExpr(hosts.folder), updatedAt: now }) + .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))) + .returning({ id: hosts.id }); + + const updatedCredentials = await this.context.drizzle + .update(sshCredentials) + .set({ folder: renameExpr(sshCredentials.folder), updatedAt: now }) + .where( + and( + eq(sshCredentials.userId, userId), + folderMatch(sshCredentials.folder), + ), + ) + .returning({ id: sshCredentials.id }); + + await this.context.drizzle + .update(sshFolders) + .set({ name: renameExpr(sshFolders.name), updatedAt: now }) + .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))); + + await this.afterWrite(); + return { + updatedHosts: updatedHosts.length, + updatedCredentials: updatedCredentials.length, + }; + } + + async listFolders(userId: string): Promise { + return this.context.drizzle + .select() + .from(sshFolders) + .where(eq(sshFolders.userId, userId)); + } + + async upsertMetadata( + userId: string, + name: string, + color: string | null | undefined, + icon: string | null | undefined, + now = new Date().toISOString(), + ): Promise<{ folder: HostFolderRecord; created: boolean }> { + const existing = await this.findFolder(userId, name); + if (existing) { + const [updated] = await this.context.drizzle + .update(sshFolders) + .set({ color, icon, updatedAt: now }) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) + .returning(); + + await this.afterWrite(); + return { folder: updated, created: false }; + } + + const [created] = await this.context.drizzle + .insert(sshFolders) + .values({ + userId, + name, + color, + icon, + createdAt: now, + updatedAt: now, + }) + .returning(); + + await this.afterWrite(); + return { folder: created, created: true }; + } + + async listHostsInFolder( + userId: string, + folderName: string, + ): Promise { + const folderMatch = (col: SQLiteColumn) => + or(eq(col, folderName), like(col, `${folderName} / %`)); + + return this.context.drizzle + .select() + .from(hosts) + .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); + } + + async deleteHostsAndFolderRecords( + userId: string, + folderName: string, + ): Promise { + const folderMatch = (col: SQLiteColumn) => + or(eq(col, folderName), like(col, `${folderName} / %`)); + + const hostsToDelete = await this.listHostsInFolder(userId, folderName); + if (hostsToDelete.length > 0) { + await this.context.drizzle + .delete(hosts) + .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); + } + + await this.context.drizzle + .delete(sshFolders) + .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))); + + await this.afterWrite(); + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sshFolders) + .where(eq(sshFolders.userId, userId)) + .returning({ id: sshFolders.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async findFolder( + userId: string, + name: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sshFolders) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/host-health-repository.test.ts b/src/backend/database/repositories/host-health-repository.test.ts new file mode 100644 index 00000000..3049c00d --- /dev/null +++ b/src/backend/database/repositories/host-health-repository.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { HostHealthRepository } from "./host-health-repository.js"; + +describe("HostHealthRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE host_health_checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + checks TEXT NOT NULL, + interval_seconds INTEGER NOT NULL DEFAULT 300, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_health_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + check_id TEXT NOT NULL, + ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ok INTEGER NOT NULL, + latency_ms INTEGER, + detail TEXT + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO host_health_checks ( + user_id, host_id, checks, interval_seconds, created_at, updated_at + ) + VALUES ( + 'user-1', + 1, + '[{"id":"tcp","name":"TCP","type":"tcp","target":"localhost","port":22}]', + 300, + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + `); + + return new HostHealthRepository(context, onWrite); + } + + it("finds and upserts check configuration", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const existing = await repo.findChecksByUserAndHost("user-1", 1); + expect(existing?.intervalSeconds).toBe(300); + + const updated = await repo.upsertChecks( + "user-1", + 1, + '[{"id":"http"}]', + 60, + "2026-02-01T00:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: existing?.id, + checks: '[{"id":"http"}]', + intervalSeconds: 60, + updatedAt: "2026-02-01T00:00:00.000Z", + }); + + const created = await repo.upsertChecks( + "user-2", + 2, + '[{"id":"tcp"}]', + 120, + "2026-03-01T00:00:00.000Z", + ); + expect(created).toMatchObject({ + userId: "user-2", + hostId: 2, + checks: '[{"id":"tcp"}]', + intervalSeconds: 120, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(writeCount).toBe(2); + }); + + it("records and prunes history", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.recordHistory( + "user-1", + 1, + [{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }], + 1, + "2026-01-01T00:00:00.000Z", + ), + ).toBe(1); + expect( + await repo.recordHistory( + "user-1", + 1, + [{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }], + 1, + "2026-01-02T00:00:00.000Z", + ), + ).toBe(1); + + const history = await repo.listHistory("user-1", 1, 10); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + userId: "user-1", + hostId: 1, + checkId: "two", + ok: false, + latencyMs: null, + detail: "closed", + }); + expect(writeCount).toBe(2); + }); + + it("deletes all health checks and history for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertChecks("user-2", 2, '[{"id":"tcp"}]', 120); + await repo.recordHistory( + "user-1", + 1, + [{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }], + 10, + ); + await repo.recordHistory( + "user-2", + 2, + [{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }], + 10, + ); + + await expect(repo.deleteByUserId("user-1")).resolves.toEqual({ + checksDeleted: 1, + historyDeleted: 1, + }); + await expect(repo.deleteByUserId("missing")).resolves.toEqual({ + checksDeleted: 0, + historyDeleted: 0, + }); + + expect(await repo.findChecksByUserAndHost("user-1", 1)).toBeNull(); + expect(await repo.listHistory("user-1", 1, 10)).toEqual([]); + expect((await repo.findChecksByUserAndHost("user-2", 2))?.hostId).toBe(2); + expect(await repo.listHistory("user-2", 2, 10)).toHaveLength(1); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/database/repositories/host-health-repository.ts b/src/backend/database/repositories/host-health-repository.ts new file mode 100644 index 00000000..4e5bae87 --- /dev/null +++ b/src/backend/database/repositories/host-health-repository.ts @@ -0,0 +1,164 @@ +import { and, desc, eq } from "drizzle-orm"; +import { hostHealthChecks, hostHealthHistory } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect; +export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect; + +export interface HostHealthResultInput { + checkId: string; + ok: boolean; + latencyMs: number | null; + detail: string; +} + +export class HostHealthRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findChecksByUserAndHost( + userId: string, + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostHealthChecks) + .where( + and( + eq(hostHealthChecks.userId, userId), + eq(hostHealthChecks.hostId, hostId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async upsertChecks( + userId: string, + hostId: number, + checks: string, + intervalSeconds: number, + now = new Date().toISOString(), + ): Promise { + const existing = await this.findChecksByUserAndHost(userId, hostId); + if (existing) { + const [updated] = await this.context.drizzle + .update(hostHealthChecks) + .set({ checks, intervalSeconds, updatedAt: now }) + .where(eq(hostHealthChecks.id, existing.id)) + .returning(); + + await this.afterWrite(); + return updated; + } + + const [created] = await this.context.drizzle + .insert(hostHealthChecks) + .values({ + userId, + hostId, + checks, + intervalSeconds, + createdAt: now, + updatedAt: now, + }) + .returning(); + + await this.afterWrite(); + return created; + } + + async recordHistory( + userId: string, + hostId: number, + results: HostHealthResultInput[], + keep: number, + now = new Date().toISOString(), + ): Promise { + if (results.length === 0) { + return 0; + } + + await this.context.drizzle.insert(hostHealthHistory).values( + results.map((result) => ({ + userId, + hostId, + checkId: result.checkId, + ts: now, + ok: result.ok, + latencyMs: result.latencyMs, + detail: result.detail, + })), + ); + + this.pruneHistory(userId, hostId, keep); + await this.afterWrite(); + return results.length; + } + + async listHistory( + userId: string, + hostId: number, + limit: number, + ): Promise { + return this.context.drizzle + .select() + .from(hostHealthHistory) + .where( + and( + eq(hostHealthHistory.userId, userId), + eq(hostHealthHistory.hostId, hostId), + ), + ) + .orderBy(desc(hostHealthHistory.ts)) + .limit(limit); + } + + async deleteByUserId(userId: string): Promise<{ + checksDeleted: number; + historyDeleted: number; + }> { + const historyRows = await this.context.drizzle + .delete(hostHealthHistory) + .where(eq(hostHealthHistory.userId, userId)) + .returning({ id: hostHealthHistory.id }); + + const checkRows = await this.context.drizzle + .delete(hostHealthChecks) + .where(eq(hostHealthChecks.userId, userId)) + .returning({ id: hostHealthChecks.id }); + + if (historyRows.length > 0 || checkRows.length > 0) { + await this.afterWrite(); + } + + return { + checksDeleted: checkRows.length, + historyDeleted: historyRows.length, + }; + } + + private pruneHistory(userId: string, hostId: number, keep: number): void { + this.context.sqlite + ?.prepare( + `DELETE FROM host_health_history + WHERE id IN ( + SELECT id FROM host_health_history + WHERE user_id = ? AND host_id = ? + AND id NOT IN ( + SELECT id FROM host_health_history + WHERE user_id = ? AND host_id = ? + ORDER BY ts DESC LIMIT ? + ) + )`, + ) + .run(userId, hostId, userId, hostId, keep); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/host-metrics-history-repository.test.ts b/src/backend/database/repositories/host-metrics-history-repository.test.ts new file mode 100644 index 00000000..6ae5e7e8 --- /dev/null +++ b/src/backend/database/repositories/host-metrics-history-repository.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js"; + +describe("HostMetricsHistoryRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE host_metrics_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + cpu_percent REAL, + mem_percent REAL, + disk_percent REAL, + net_rx_bytes INTEGER, + net_tx_bytes INTEGER + ); + + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO host_metrics_history ( + host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes + ) + VALUES + (1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200), + (1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201), + (1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202), + (2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999); + `); + + return new HostMetricsHistoryRepository(context, onWrite); + } + + it("creates and lists metrics history rows by range", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create({ + hostId: 1, + cpuPercent: 12, + memPercent: 22, + diskPercent: 32, + netRxBytes: 102, + netTxBytes: 202, + }); + + const rows = await repo.listRange( + 1, + "2026-01-01 00:00:00", + "2026-01-02 23:59:59", + ); + + expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]); + expect(writeCount).toBe(1); + }); + + it("prunes old history for a host only", async () => { + const repo = await createRepository(); + + repo.pruneOlderThan(1, 1); + + const rows = await repo.listRange( + 1, + "2000-01-01 00:00:00", + "2999-12-31 23:59:59", + ); + expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]); + expect( + await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"), + ).toHaveLength(1); + }); +}); diff --git a/src/backend/database/repositories/host-metrics-history-repository.ts b/src/backend/database/repositories/host-metrics-history-repository.ts new file mode 100644 index 00000000..8a919165 --- /dev/null +++ b/src/backend/database/repositories/host-metrics-history-repository.ts @@ -0,0 +1,64 @@ +import { and, asc, eq, gte, lte } from "drizzle-orm"; +import { hostMetricsHistory } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect; + +export interface HostMetricsHistoryCreateInput { + hostId: number; + cpuPercent?: number | null; + memPercent?: number | null; + diskPercent?: number | null; + netRxBytes?: number | null; + netTxBytes?: number | null; +} + +export class HostMetricsHistoryRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(input: HostMetricsHistoryCreateInput): Promise { + await this.context.drizzle.insert(hostMetricsHistory).values({ + hostId: input.hostId, + cpuPercent: input.cpuPercent, + memPercent: input.memPercent, + diskPercent: input.diskPercent, + netRxBytes: input.netRxBytes, + netTxBytes: input.netTxBytes, + }); + + await this.afterWrite(); + } + + pruneOlderThan(hostId: number, retentionDays: number): void { + this.context.sqlite + ?.prepare( + "DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)", + ) + .run(hostId, `-${retentionDays} days`); + } + + async listRange( + hostId: number, + fromTs: string, + toTs: string, + ): Promise { + return this.context.drizzle + .select() + .from(hostMetricsHistory) + .where( + and( + eq(hostMetricsHistory.hostId, hostId), + gte(hostMetricsHistory.ts, fromTs), + lte(hostMetricsHistory.ts, toTs), + ), + ) + .orderBy(asc(hostMetricsHistory.ts)); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/host-metrics-preference-repository.test.ts b/src/backend/database/repositories/host-metrics-preference-repository.test.ts new file mode 100644 index 00000000..91f52533 --- /dev/null +++ b/src/backend/database/repositories/host-metrics-preference-repository.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js"; + +describe("HostMetricsPreferenceRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + stats_config TEXT + ); + + CREATE TABLE host_metrics_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + layout TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, stats_config) + VALUES (1, 'user-1', 'one', '{}'), (2, 'user-2', 'two', '{}'); + INSERT INTO host_metrics_preferences ( + user_id, host_id, layout, created_at, updated_at + ) + VALUES ( + 'user-1', + 1, + '{"slots":[],"columns":3}', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ); + `); + + return new HostMetricsPreferenceRepository(context, onWrite); + } + + it("finds a saved layout by user and host", async () => { + const repo = await createRepository(); + + const existing = await repo.findByUserAndHost("user-1", 1); + expect(existing?.layout).toBe('{"slots":[],"columns":3}'); + expect(await repo.findByUserAndHost("user-1", 2)).toBeNull(); + }); + + it("updates and inserts layouts with write notifications", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.upsertLayout( + "user-1", + 1, + '{"slots":[{"id":"cpu"}],"columns":2}', + "2026-02-01T00:00:00.000Z", + ); + expect(updated).toMatchObject({ + id: 1, + layout: '{"slots":[{"id":"cpu"}],"columns":2}', + updatedAt: "2026-02-01T00:00:00.000Z", + }); + + const created = await repo.upsertLayout( + "user-2", + 2, + '{"slots":[{"id":"mem"}],"columns":1}', + "2026-03-01T00:00:00.000Z", + ); + expect(created).toMatchObject({ + userId: "user-2", + hostId: 2, + layout: '{"slots":[{"id":"mem"}],"columns":1}', + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(writeCount).toBe(2); + }); + + it("updates host stats config for the owning user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect( + repo.updateHostStatsConfig( + "user-1", + 1, + '{"enabledWidgets":["cpu","memory"]}', + ), + ).resolves.toBe(true); + await expect( + repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'), + ).resolves.toBe(false); + + expect(writeCount).toBe(1); + }); + + it("deletes all metric preferences for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}'); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); + expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe( + '{"slots":["mem"]}', + ); + expect(writeCount).toBe(2); + }); +}); diff --git a/src/backend/database/repositories/host-metrics-preference-repository.ts b/src/backend/database/repositories/host-metrics-preference-repository.ts new file mode 100644 index 00000000..1a5d3eff --- /dev/null +++ b/src/backend/database/repositories/host-metrics-preference-repository.ts @@ -0,0 +1,97 @@ +import { and, eq } from "drizzle-orm"; +import { hostMetricsPreferences, hosts } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type HostMetricsPreferenceRecord = + typeof hostMetricsPreferences.$inferSelect; + +export class HostMetricsPreferenceRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findByUserAndHost( + userId: string, + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostMetricsPreferences) + .where( + and( + eq(hostMetricsPreferences.userId, userId), + eq(hostMetricsPreferences.hostId, hostId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async upsertLayout( + userId: string, + hostId: number, + layout: string, + now = new Date().toISOString(), + ): Promise { + const existing = await this.findByUserAndHost(userId, hostId); + if (existing) { + const [updated] = await this.context.drizzle + .update(hostMetricsPreferences) + .set({ layout, updatedAt: now }) + .where(eq(hostMetricsPreferences.id, existing.id)) + .returning(); + + await this.afterWrite(); + return updated; + } + + const [created] = await this.context.drizzle + .insert(hostMetricsPreferences) + .values({ + userId, + hostId, + layout, + createdAt: now, + updatedAt: now, + }) + .returning(); + + await this.afterWrite(); + return created; + } + + async updateHostStatsConfig( + userId: string, + hostId: number, + statsConfig: string, + ): Promise { + const rows = await this.context.drizzle + .update(hosts) + .set({ statsConfig }) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .returning({ id: hosts.id }); + + if (rows.length === 0) return false; + await this.afterWrite(); + return true; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(hostMetricsPreferences) + .where(eq(hostMetricsPreferences.userId, userId)) + .returning({ id: hostMetricsPreferences.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/host-repository.ts b/src/backend/database/repositories/host-repository.ts new file mode 100644 index 00000000..d8ee5086 --- /dev/null +++ b/src/backend/database/repositories/host-repository.ts @@ -0,0 +1,239 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { hostAccess, hosts } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; + +export type HostRecord = typeof hosts.$inferSelect; +export type NewHostRecord = typeof hosts.$inferInsert; +export type HostUpdate = Partial>; +export interface HostBulkUpdateState { + id: number; + statsConfig: string | null; + credentialId: number | null; + proxmoxConfig: string | null; +} + +export class HostRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(host: NewHostRecord): Promise { + const rows = await this.context.drizzle + .insert(hosts) + .values(host) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async createEncryptedForUser( + userId: string, + host: NewHostRecord | Record, + ): Promise { + const userDataKey = DataCrypto.validateUserAccess(userId); + const tempId = host.id ?? Date.now(); + const dataWithTempId = { ...host, id: tempId }; + const encryptedHost = DataCrypto.encryptRecord( + "ssh_data", + dataWithTempId, + userId, + userDataKey, + ); + + if (!host.id) { + delete (encryptedHost as Partial).id; + } + + const rows = await this.context.drizzle + .insert(hosts) + .values(encryptedHost as NewHostRecord) + .returning(); + + await this.afterWrite(); + return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey); + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(hosts) + .where(eq(hosts.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async findByIdForUser( + userId: string, + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hosts) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(hosts) + .where(eq(hosts.userId, userId)); + } + + async listDecryptedByUserId(userId: string): Promise { + const rows = await this.listByUserId(userId); + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) return []; + return DataCrypto.decryptRecords("ssh_data", rows, userId, userDataKey); + } + + async existsForImportIdentity( + userId: string, + ip: string, + port: number, + username: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: hosts.id }) + .from(hosts) + .where( + and( + eq(hosts.userId, userId), + eq(hosts.ip, ip), + eq(hosts.port, port), + eq(hosts.username, username), + ), + ) + .limit(1); + + return rows.length > 0; + } + + async updateForUser( + userId: string, + hostId: number, + update: HostUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(hosts) + .set(update) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async updateEncryptedForUser( + userId: string, + hostId: number, + update: HostUpdate, + ): Promise { + const userDataKey = DataCrypto.validateUserAccess(userId); + const encryptedUpdate = DataCrypto.encryptRecord( + "ssh_data", + update, + userId, + userDataKey, + ); + + const rows = await this.context.drizzle + .update(hosts) + .set(encryptedUpdate) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .returning(); + + await this.afterWrite(); + return rows[0] + ? DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey) + : null; + } + + async listBulkUpdateState( + userId: string, + hostIds: number[], + ): Promise { + if (hostIds.length === 0) { + return []; + } + + return this.context.drizzle + .select({ + id: hosts.id, + statsConfig: hosts.statsConfig, + credentialId: hosts.credentialId, + proxmoxConfig: hosts.proxmoxConfig, + }) + .from(hosts) + .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); + } + + async updateManyForUser( + userId: string, + hostIds: number[], + update: HostUpdate, + ): Promise { + if (hostIds.length === 0 || Object.keys(update).length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .update(hosts) + .set(update) + .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))) + .returning({ id: hosts.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteForUser(userId: string, hostId: number): Promise { + await this.deleteAccessForHost(hostId); + + const rows = await this.context.drizzle + .delete(hosts) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .returning({ id: hosts.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(hosts) + .where(eq(hosts.userId, userId)) + .returning({ id: hosts.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteAccessForHost(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.hostId, hostId)) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/host-resolution-repository.test.ts b/src/backend/database/repositories/host-resolution-repository.test.ts new file mode 100644 index 00000000..5a2a0ee4 --- /dev/null +++ b/src/backend/database/repositories/host-resolution-repository.test.ts @@ -0,0 +1,501 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { HostResolutionRepository } from "./host-resolution-repository.js"; + +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { + getUserDataKey: vi.fn(), + decryptRecord: vi.fn((_tableName, record) => record), + }, +})); + +describe("HostResolutionRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReset(); + vi.mocked(DataCrypto.decryptRecord).mockClear(); + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + auth_type TEXT NOT NULL, + username TEXT, + password TEXT, + key TEXT, + private_key TEXT, + public_key TEXT, + key_password TEXT, + key_type TEXT, + detected_key_type TEXT, + cert_public_key TEXT, + system_password TEXT, + system_key TEXT, + system_key_password TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data ( + id, user_id, name, ip, port, username, auth_type, credential_id, + tunnel_connections + ) + VALUES + (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'), + (2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL), + (3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]'); + INSERT INTO ssh_credentials ( + id, user_id, name, auth_type, username, password, private_key, key_password + ) + VALUES + (7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL), + (8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass'); + INSERT INTO host_access ( + host_id, user_id, granted_by, permission_level, override_credential_id + ) + VALUES (1, 'user-2', 'user-1', 'execute', 8); + `); + + return new HostResolutionRepository(context, onWrite); + } + + it("loads host and credential rows through the decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue( + Buffer.from("user-key"), + ); + const repository = await createRepository(); + + await expect(repository.findHostById(1, "user-1")).resolves.toMatchObject({ + id: 1, + userId: "user-1", + name: "web", + credentialId: 7, + }); + await expect( + repository.findHostByIdForUser(1, "user-1"), + ).resolves.toMatchObject({ + id: 1, + userId: "user-1", + name: "web", + credentialId: 7, + }); + await expect( + repository.findHostByIdForUser(3, "user-1"), + ).resolves.toBeNull(); + await expect( + repository.findCredentialByIdForUser(7, "user-1"), + ).resolves.toMatchObject({ + id: 7, + userId: "user-1", + username: "root", + password: "secret", + }); + await expect( + repository.findCredentialByIdForOwnerDecryptedAs(7, "user-1", "user-2"), + ).resolves.toMatchObject({ + id: 7, + userId: "user-1", + username: "root", + password: "secret", + }); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ id: 7 }), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_credentials", + expect.objectContaining({ id: 7 }), + "user-2", + Buffer.from("user-key"), + ); + }); + + it("lists user-owned hosts through the decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue( + Buffer.from("user-key"), + ); + const repository = await createRepository(); + + const rows = await repository.findHostsByUserId("user-1"); + + expect(rows.map((row) => row.id)).toEqual([1, 2]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 2 }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("lists raw own and shared host rows for access list assembly", async () => { + const repository = await createRepository(); + + const rows = await repository.listHostRowsForAccessList("user-2", [ + { hostId: 1, permissionLevel: "execute", expiresAt: null }, + { hostId: 3, permissionLevel: "view", expiresAt: null }, + { hostId: 999, permissionLevel: "view", expiresAt: null }, + ]); + + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + id: 3, + userId: "user-2", + ownerId: "user-2", + isShared: false, + permissionLevel: undefined, + expiresAt: undefined, + }); + expect(rows[1]).toMatchObject({ + id: 1, + userId: "user-1", + ownerId: "user-1", + isShared: true, + permissionLevel: "execute", + expiresAt: null, + }); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("loads host owner metadata without decrypting host data", async () => { + const repository = await createRepository(); + + await expect(repository.findHostOwnerId(1)).resolves.toBe("user-1"); + await expect(repository.findHostOwnerId(999)).resolves.toBeNull(); + await expect(repository.isHostOwnedByUser(1, "user-1")).resolves.toBe(true); + await expect(repository.isHostOwnedByUser(1, "user-2")).resolves.toBe( + false, + ); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("loads host update state without decrypting host data", async () => { + const repository = await createRepository(); + + await expect(repository.findHostUpdateState(1)).resolves.toEqual({ + userId: "user-1", + credentialId: 7, + rdpCredentialId: null, + vncCredentialId: null, + telnetCredentialId: null, + authType: "password", + }); + await expect(repository.findHostUpdateState(999)).resolves.toBeNull(); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("lists hosts using a credential through the decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue( + Buffer.from("user-key"), + ); + const repository = await createRepository(); + + const rows = await repository.listHostsUsingCredentialForUser("user-1", 7); + + expect(rows.map((row) => row.id)).toEqual([1]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("lists all hosts through each owner decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) => + Buffer.from(`${userId}-key`), + ); + const repository = await createRepository(); + + const rows = await repository.listAllHosts(); + + expect(rows.map((row) => row.id)).toEqual([1, 2, 3]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-1-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 3 }), + "user-2", + Buffer.from("user-2-key"), + ); + }); + + it("lists tunnel-enabled hosts with tunnel data through each owner decryption boundary", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) => + Buffer.from(`${userId}-key`), + ); + const repository = await createRepository(); + + const rows = await repository.listHostsWithTunnelConnections(); + + expect(rows.map((row) => row.id)).toEqual([1, 3]); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 1 }), + "user-1", + Buffer.from("user-1-key"), + ); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 3 }), + "user-2", + Buffer.from("user-2-key"), + ); + }); + + it("skips owner-scoped host list rows when that user's data is locked", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) => + userId === "user-1" ? Buffer.from("user-1-key") : null, + ); + const repository = await createRepository(); + + const rows = await repository.listAllHosts(); + + expect(rows.map((row) => row.id)).toEqual([1, 2]); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalledWith( + "ssh_data", + expect.objectContaining({ id: 3 }), + expect.any(String), + expect.any(Buffer), + ); + }); + + it("loads host key verification metadata without decrypting credentials", async () => { + const repository = await createRepository(); + + const row = await repository.findHostKeyVerificationData(1); + + expect(row).toMatchObject({ + hostKeyFingerprint: null, + hostKeyType: null, + hostKeyAlgorithm: "sha256", + hostKeyChangedCount: 0, + name: "web", + }); + expect(DataCrypto.decryptRecord).not.toHaveBeenCalled(); + }); + + it("stores and updates host key verification metadata through the write boundary", async () => { + const onWrite = vi.fn(); + const repository = await createRepository(onWrite); + + await repository.storeHostKey( + 1, + "fingerprint-1", + "ssh-rsa", + "sha256", + "t1", + ); + await expect( + repository.findHostKeyVerificationData(1), + ).resolves.toMatchObject({ + hostKeyFingerprint: "fingerprint-1", + hostKeyType: "ssh-rsa", + hostKeyAlgorithm: "sha256", + hostKeyChangedCount: 0, + }); + + await repository.touchHostKeyLastVerified(1, "t2"); + await repository.updateHostKey( + 1, + "fingerprint-2", + "ssh-ed25519", + "sha256", + 0, + "t3", + ); + + await expect( + repository.findHostKeyVerificationData(1), + ).resolves.toMatchObject({ + hostKeyFingerprint: "fingerprint-2", + hostKeyType: "ssh-ed25519", + hostKeyAlgorithm: "sha256", + hostKeyChangedCount: 1, + }); + expect(onWrite).toHaveBeenCalledTimes(3); + }); + + it("returns null when user data is locked", async () => { + vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(null); + const repository = await createRepository(); + + await expect(repository.findHostById(1, "user-1")).resolves.toBeNull(); + await expect( + repository.findHostByIdForUser(1, "user-1"), + ).resolves.toBeNull(); + await expect(repository.findHostsByUserId("user-1")).resolves.toEqual([]); + await expect( + repository.listHostsUsingCredentialForUser("user-1", 7), + ).resolves.toEqual([]); + await expect( + repository.findCredentialByIdForUser(7, "user-1"), + ).resolves.toBeNull(); + }); + + it("loads override credential ids for shared host resolution", async () => { + const repository = await createRepository(); + + await expect( + repository.findOverrideCredentialId(1, "user-2"), + ).resolves.toBe(8); + await expect( + repository.findOverrideCredentialId(1, "user-1"), + ).resolves.toBeNull(); + }); +}); diff --git a/src/backend/database/repositories/host-resolution-repository.ts b/src/backend/database/repositories/host-resolution-repository.ts new file mode 100644 index 00000000..e3a229c5 --- /dev/null +++ b/src/backend/database/repositories/host-resolution-repository.ts @@ -0,0 +1,354 @@ +import { and, eq, inArray, isNotNull } from "drizzle-orm"; +import { hostAccess, hosts, sshCredentials } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; + +export type HostResolutionHostRecord = typeof hosts.$inferSelect; +export type HostResolutionCredentialRecord = typeof sshCredentials.$inferSelect; +export interface HostKeyVerificationRecord { + hostKeyFingerprint: string | null; + hostKeyType: string | null; + hostKeyAlgorithm: string | null; + hostKeyChangedCount: number | null; + name: string | null; +} +export interface HostUpdateStateRecord { + userId: string; + credentialId: number | null; + rdpCredentialId: number | null; + vncCredentialId: number | null; + telnetCredentialId: number | null; + authType: string; +} +export interface HostListAccessEntry { + hostId: number; + permissionLevel: string; + expiresAt: string | null; +} +export type HostListRow = HostResolutionHostRecord & { + ownerId: string; + isShared: boolean; + permissionLevel?: string; + expiresAt?: string | null; +}; + +export class HostResolutionRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findHostById( + hostId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hosts) + .where(eq(hosts.id, hostId)) + .limit(1); + + return this.decryptOne("ssh_data", rows[0], userId); + } + + async findHostByIdForUser( + hostId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hosts) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .limit(1); + + return this.decryptOne("ssh_data", rows[0], userId); + } + + async findHostUpdateState( + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select({ + userId: hosts.userId, + credentialId: hosts.credentialId, + rdpCredentialId: hosts.rdpCredentialId, + vncCredentialId: hosts.vncCredentialId, + telnetCredentialId: hosts.telnetCredentialId, + authType: hosts.authType, + }) + .from(hosts) + .where(eq(hosts.id, hostId)) + .limit(1); + + return rows[0] ?? null; + } + + async findHostsByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .select() + .from(hosts) + .where(eq(hosts.userId, userId)); + + return this.decryptMany("ssh_data", rows, userId); + } + + async listHostRowsForAccessList( + userId: string, + accessEntries: HostListAccessEntry[], + ): Promise { + const ownHostRows = await this.context.drizzle + .select() + .from(hosts) + .where(eq(hosts.userId, userId)); + + const sharedHostIds = Array.from( + new Set(accessEntries.map((access) => access.hostId)), + ); + const sharedHostRows = + sharedHostIds.length > 0 + ? await this.context.drizzle + .select() + .from(hosts) + .where(inArray(hosts.id, sharedHostIds)) + : []; + const sharedHostsById = new Map( + sharedHostRows.map((host) => [host.id, host]), + ); + + return [ + ...ownHostRows.map((host) => ({ + ...host, + ownerId: host.userId, + isShared: false, + permissionLevel: undefined, + expiresAt: undefined, + })), + ...accessEntries.flatMap((access) => { + const host = sharedHostsById.get(access.hostId); + if (!host || host.userId === userId) { + return []; + } + + return [ + { + ...host, + ownerId: host.userId, + isShared: host.userId !== userId, + permissionLevel: access.permissionLevel, + expiresAt: access.expiresAt, + }, + ]; + }), + ]; + } + + async findHostOwnerId(hostId: number): Promise { + const rows = await this.context.drizzle + .select({ ownerId: hosts.userId }) + .from(hosts) + .where(eq(hosts.id, hostId)) + .limit(1); + + return rows[0]?.ownerId ?? null; + } + + async isHostOwnedByUser(hostId: number, userId: string): Promise { + const rows = await this.context.drizzle + .select({ id: hosts.id }) + .from(hosts) + .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) + .limit(1); + + return rows.length > 0; + } + + async listAllHosts(): Promise { + const rows = await this.context.drizzle.select().from(hosts); + + return this.decryptManyByOwner("ssh_data", rows); + } + + async listHostsWithTunnelConnections(): Promise { + const rows = await this.context.drizzle + .select() + .from(hosts) + .where( + and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)), + ); + + return this.decryptManyByOwner("ssh_data", rows); + } + + async listHostsUsingCredentialForUser( + userId: string, + credentialId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hosts) + .where( + and(eq(hosts.credentialId, credentialId), eq(hosts.userId, userId)), + ); + + return this.decryptMany("ssh_data", rows, userId); + } + + async findHostKeyVerificationData( + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select({ + hostKeyFingerprint: hosts.hostKeyFingerprint, + hostKeyType: hosts.hostKeyType, + hostKeyAlgorithm: hosts.hostKeyAlgorithm, + hostKeyChangedCount: hosts.hostKeyChangedCount, + name: hosts.name, + }) + .from(hosts) + .where(eq(hosts.id, hostId)) + .limit(1); + + return rows[0] ?? null; + } + + async storeHostKey( + hostId: number, + fingerprint: string, + keyType: string, + algorithm: string, + now = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(hosts) + .set({ + hostKeyFingerprint: fingerprint, + hostKeyType: keyType, + hostKeyAlgorithm: algorithm, + hostKeyFirstSeen: now, + hostKeyLastVerified: now, + }) + .where(eq(hosts.id, hostId)); + await this.afterWrite(); + } + + async updateHostKey( + hostId: number, + fingerprint: string, + keyType: string, + algorithm: string, + currentChangeCount: number, + now = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(hosts) + .set({ + hostKeyFingerprint: fingerprint, + hostKeyType: keyType, + hostKeyAlgorithm: algorithm, + hostKeyLastVerified: now, + hostKeyChangedCount: currentChangeCount + 1, + }) + .where(eq(hosts.id, hostId)); + await this.afterWrite(); + } + + async touchHostKeyLastVerified( + hostId: number, + now = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(hosts) + .set({ hostKeyLastVerified: now }) + .where(eq(hosts.id, hostId)); + await this.afterWrite(); + } + + async findCredentialByIdForUser( + credentialId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ) + .limit(1); + + return this.decryptOne("ssh_credentials", rows[0], userId); + } + + async findCredentialByIdForOwnerDecryptedAs( + credentialId: number, + ownerUserId: string, + decryptUserId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, ownerUserId), + ), + ) + .limit(1); + + return this.decryptOne("ssh_credentials", rows[0], decryptUserId); + } + + async findOverrideCredentialId( + hostId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select({ overrideCredentialId: hostAccess.overrideCredentialId }) + .from(hostAccess) + .where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId))) + .limit(1); + + return rows[0]?.overrideCredentialId ?? null; + } + + private decryptOne>( + tableName: "ssh_data" | "ssh_credentials", + record: T | undefined, + userId: string, + ): T | null { + if (!record) return null; + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) return null; + return DataCrypto.decryptRecord(tableName, record, userId, userDataKey); + } + + private decryptMany>( + tableName: "ssh_data" | "ssh_credentials", + records: T[], + userId: string, + ): T[] { + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) return []; + return records.map((record) => + DataCrypto.decryptRecord(tableName, record, userId, userDataKey), + ); + } + + private decryptManyByOwner< + T extends Record & { userId: string }, + >(tableName: "ssh_data" | "ssh_credentials", records: T[]): T[] { + return records.flatMap((record) => { + const userDataKey = DataCrypto.getUserDataKey(record.userId); + if (!userDataKey) return []; + return [ + DataCrypto.decryptRecord(tableName, record, record.userId, userDataKey), + ]; + }); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/network-topology-repository.test.ts b/src/backend/database/repositories/network-topology-repository.test.ts new file mode 100644 index 00000000..b18accd2 --- /dev/null +++ b/src/backend/database/repositories/network-topology-repository.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { NetworkTopologyRepository } from "./network-topology-repository.js"; + +describe("NetworkTopologyRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE network_topology ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + topology TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new NetworkTopologyRepository(context, onWrite); + } + + it("finds, creates, and updates topology by user id", async () => { + const repo = await createRepository(); + + expect(await repo.findByUserId("user-1")).toBeNull(); + + await repo.upsertForUser( + "user-1", + JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }), + "2026-06-27T00:00:00.000Z", + ); + expect(await repo.findByUserId("user-1")).toMatchObject({ + userId: "user-1", + topology: '{"nodes":[{"id":"host-1"}],"edges":[]}', + updatedAt: "2026-06-27T00:00:00.000Z", + }); + + await repo.upsertForUser( + "user-1", + JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }), + "2026-06-27T01:00:00.000Z", + ); + expect(await repo.findByUserId("user-1")).toMatchObject({ + userId: "user-1", + topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}', + updatedAt: "2026-06-27T01:00:00.000Z", + }); + }); + + it("deletes topology and only triggers writes for changed rows", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForUser("user-1", "{}"); + await repo.upsertForUser("user-1", '{"nodes":[]}'); + expect(writeCount).toBe(2); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(2); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(3); + expect(await repo.findByUserId("user-1")).toBeNull(); + }); +}); diff --git a/src/backend/database/repositories/network-topology-repository.ts b/src/backend/database/repositories/network-topology-repository.ts new file mode 100644 index 00000000..5a56b7c9 --- /dev/null +++ b/src/backend/database/repositories/network-topology-repository.ts @@ -0,0 +1,63 @@ +import { eq } from "drizzle-orm"; +import { networkTopology } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type NetworkTopologyRecord = typeof networkTopology.$inferSelect; + +export class NetworkTopologyRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .select() + .from(networkTopology) + .where(eq(networkTopology.userId, userId)) + .limit(1); + + return rows[0] ?? null; + } + + async upsertForUser( + userId: string, + topology: string, + updatedAt = new Date().toISOString(), + ): Promise { + const existing = await this.findByUserId(userId); + + if (existing) { + await this.context.drizzle + .update(networkTopology) + .set({ topology, updatedAt }) + .where(eq(networkTopology.userId, userId)); + await this.afterWrite(); + return; + } + + await this.context.drizzle.insert(networkTopology).values({ + userId, + topology, + updatedAt, + }); + await this.afterWrite(); + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(networkTopology) + .where(eq(networkTopology.userId, userId)) + .returning({ id: networkTopology.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/open-tab-repository.test.ts b/src/backend/database/repositories/open-tab-repository.test.ts new file mode 100644 index 00000000..60020bdb --- /dev/null +++ b/src/backend/database/repositories/open-tab-repository.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { OpenTabRepository } from "./open-tab-repository.js"; + +describe("OpenTabRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE user_open_tabs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + tab_type TEXT NOT NULL, + host_id INTEGER, + label TEXT NOT NULL, + tab_order INTEGER NOT NULL DEFAULT 0, + backend_session_id TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return new OpenTabRepository(context, onWrite); + } + + it("lists recent tabs ordered by tab order", async () => { + const repo = await createRepository(); + + await repo.upsertForUser( + "user-1", + { + id: "tab-old", + tabType: "terminal", + label: "Old", + tabOrder: 1, + }, + "2026-06-27T00:00:00.000Z", + ); + await repo.upsertForUser( + "user-1", + { + id: "tab-new", + tabType: "stats", + label: "New", + tabOrder: 0, + }, + "2026-06-27T01:00:00.000Z", + ); + await repo.upsertForUser( + "user-2", + { + id: "other", + tabType: "terminal", + label: "Other", + tabOrder: 0, + }, + "2026-06-27T02:00:00.000Z", + ); + + expect( + (await repo.listRecentForUser("user-1", "2026-06-27T00:30:00.000Z")).map( + (tab) => tab.id, + ), + ).toEqual(["tab-new"]); + }); + + it("upserts tabs and preserves backend session when omitted", async () => { + const repo = await createRepository(); + + await repo.upsertForUser("user-1", { + id: "tab-1", + tabType: "terminal", + hostId: 1, + label: "Server", + tabOrder: 0, + backendSessionId: "session-1", + }); + await repo.upsertForUser("user-1", { + id: "tab-1", + tabType: "terminal", + hostId: 2, + label: "Server renamed", + tabOrder: 1, + }); + + expect( + (await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z"))[0], + ).toMatchObject({ + id: "tab-1", + hostId: 2, + label: "Server renamed", + tabOrder: 1, + backendSessionId: "session-1", + }); + }); + + it("replaces, updates, and deletes tabs for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.replaceForUser("user-1", [ + { id: "tab-1", tabType: "terminal", label: "One", tabOrder: 1 }, + { id: "tab-2", tabType: "settings", label: "Two", tabOrder: 0 }, + ]); + expect( + (await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z")).map( + (tab) => tab.id, + ), + ).toEqual(["tab-2", "tab-1"]); + + expect( + await repo.updateForUser("user-1", "missing", { label: "Missing" }), + ).toBe(false); + expect( + await repo.updateForUser("user-1", "tab-1", { label: "Renamed" }), + ).toBe(true); + expect(await repo.deleteForUser("user-1", "tab-2")).toBe(1); + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(writeCount).toBe(4); + }); +}); diff --git a/src/backend/database/repositories/open-tab-repository.ts b/src/backend/database/repositories/open-tab-repository.ts new file mode 100644 index 00000000..219d7bcf --- /dev/null +++ b/src/backend/database/repositories/open-tab-repository.ts @@ -0,0 +1,169 @@ +import { and, eq, gt } from "drizzle-orm"; +import { userOpenTabs } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type OpenTabRecord = typeof userOpenTabs.$inferSelect; +export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert; +export type OpenTabUpdate = Partial< + Pick +>; + +export type OpenTabUpsertInput = Pick< + NewOpenTabRecord, + "id" | "tabType" | "label" | "tabOrder" +> & { + hostId?: number | null; + backendSessionId?: string | null; +}; + +export class OpenTabRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listRecentForUser( + userId: string, + updatedAfter: string, + ): Promise { + return this.context.drizzle + .select() + .from(userOpenTabs) + .where( + and( + eq(userOpenTabs.userId, userId), + gt(userOpenTabs.updatedAt, updatedAfter), + ), + ) + .orderBy(userOpenTabs.tabOrder); + } + + async upsertForUser( + userId: string, + input: OpenTabUpsertInput, + updatedAt = new Date().toISOString(), + ): Promise { + const existing = await this.findByIdForUser(userId, input.id); + if (existing) { + await this.context.drizzle + .update(userOpenTabs) + .set({ + tabType: input.tabType, + hostId: input.hostId ?? null, + label: input.label, + tabOrder: input.tabOrder, + backendSessionId: + input.backendSessionId !== undefined + ? input.backendSessionId + : existing.backendSessionId, + updatedAt, + }) + .where( + and(eq(userOpenTabs.id, input.id), eq(userOpenTabs.userId, userId)), + ); + await this.afterWrite(); + return; + } + + await this.context.drizzle.insert(userOpenTabs).values({ + id: input.id, + userId, + tabType: input.tabType, + hostId: input.hostId ?? null, + label: input.label, + tabOrder: input.tabOrder, + backendSessionId: input.backendSessionId ?? null, + updatedAt, + }); + await this.afterWrite(); + } + + async replaceForUser( + userId: string, + tabs: OpenTabUpsertInput[], + updatedAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .delete(userOpenTabs) + .where(eq(userOpenTabs.userId, userId)); + + if (tabs.length > 0) { + await this.context.drizzle.insert(userOpenTabs).values( + tabs.map((tab) => ({ + id: tab.id, + userId, + tabType: tab.tabType, + hostId: tab.hostId ?? null, + label: tab.label, + tabOrder: tab.tabOrder, + backendSessionId: tab.backendSessionId ?? null, + updatedAt, + })), + ); + } + + await this.afterWrite(); + } + + async updateForUser( + userId: string, + id: string, + update: OpenTabUpdate, + updatedAt = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .update(userOpenTabs) + .set({ ...update, updatedAt }) + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) + .returning({ id: userOpenTabs.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteForUser(userId: string, id: string): Promise { + const rows = await this.context.drizzle + .delete(userOpenTabs) + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) + .returning({ id: userOpenTabs.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(userOpenTabs) + .where(eq(userOpenTabs.userId, userId)) + .returning({ id: userOpenTabs.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async findByIdForUser( + userId: string, + id: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(userOpenTabs) + .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/opkssh-token-repository.test.ts b/src/backend/database/repositories/opkssh-token-repository.test.ts new file mode 100644 index 00000000..03d538e3 --- /dev/null +++ b/src/backend/database/repositories/opkssh-token-repository.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { OpksshTokenRepository } from "./opkssh-token-repository.js"; + +describe("OpksshTokenRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE opkssh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + ssh_cert TEXT NOT NULL, + private_key TEXT NOT NULL, + email TEXT, + sub TEXT, + issuer TEXT, + audience TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used TEXT, + UNIQUE(user_id, host_id) + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO opkssh_tokens ( + user_id, host_id, ssh_cert, private_key, email, expires_at + ) + VALUES + ('user-1', 1, 'cert-1', 'key-1', 'alice@example.com', '2099-01-01T00:00:00.000Z'), + ('user-1', 2, 'cert-2', 'key-2', 'alice2@example.com', '2099-01-01T00:00:00.000Z'), + ('user-2', 3, 'cert-3', 'key-3', 'bob@example.com', '2099-01-01T00:00:00.000Z'); + `); + + return new OpksshTokenRepository(context, onWrite); + } + + it("finds and upserts a token by user and host", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const existing = await repo.findByUserAndHost("user-1", 1); + expect(existing?.sshCert).toBe("cert-1"); + + await repo.upsert({ + userId: "user-1", + hostId: 1, + sshCert: "new-cert", + privateKey: "new-key", + email: "new@example.com", + sub: "sub", + issuer: "issuer", + audience: "aud", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + const updated = await repo.findByUserAndHost("user-1", 1); + expect(updated).toMatchObject({ + sshCert: "new-cert", + privateKey: "new-key", + email: "new@example.com", + sub: "sub", + issuer: "issuer", + audience: "aud", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(writeCount).toBe(1); + }); + + it("updates last-used and deletes one token", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"), + ).toBe(true); + expect(await repo.updateLastUsed("missing", 1)).toBe(false); + expect((await repo.findByUserAndHost("user-1", 1))?.lastUsed).toBe( + "2026-01-02T00:00:00.000Z", + ); + + expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(true); + expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(false); + expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes tokens by user id", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteByUserId("user-1")).toBe(2); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(await repo.findByUserAndHost("user-1", 1)).toBeNull(); + expect(await repo.findByUserAndHost("user-2", 3)).not.toBeNull(); + expect(writeCount).toBe(1); + }); +}); diff --git a/src/backend/database/repositories/opkssh-token-repository.ts b/src/backend/database/repositories/opkssh-token-repository.ts new file mode 100644 index 00000000..cf7186cc --- /dev/null +++ b/src/backend/database/repositories/opkssh-token-repository.ts @@ -0,0 +1,125 @@ +import { and, eq } from "drizzle-orm"; +import { opksshTokens } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type OpksshTokenRecord = typeof opksshTokens.$inferSelect; + +export interface OpksshTokenUpsertInput { + userId: string; + hostId: number; + sshCert: string; + privateKey: string; + email?: string | null; + sub?: string | null; + issuer?: string | null; + audience?: string | null; + expiresAt: string; + createdAt?: string; +} + +export class OpksshTokenRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async upsert(input: OpksshTokenUpsertInput): Promise { + const createdAt = input.createdAt ?? new Date().toISOString(); + + await this.context.drizzle + .insert(opksshTokens) + .values({ + userId: input.userId, + hostId: input.hostId, + sshCert: input.sshCert, + privateKey: input.privateKey, + email: input.email, + sub: input.sub, + issuer: input.issuer, + audience: input.audience, + expiresAt: input.expiresAt, + }) + .onConflictDoUpdate({ + target: [opksshTokens.userId, opksshTokens.hostId], + set: { + sshCert: input.sshCert, + privateKey: input.privateKey, + email: input.email, + sub: input.sub, + issuer: input.issuer, + audience: input.audience, + expiresAt: input.expiresAt, + createdAt, + }, + }); + + await this.afterWrite(); + } + + async findByUserAndHost( + userId: string, + hostId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(opksshTokens) + .where( + and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), + ) + .limit(1); + + return rows[0] ?? null; + } + + async updateLastUsed( + userId: string, + hostId: number, + lastUsed = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .update(opksshTokens) + .set({ lastUsed }) + .where( + and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), + ) + .returning({ id: opksshTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserAndHost(userId: string, hostId: number): Promise { + const rows = await this.context.drizzle + .delete(opksshTokens) + .where( + and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), + ) + .returning({ id: opksshTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(opksshTokens) + .where(eq(opksshTokens.userId, userId)) + .returning({ id: opksshTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/rbac-access-repository.test.ts b/src/backend/database/repositories/rbac-access-repository.test.ts new file mode 100644 index 00000000..6017b416 --- /dev/null +++ b/src/backend/database/repositories/rbac-access-repository.test.ts @@ -0,0 +1,509 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { RbacAccessRepository } from "./rbac-access-repository.js"; + +describe("RbacAccessRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + const activeAccessTime = "2026-06-26T12:00:00.000Z"; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + permissions TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + override_credential_id INTEGER + ); + + CREATE TABLE shared_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_access_id INTEGER NOT NULL, + original_credential_id INTEGER NOT NULL, + target_user_id TEXT NOT NULL, + encrypted_username TEXT NOT NULL, + encrypted_auth_type TEXT NOT NULL, + encrypted_password TEXT, + encrypted_key TEXT, + encrypted_key_password TEXT, + encrypted_key_type TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + needs_re_encryption INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + credential_id INTEGER, + rdp_credential_id INTEGER, + vnc_credential_id INTEGER, + telnet_credential_id INTEGER, + folder TEXT, + tags TEXT + ); + + CREATE TABLE snippets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + folder TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + host_filter TEXT + ); + + CREATE TABLE snippet_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snippet_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash, is_admin, is_oidc) + VALUES + ('admin', 'admin', 'hash', 1, 0), + ('user-1', 'alice', 'hash', 0, 0), + ('owner-1', 'owner', 'hash', 0, 0); + + INSERT INTO roles (id, name, display_name, is_system) + VALUES (7, 'ops', 'Operations', 0); + + INSERT INTO ssh_data ( + id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags + ) + VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux'); + + INSERT INTO host_access ( + id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at + ) + VALUES + (1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), + (2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'), + (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z'); + + INSERT INTO shared_credentials ( + id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type, needs_re_encryption + ) + VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth', 0); + + INSERT INTO snippets (id, user_id, name, content) + VALUES (99, 'owner-1', 'deploy', 'echo deploy'); + + INSERT INTO snippet_access ( + id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at + ) + VALUES + (3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'), + (4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'); + `); + + return new RbacAccessRepository(context, onWrite); + } + + it("lists host access with user and role target metadata", async () => { + const repo = await createRepository(); + + const accessList = await repo.listHostAccess(42); + + expect(accessList).toMatchObject([ + { + id: 2, + targetType: "role", + userId: null, + roleId: 7, + username: null, + roleName: "ops", + roleDisplayName: "Operations", + grantedByUsername: "admin", + }, + { + id: 1, + targetType: "user", + userId: "user-1", + roleId: null, + username: "alice", + roleName: null, + roleDisplayName: null, + grantedByUsername: "admin", + }, + ]); + }); + + it("lists snippet access with user and role target metadata", async () => { + const repo = await createRepository(); + + const accessList = await repo.listSnippetAccess(99); + + expect(accessList.map((access) => access.targetType)).toEqual([ + "role", + "user", + ]); + expect(accessList[0]).toMatchObject({ + id: 4, + roleId: 7, + roleName: "ops", + grantedByUsername: "admin", + }); + expect(accessList[1]).toMatchObject({ + id: 3, + userId: "user-1", + username: "alice", + grantedByUsername: "admin", + }); + }); + + it("lists shared hosts for direct and role access", async () => { + const repo = await createRepository(); + + const sharedHosts = await repo.listSharedHosts( + "user-1", + [7], + activeAccessTime, + ); + + expect(sharedHosts).toMatchObject([ + { + id: 42, + name: "prod", + ip: "10.0.0.42", + ownerUsername: "owner", + permissionLevel: "view", + }, + { + id: 42, + name: "prod", + ip: "10.0.0.42", + ownerUsername: "owner", + permissionLevel: "view", + }, + ]); + }); + + it("lists visible host access entries for host list access checks", async () => { + const repo = await createRepository(); + + await expect( + repo.listVisibleHostAccessEntries("user-1", [7], activeAccessTime), + ).resolves.toEqual([ + { + hostId: 42, + permissionLevel: "view", + expiresAt: "2026-06-27T00:00:00.000Z", + }, + { + hostId: 42, + permissionLevel: "view", + expiresAt: null, + }, + ]); + }); + + it("lists role host access credential sources for role assignment", async () => { + const repo = await createRepository(); + + await expect(repo.listRoleHostAccessCredentialSources(7)).resolves.toEqual([ + { + hostAccessId: 2, + credentialId: 123, + rdpCredentialId: 124, + vncCredentialId: 125, + telnetCredentialId: 126, + hostId: 42, + hostOwnerId: "owner-1", + }, + ]); + }); + + it("finds shared credential and host access owner for shared credential reads", async () => { + const repo = await createRepository(); + + await expect( + repo.findSharedCredentialForHostAndUser(42, "user-1"), + ).resolves.toMatchObject({ + id: 8, + hostAccessId: 2, + originalCredentialId: 123, + targetUserId: "user-1", + encryptedUsername: "enc-user", + encryptedAuthType: "enc-auth", + }); + + await expect( + repo.findSharedCredentialForHostAndUser(99, "user-1"), + ).resolves.toBeNull(); + await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1"); + await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull(); + }); + + it("lists shared snippets and preserves route-level direct-over-role behavior", async () => { + const repo = await createRepository(); + + const sharedSnippets = await repo.listSharedSnippets( + "user-1", + [7], + activeAccessTime, + ); + + expect(sharedSnippets).toHaveLength(1); + expect(sharedSnippets[0]).toMatchObject({ + id: 99, + name: "deploy", + ownerUsername: "owner", + permissionLevel: "view", + }); + }); + + it("lists visible shared snippets for the main snippets route", async () => { + const repo = await createRepository(); + + const sharedSnippets = await repo.listVisibleSharedSnippets( + "user-1", + [7], + activeAccessTime, + ); + + expect(sharedSnippets.map((snippet) => snippet.id)).toEqual([99, 99]); + expect(sharedSnippets[0]).toMatchObject({ + userId: "owner-1", + name: "deploy", + content: "echo deploy", + ownerUsername: "owner", + }); + }); + + it("finds an accessible shared snippet for direct or role access", async () => { + const repo = await createRepository(); + + await expect( + repo.findAccessibleSharedSnippet(99, "user-2", [7], activeAccessTime), + ).resolves.toMatchObject({ + id: 99, + userId: "owner-1", + name: "deploy", + content: "echo deploy", + ownerUsername: "owner", + permissionLevel: "view", + hostFilter: null, + }); + + await expect( + repo.findAccessibleSharedSnippet( + 99, + "user-2", + [7], + "2026-06-28T00:00:00.000Z", + ), + ).resolves.toBeNull(); + }); + + it("upserts host access and updates overrides", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.upsertHostAccess({ + hostId: 42, + targetType: "user", + targetUserId: "user-1", + grantedBy: "admin", + permissionLevel: "view", + expiresAt: "2026-06-28T00:00:00.000Z", + }); + + expect(updated).toEqual({ id: 1, created: false }); + expect( + (await repo.listHostAccess(42)).find((row) => row.id === 1), + ).toMatchObject({ + expiresAt: "2026-06-28T00:00:00.000Z", + }); + + const created = await repo.upsertHostAccess({ + hostId: 43, + targetType: "role", + targetRoleId: 7, + grantedBy: "admin", + permissionLevel: "view", + expiresAt: null, + }); + expect(created.created).toBe(true); + + const directAccess = await repo.findDirectHostAccess(42, "user-1"); + expect(directAccess?.id).toBe(1); + + await repo.updateHostAccessOverrideCredential(1, 123); + expect( + (await repo.findDirectHostAccess(42, "user-1"))?.overrideCredentialId, + ).toBe(123); + + await repo.touchHostAccess(1, "2026-06-26T03:00:00.000Z"); + expect( + (await repo.findDirectHostAccess(42, "user-1"))?.lastAccessedAt, + ).toBe("2026-06-26T03:00:00.000Z"); + + await repo.revokeHostAccess(1, 42); + expect(await repo.findDirectHostAccess(42, "user-1")).toBeNull(); + expect(writeCount).toBe(5); + }); + + it("finds active host access and deletes expired host access", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.findActiveHostAccess( + 42, + "user-1", + [7], + "2026-06-26T12:00:00.000Z", + ), + ).toMatchObject({ id: 1 }); + expect( + await repo.findActiveHostAccess( + 44, + "user-1", + [], + "2026-06-26T12:00:00.000Z", + ), + ).toBeNull(); + + expect(await repo.deleteExpiredHostAccess("2026-06-26T12:00:00.000Z")).toBe( + 1, + ); + expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull(); + expect(writeCount).toBe(1); + }); + + it("deletes host access for a host and only saves when rows changed", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteHostAccessForHost(42)).toBe(2); + expect(await repo.listHostAccess(42)).toEqual([]); + expect(writeCount).toBe(1); + + expect(await repo.deleteHostAccessForHost(42)).toBe(0); + expect(writeCount).toBe(1); + }); + + it("deletes host access for multiple hosts", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteHostAccessForHosts([])).toBe(0); + expect(writeCount).toBe(0); + + expect(await repo.deleteHostAccessForHosts([42, 44])).toBe(3); + expect(await repo.listHostAccess(42)).toEqual([]); + expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull(); + expect(writeCount).toBe(1); + }); + + it("deletes host access that references a user directly or as grantor", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(3); + expect(await repo.listHostAccess(42)).toEqual([]); + expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull(); + expect(writeCount).toBe(1); + + expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0); + expect(writeCount).toBe(1); + }); + + it("upserts and revokes snippet access", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.upsertSnippetAccess({ + snippetId: 99, + targetType: "user", + targetUserId: "user-1", + grantedBy: "admin", + expiresAt: "2026-06-28T00:00:00.000Z", + }); + + expect(updated).toEqual({ id: 3, created: false }); + expect( + (await repo.listSnippetAccess(99)).find((row) => row.id === 3), + ).toMatchObject({ expiresAt: "2026-06-28T00:00:00.000Z" }); + + const created = await repo.upsertSnippetAccess({ + snippetId: 100, + targetType: "role", + targetRoleId: 7, + grantedBy: "admin", + expiresAt: null, + }); + expect(created.created).toBe(true); + + await repo.revokeSnippetAccess(3, 99); + expect((await repo.listSnippetAccess(99)).map((row) => row.id)).toEqual([ + 4, + ]); + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/database/repositories/rbac-access-repository.ts b/src/backend/database/repositories/rbac-access-repository.ts new file mode 100644 index 00000000..cff70ba2 --- /dev/null +++ b/src/backend/database/repositories/rbac-access-repository.ts @@ -0,0 +1,677 @@ +import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm"; +import { + hostAccess, + hosts, + roles, + sharedCredentials, + snippetAccess, + snippets, + users, +} from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type RbacAccessTargetType = "user" | "role"; + +export interface RbacAccessListItem { + id: number; + targetType: RbacAccessTargetType; + userId: string | null; + roleId: number | null; + username: string | null; + roleName: string | null; + roleDisplayName: string | null; + grantedBy: string; + grantedByUsername: string | null; + permissionLevel: string; + expiresAt: string | null; + createdAt: string; +} + +export interface RbacSharedHost { + id: number; + name: string | null; + ip: string; + port: number; + username: string; + folder: string | null; + tags: string | null; + permissionLevel: string; + expiresAt: string | null; + grantedBy: string; + ownerUsername: string; +} + +export interface RbacSharedSnippet { + id: number; + name: string; + content: string; + description: string | null; + folder: string | null; + ownerUsername: string; + permissionLevel: string; + expiresAt: string | null; +} + +export interface RbacVisibleSharedSnippet extends RbacSharedSnippet { + userId: string; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet { + hostFilter: string | null; +} + +export interface RbacRoleHostAccessCredentialSource { + hostAccessId: number; + credentialId: number | null; + rdpCredentialId: number | null; + vncCredentialId: number | null; + telnetCredentialId: number | null; + hostId: number; + hostOwnerId: string; +} + +export interface RbacVisibleHostAccessEntry { + hostId: number; + permissionLevel: string; + expiresAt: string | null; +} + +export type RbacAccessTarget = + | { targetType: "user"; targetUserId: string } + | { targetType: "role"; targetRoleId: number }; + +export type UpsertHostAccessInput = RbacAccessTarget & { + hostId: number; + grantedBy: string; + permissionLevel: string; + expiresAt: string | null; +}; + +export type UpsertSnippetAccessInput = RbacAccessTarget & { + snippetId: number; + grantedBy: string; + expiresAt: string | null; +}; + +type RawAccessListItem = Omit; + +function toAccessListItem(access: RawAccessListItem): RbacAccessListItem { + return { + ...access, + targetType: access.userId ? "user" : "role", + }; +} + +export class RbacAccessRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listHostAccess(hostId: number): Promise { + const rows = await this.context.drizzle + .select({ + id: hostAccess.id, + userId: hostAccess.userId, + roleId: hostAccess.roleId, + username: users.username, + roleName: roles.name, + roleDisplayName: roles.displayName, + grantedBy: hostAccess.grantedBy, + grantedByUsername: sql< + string | null + >`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + createdAt: hostAccess.createdAt, + }) + .from(hostAccess) + .leftJoin(users, eq(hostAccess.userId, users.id)) + .leftJoin(roles, eq(hostAccess.roleId, roles.id)) + .where(eq(hostAccess.hostId, hostId)) + .orderBy(desc(hostAccess.createdAt)); + + return rows.map(toAccessListItem); + } + + async upsertHostAccess(input: UpsertHostAccessInput): Promise<{ + id: number; + created: boolean; + }> { + const existing = await this.findHostAccess(input.hostId, input); + + if (existing) { + await this.context.drizzle + .update(hostAccess) + .set({ + permissionLevel: input.permissionLevel, + expiresAt: input.expiresAt, + }) + .where(eq(hostAccess.id, existing.id)); + + await this.context.drizzle + .delete(sharedCredentials) + .where(eq(sharedCredentials.hostAccessId, existing.id)); + + await this.afterWrite(); + return { id: existing.id, created: false }; + } + + const result = await this.context.drizzle.insert(hostAccess).values({ + hostId: input.hostId, + userId: input.targetType === "user" ? input.targetUserId : null, + roleId: input.targetType === "role" ? input.targetRoleId : null, + grantedBy: input.grantedBy, + permissionLevel: input.permissionLevel, + expiresAt: input.expiresAt, + }); + + await this.afterWrite(); + return { id: Number(result.lastInsertRowid), created: true }; + } + + async revokeHostAccess(accessId: number, hostId: number): Promise { + await this.context.drizzle + .delete(hostAccess) + .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))); + await this.afterWrite(); + } + + async deleteHostAccessForHost(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.hostId, hostId)) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteHostAccessForHosts(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .delete(hostAccess) + .where(inArray(hostAccess.hostId, hostIds)) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteHostAccessForUserReferences(userId: string): Promise { + const directRows = await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.userId, userId)) + .returning({ id: hostAccess.id }); + + const grantedRows = await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.grantedBy, userId)) + .returning({ id: hostAccess.id }); + + const deletedCount = directRows.length + grantedRows.length; + if (deletedCount > 0) { + await this.afterWrite(); + } + + return deletedCount; + } + + async findDirectHostAccess( + hostId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + async updateHostAccessOverrideCredential( + accessId: number, + credentialId: number | null, + ): Promise { + await this.context.drizzle + .update(hostAccess) + .set({ overrideCredentialId: credentialId }) + .where(eq(hostAccess.id, accessId)); + await this.afterWrite(); + } + + async listSnippetAccess(snippetId: number): Promise { + const rows = await this.context.drizzle + .select({ + id: snippetAccess.id, + userId: snippetAccess.userId, + roleId: snippetAccess.roleId, + username: users.username, + roleName: roles.name, + roleDisplayName: roles.displayName, + grantedBy: snippetAccess.grantedBy, + grantedByUsername: sql< + string | null + >`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + createdAt: snippetAccess.createdAt, + }) + .from(snippetAccess) + .leftJoin(users, eq(snippetAccess.userId, users.id)) + .leftJoin(roles, eq(snippetAccess.roleId, roles.id)) + .where(eq(snippetAccess.snippetId, snippetId)) + .orderBy(desc(snippetAccess.createdAt)); + + return rows.map(toAccessListItem); + } + + async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{ + id: number; + created: boolean; + }> { + const existing = await this.findSnippetAccess(input.snippetId, input); + + if (existing) { + await this.context.drizzle + .update(snippetAccess) + .set({ expiresAt: input.expiresAt }) + .where(eq(snippetAccess.id, existing.id)); + + await this.afterWrite(); + return { id: existing.id, created: false }; + } + + const result = await this.context.drizzle.insert(snippetAccess).values({ + snippetId: input.snippetId, + userId: input.targetType === "user" ? input.targetUserId : null, + roleId: input.targetType === "role" ? input.targetRoleId : null, + grantedBy: input.grantedBy, + permissionLevel: "view", + expiresAt: input.expiresAt, + }); + + await this.afterWrite(); + return { id: Number(result.lastInsertRowid), created: true }; + } + + async revokeSnippetAccess( + accessId: number, + snippetId: number, + ): Promise { + await this.context.drizzle + .delete(snippetAccess) + .where( + and( + eq(snippetAccess.id, accessId), + eq(snippetAccess.snippetId, snippetId), + ), + ); + await this.afterWrite(); + } + + async listSharedHosts( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + return this.context.drizzle + .select({ + id: hosts.id, + name: hosts.name, + ip: hosts.ip, + port: hosts.port, + username: hosts.username, + folder: hosts.folder, + tags: hosts.tags, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + grantedBy: hostAccess.grantedBy, + ownerUsername: users.username, + }) + .from(hostAccess) + .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) + .innerJoin(users, eq(hosts.userId, users.id)) + .where( + and( + this.userOrRoleHostAccessFilter(userId, roleIds), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ) + .orderBy(desc(hostAccess.createdAt)); + } + + async listVisibleHostAccessEntries( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + return this.context.drizzle + .select({ + hostId: hostAccess.hostId, + permissionLevel: hostAccess.permissionLevel, + expiresAt: hostAccess.expiresAt, + }) + .from(hostAccess) + .where( + and( + this.userOrRoleHostAccessFilter(userId, roleIds), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ) + .orderBy(desc(hostAccess.createdAt)); + } + + async listSharedSnippets( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const directShared = await this.context.drizzle + .select({ + id: snippets.id, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + eq(snippetAccess.userId, userId), + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + ), + ); + + if (roleIds.length === 0) { + return directShared; + } + + const directIds = new Set(directShared.map((snippet) => snippet.id)); + const roleShared = await this.context.drizzle + .select({ + id: snippets.id, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + inArray(snippetAccess.roleId, roleIds), + ), + ); + + return [ + ...directShared, + ...roleShared.filter((snippet) => !directIds.has(snippet.id)), + ]; + } + + async listVisibleSharedSnippets( + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + return this.context.drizzle + .select({ + id: snippets.id, + userId: snippets.userId, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + order: snippets.order, + createdAt: snippets.createdAt, + updatedAt: snippets.updatedAt, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + this.userOrRoleSnippetAccessFilter(userId, roleIds), + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + ), + ); + } + + async findAccessibleSharedSnippet( + snippetId: number, + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .select({ + id: snippets.id, + userId: snippets.userId, + name: snippets.name, + content: snippets.content, + description: snippets.description, + folder: snippets.folder, + order: snippets.order, + createdAt: snippets.createdAt, + updatedAt: snippets.updatedAt, + hostFilter: snippets.hostFilter, + ownerUsername: users.username, + permissionLevel: snippetAccess.permissionLevel, + expiresAt: snippetAccess.expiresAt, + }) + .from(snippetAccess) + .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) + .innerJoin(users, eq(snippets.userId, users.id)) + .where( + and( + eq(snippetAccess.snippetId, snippetId), + this.userOrRoleSnippetAccessFilter(userId, roleIds), + or( + isNull(snippetAccess.expiresAt), + gte(snippetAccess.expiresAt, now), + ), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async deleteExpiredHostAccess( + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .delete(hostAccess) + .where( + and( + sql`${hostAccess.expiresAt} IS NOT NULL`, + sql`${hostAccess.expiresAt} <= ${now}`, + ), + ) + .returning({ id: hostAccess.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async findActiveHostAccess( + hostId: number, + userId: string, + roleIds: number[], + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + this.userOrRoleHostAccessFilter(userId, roleIds), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async touchHostAccess( + accessId: number, + lastAccessedAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(hostAccess) + .set({ lastAccessedAt }) + .where(eq(hostAccess.id, accessId)); + await this.afterWrite(); + } + + async listRoleHostAccessCredentialSources( + roleId: number, + ): Promise { + return this.context.drizzle + .select({ + hostAccessId: hostAccess.id, + credentialId: hosts.credentialId, + rdpCredentialId: hosts.rdpCredentialId, + vncCredentialId: hosts.vncCredentialId, + telnetCredentialId: hosts.telnetCredentialId, + hostId: hosts.id, + hostOwnerId: hosts.userId, + }) + .from(hostAccess) + .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) + .where(eq(hostAccess.roleId, roleId)); + } + + async findSharedCredentialForHostAndUser( + hostId: number, + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select({ + sharedCredential: sharedCredentials, + }) + .from(sharedCredentials) + .innerJoin(hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id)) + .where( + and( + eq(hostAccess.hostId, hostId), + eq(sharedCredentials.targetUserId, userId), + ), + ) + .limit(1); + + return rows[0]?.sharedCredential ?? null; + } + + async findHostAccessOwnerId(hostAccessId: number): Promise { + const rows = await this.context.drizzle + .select({ ownerId: hosts.userId }) + .from(hostAccess) + .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) + .where(eq(hostAccess.id, hostAccessId)) + .limit(1); + + return rows[0]?.ownerId ?? null; + } + + private userOrRoleHostAccessFilter(userId: string, roleIds: number[]) { + if (roleIds.length === 0) { + return eq(hostAccess.userId, userId); + } + + return or( + eq(hostAccess.userId, userId), + inArray(hostAccess.roleId, roleIds), + ); + } + + private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) { + if (roleIds.length === 0) { + return eq(snippetAccess.userId, userId); + } + + return or( + eq(snippetAccess.userId, userId), + inArray(snippetAccess.roleId, roleIds), + ); + } + + private async findHostAccess(hostId: number, target: RbacAccessTarget) { + const rows = await this.context.drizzle + .select() + .from(hostAccess) + .where( + and( + eq(hostAccess.hostId, hostId), + target.targetType === "user" + ? eq(hostAccess.userId, target.targetUserId) + : eq(hostAccess.roleId, target.targetRoleId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) { + const rows = await this.context.drizzle + .select() + .from(snippetAccess) + .where( + and( + eq(snippetAccess.snippetId, snippetId), + target.targetType === "user" + ? eq(snippetAccess.userId, target.targetUserId) + : eq(snippetAccess.roleId, target.targetRoleId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/recent-activity-repository.test.ts b/src/backend/database/repositories/recent-activity-repository.test.ts new file mode 100644 index 00000000..b277b6db --- /dev/null +++ b/src/backend/database/repositories/recent-activity-repository.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { RecentActivityRepository } from "./recent-activity-repository.js"; + +describe("RecentActivityRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise<{ + repository: RecentActivityRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE recent_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + type TEXT NOT NULL, + host_id INTEGER NOT NULL, + host_name TEXT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp) + VALUES + (1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'), + (2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'), + (3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z'); + `); + + return { + repository: new RecentActivityRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("lists, creates, and trims recent activity", async () => { + let writeCount = 0; + const { repository, sqlite } = await createRepository(() => { + writeCount += 1; + }); + + expect( + (await repository.listByUserId("user-1", 2)).map((row) => row.id), + ).toEqual([2, 1]); + + const created = await repository.create({ + userId: "user-1", + type: "terminal", + hostId: 1, + hostName: "one", + timestamp: "2026-06-26T00:03:00.000Z", + }); + expect(created).toMatchObject({ + userId: "user-1", + type: "terminal", + hostId: 1, + }); + + expect(await repository.trimUserActivity("user-1", 2)).toBe(1); + expect( + sqlite + .prepare( + "SELECT id FROM recent_activity WHERE user_id = ? ORDER BY timestamp DESC", + ) + .all("user-1"), + ).toEqual([{ id: created.id }, { id: 2 }]); + expect(writeCount).toBe(2); + }); + + it("deletes activity by user id and only triggers writes for changed rows", async () => { + let writeCount = 0; + const { repository: repo } = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(0); + + expect(await repo.deleteByUserId("user-1")).toBe(2); + expect(writeCount).toBe(1); + expect(await repo.deleteByUserId("user-1")).toBe(0); + expect(writeCount).toBe(1); + }); + + it("deletes activity by host id and host id list", async () => { + let writeCount = 0; + const { repository: repo } = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.deleteByHostId(1)).toBe(1); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2, 3])).toBe(2); + expect(writeCount).toBe(2); + }); +}); diff --git a/src/backend/database/repositories/recent-activity-repository.ts b/src/backend/database/repositories/recent-activity-repository.ts new file mode 100644 index 00000000..6f5d1597 --- /dev/null +++ b/src/backend/database/repositories/recent-activity-repository.ts @@ -0,0 +1,112 @@ +import { desc, eq, inArray } from "drizzle-orm"; +import { recentActivity } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type RecentActivityRecord = typeof recentActivity.$inferSelect; +export type NewRecentActivityRecord = typeof recentActivity.$inferInsert; + +export class RecentActivityRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId( + userId: string, + limit: number, + ): Promise { + return this.context.drizzle + .select() + .from(recentActivity) + .where(eq(recentActivity.userId, userId)) + .orderBy(desc(recentActivity.timestamp)) + .limit(limit); + } + + async create( + activity: NewRecentActivityRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(recentActivity) + .values(activity) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async trimUserActivity(userId: string, keepCount: number): Promise { + const rows = await this.context.drizzle + .select({ id: recentActivity.id }) + .from(recentActivity) + .where(eq(recentActivity.userId, userId)) + .orderBy(desc(recentActivity.timestamp)); + + const idsToDelete = rows + .slice(keepCount) + .map((row) => row.id) + .filter((id) => typeof id === "number"); + + if (idsToDelete.length === 0) { + return 0; + } + + const deletedRows = await this.context.drizzle + .delete(recentActivity) + .where(inArray(recentActivity.id, idsToDelete)) + .returning({ id: recentActivity.id }); + + if (deletedRows.length > 0) { + await this.afterWrite(); + } + + return deletedRows.length; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(recentActivity) + .where(eq(recentActivity.userId, userId)) + .returning({ id: recentActivity.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(recentActivity) + .where(eq(recentActivity.hostId, hostId)) + .returning({ id: recentActivity.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostIds(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .delete(recentActivity) + .where(inArray(recentActivity.hostId, hostIds)) + .returning({ id: recentActivity.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/repository-rollout.test.ts b/src/backend/database/repositories/repository-rollout.test.ts new file mode 100644 index 00000000..312c4d83 --- /dev/null +++ b/src/backend/database/repositories/repository-rollout.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + getRepositoryRolloutStatus, + getRepositoryRolloutWarnings, + isRepositoryRolloutDomainEnabled, + parseRepositoryRolloutConfig, + REPOSITORY_ROLLOUT_ENV, +} from "./repository-rollout.js"; + +describe("parseRepositoryRolloutConfig", () => { + it("defaults to the current migrated repository slice", () => { + const config = parseRepositoryRolloutConfig({}); + + expect(config).toEqual({ + mode: "all", + enabledDomains: [ + "settings", + "users", + "sessions", + "api_keys", + "trusted_devices", + "credentials", + "termix_identity", + "termix_identity_ca", + "hosts", + "snippets", + "roles", + "rbac_access", + "shared_credentials", + "sso_providers", + "audit_logs", + "user_preferences", + "open_tabs", + "dismissed_alerts", + "homepage_layouts", + "homepage_items", + "network_topology", + "dashboard_service_links", + "session_recordings", + "command_history", + "recent_activity", + "ssh_credential_usage", + "transfer_recent", + "file_manager_bookmarks", + "c2s_tunnel_presets", + "tmux_session_tags", + "opkssh_tokens", + "vault_tokens", + "vault_profiles", + "host_metrics_preferences", + "host_health", + "host_metrics_history", + "alerts", + "user_data_exports", + "host_folders", + "host_resolution", + ], + explicit: false, + }); + }); + + it("allows explicitly disabling all migrated repository domains", () => { + const config = parseRepositoryRolloutConfig({ + [REPOSITORY_ROLLOUT_ENV]: "off", + }); + + expect(config).toEqual({ + mode: "none", + enabledDomains: [], + explicit: true, + }); + }); + + it("accepts a partial domain allowlist with aliases", () => { + const config = parseRepositoryRolloutConfig({ + [REPOSITORY_ROLLOUT_ENV]: + "settings,user,api-key,credential,termix-id,termix-ca,host,dismissed,alerts,layout,items,topology,dashboard-link,recordings,history,activity,usage,transfer,user-data-export,ssh-folder,host-resolver,shared-credentials,bookmarks,c2s,tmux,opkssh,vault-token,vault-profile,metrics-preferences,host-health,metrics-history", + }); + + expect(config).toEqual({ + mode: "partial", + enabledDomains: [ + "settings", + "users", + "api_keys", + "credentials", + "termix_identity", + "termix_identity_ca", + "hosts", + "dismissed_alerts", + "alerts", + "homepage_layouts", + "homepage_items", + "network_topology", + "dashboard_service_links", + "session_recordings", + "command_history", + "recent_activity", + "ssh_credential_usage", + "transfer_recent", + "user_data_exports", + "host_folders", + "host_resolution", + "shared_credentials", + "file_manager_bookmarks", + "c2s_tunnel_presets", + "tmux_session_tags", + "opkssh_tokens", + "vault_tokens", + "vault_profiles", + "host_metrics_preferences", + "host_health", + "host_metrics_history", + ], + explicit: true, + }); + }); + + it("deduplicates allowlisted domains", () => { + const config = parseRepositoryRolloutConfig({ + [REPOSITORY_ROLLOUT_ENV]: "users,user,users", + }); + + expect(config.enabledDomains).toEqual(["users"]); + }); + + it("rejects unknown domains", () => { + expect(() => + parseRepositoryRolloutConfig({ + [REPOSITORY_ROLLOUT_ENV]: "settings,unknown-domain", + }), + ).toThrow("Unsupported DATABASE_LAYER_REPOSITORY_ROLLOUT domain"); + }); + + it("checks whether an individual domain is enabled", () => { + const env = { [REPOSITORY_ROLLOUT_ENV]: "sessions" }; + + expect(isRepositoryRolloutDomainEnabled("sessions", env)).toBe(true); + expect(isRepositoryRolloutDomainEnabled("users", env)).toBe(false); + }); + + it("builds a status payload for admin visibility", () => { + const status = getRepositoryRolloutStatus({ + [REPOSITORY_ROLLOUT_ENV]: "settings,sessions", + }); + + expect(status).toEqual({ + mode: "partial", + enabledDomains: ["settings", "sessions"], + explicit: true, + envKey: REPOSITORY_ROLLOUT_ENV, + supportedDomains: [ + "settings", + "users", + "sessions", + "api_keys", + "trusted_devices", + "credentials", + "termix_identity", + "termix_identity_ca", + "hosts", + "snippets", + "roles", + "rbac_access", + "shared_credentials", + "sso_providers", + "audit_logs", + "user_preferences", + "open_tabs", + "dismissed_alerts", + "homepage_layouts", + "homepage_items", + "network_topology", + "dashboard_service_links", + "session_recordings", + "command_history", + "recent_activity", + "ssh_credential_usage", + "transfer_recent", + "file_manager_bookmarks", + "c2s_tunnel_presets", + "tmux_session_tags", + "opkssh_tokens", + "vault_tokens", + "vault_profiles", + "host_metrics_preferences", + "host_health", + "host_metrics_history", + "alerts", + "user_data_exports", + "host_folders", + "host_resolution", + ], + warnings: [ + "Partial repository rollout enabled for domains: settings, sessions.", + ], + }); + }); + + it("warns when gray rollout is implicit", () => { + const warnings = getRepositoryRolloutWarnings( + parseRepositoryRolloutConfig({}), + ); + + expect(warnings).toEqual([ + "DATABASE_LAYER_REPOSITORY_ROLLOUT is not explicitly set; gray targets should set it so rollout state is visible in deployment config.", + ]); + }); + + it("warns when migrated repository domains are disabled", () => { + const warnings = getRepositoryRolloutWarnings( + parseRepositoryRolloutConfig({ [REPOSITORY_ROLLOUT_ENV]: "off" }), + ); + + expect(warnings).toEqual([ + "All migrated repository domains are disabled; migrated auth/settings/session paths will fail closed.", + ]); + }); +}); diff --git a/src/backend/database/repositories/repository-rollout.ts b/src/backend/database/repositories/repository-rollout.ts new file mode 100644 index 00000000..d380bc66 --- /dev/null +++ b/src/backend/database/repositories/repository-rollout.ts @@ -0,0 +1,381 @@ +import { databaseLogger } from "../../utils/logger.js"; + +export const REPOSITORY_ROLLOUT_ENV = "DATABASE_LAYER_REPOSITORY_ROLLOUT"; + +export const REPOSITORY_ROLLOUT_DOMAINS = [ + "settings", + "users", + "sessions", + "api_keys", + "trusted_devices", + "credentials", + "termix_identity", + "termix_identity_ca", + "hosts", + "snippets", + "roles", + "rbac_access", + "shared_credentials", + "sso_providers", + "audit_logs", + "user_preferences", + "open_tabs", + "dismissed_alerts", + "homepage_layouts", + "homepage_items", + "network_topology", + "dashboard_service_links", + "session_recordings", + "command_history", + "recent_activity", + "ssh_credential_usage", + "transfer_recent", + "file_manager_bookmarks", + "c2s_tunnel_presets", + "tmux_session_tags", + "opkssh_tokens", + "vault_tokens", + "vault_profiles", + "host_metrics_preferences", + "host_health", + "host_metrics_history", + "alerts", + "user_data_exports", + "host_folders", + "host_resolution", +] as const; + +export type RepositoryRolloutDomain = + (typeof REPOSITORY_ROLLOUT_DOMAINS)[number]; + +export interface RepositoryRolloutConfig { + mode: "all" | "none" | "partial"; + enabledDomains: RepositoryRolloutDomain[]; + explicit: boolean; +} + +export interface RepositoryRolloutStatus extends RepositoryRolloutConfig { + envKey: typeof REPOSITORY_ROLLOUT_ENV; + supportedDomains: RepositoryRolloutDomain[]; + warnings: string[]; +} + +type EnvLike = Record; + +const DOMAIN_ALIASES: Record = { + api: "api_keys", + api_key: "api_keys", + api_keys: "api_keys", + apikey: "api_keys", + apikeys: "api_keys", + audit: "audit_logs", + audit_log: "audit_logs", + audit_logs: "audit_logs", + auditlog: "audit_logs", + auditlogs: "audit_logs", + alert: "alerts", + alerts: "alerts", + dashboard_link: "dashboard_service_links", + dashboard_links: "dashboard_service_links", + dashboard_service_link: "dashboard_service_links", + dashboard_service_links: "dashboard_service_links", + dashboardlink: "dashboard_service_links", + dashboardlinks: "dashboard_service_links", + command_history: "command_history", + commandhistory: "command_history", + credential: "credentials", + credentials: "credentials", + ca: "termix_identity_ca", + history: "command_history", + ssh_credential: "credentials", + ssh_credentials: "credentials", + sshcredential: "credentials", + sshcredentials: "credentials", + terminal_history: "command_history", + termix_ca: "termix_identity_ca", + termix_id: "termix_identity", + termix_id_ca: "termix_identity_ca", + termix_identity: "termix_identity", + termix_identity_ca: "termix_identity_ca", + termix_identity_cas: "termix_identity_ca", + termix_identity_key: "termix_identity", + termix_identity_keys: "termix_identity", + termix_identity_public_keys: "termix_identity", + termix_identities: "termix_identity", + termixidca: "termix_identity_ca", + termixidentity: "termix_identity", + dismissed_alert: "dismissed_alerts", + dismissed_alerts: "dismissed_alerts", + dismissedalert: "dismissed_alerts", + dismissedalerts: "dismissed_alerts", + dismissed: "dismissed_alerts", + homepage_layout: "homepage_layouts", + homepage_layouts: "homepage_layouts", + homepagelayout: "homepage_layouts", + homepagelayouts: "homepage_layouts", + layout: "homepage_layouts", + layouts: "homepage_layouts", + homepage_item: "homepage_items", + homepage_items: "homepage_items", + homepageitem: "homepage_items", + homepageitems: "homepage_items", + host_folder: "host_folders", + host_folders: "host_folders", + hostfolder: "host_folders", + hostfolders: "host_folders", + host: "hosts", + hosts: "hosts", + snippet: "snippets", + snippets: "snippets", + host_resolution: "host_resolution", + host_resolver: "host_resolution", + hostresolution: "host_resolution", + hostresolver: "host_resolution", + resolver: "host_resolution", + ssh_folder: "host_folders", + ssh_folders: "host_folders", + sshfolder: "host_folders", + sshfolders: "host_folders", + item: "homepage_items", + items: "homepage_items", + recording: "session_recordings", + recordings: "session_recordings", + session_recording: "session_recordings", + session_recordings: "session_recordings", + sessionrecording: "session_recordings", + sessionrecordings: "session_recordings", + network_topologies: "network_topology", + network_topology: "network_topology", + networktopologies: "network_topology", + networktopology: "network_topology", + topology: "network_topology", + activity: "recent_activity", + recent_activities: "recent_activity", + recent_activity: "recent_activity", + recentactivity: "recent_activity", + credential_usage: "ssh_credential_usage", + ssh_credential_usage: "ssh_credential_usage", + sshcredentialusage: "ssh_credential_usage", + usage: "ssh_credential_usage", + transfer: "transfer_recent", + transfer_recent: "transfer_recent", + transferrecent: "transfer_recent", + export: "user_data_exports", + exports: "user_data_exports", + user_data_export: "user_data_exports", + user_data_exports: "user_data_exports", + userdataexport: "user_data_exports", + userdataexports: "user_data_exports", + bookmark: "file_manager_bookmarks", + bookmarks: "file_manager_bookmarks", + file_bookmarks: "file_manager_bookmarks", + file_manager_bookmark: "file_manager_bookmarks", + file_manager_bookmarks: "file_manager_bookmarks", + filemanagerbookmarks: "file_manager_bookmarks", + c2s: "c2s_tunnel_presets", + c2s_preset: "c2s_tunnel_presets", + c2s_presets: "c2s_tunnel_presets", + c2s_tunnel_preset: "c2s_tunnel_presets", + c2s_tunnel_presets: "c2s_tunnel_presets", + c2stunnelpresets: "c2s_tunnel_presets", + tmux: "tmux_session_tags", + tmux_tag: "tmux_session_tags", + tmux_tags: "tmux_session_tags", + tmux_session_tag: "tmux_session_tags", + tmux_session_tags: "tmux_session_tags", + tmuxsessiontags: "tmux_session_tags", + opkssh: "opkssh_tokens", + opkssh_token: "opkssh_tokens", + opkssh_tokens: "opkssh_tokens", + opksshtoken: "opkssh_tokens", + opksshtokens: "opkssh_tokens", + vault_token: "vault_tokens", + vault_tokens: "vault_tokens", + vaulttoken: "vault_tokens", + vaulttokens: "vault_tokens", + vault_profile: "vault_profiles", + vault_profiles: "vault_profiles", + vaultprofile: "vault_profiles", + vaultprofiles: "vault_profiles", + host_metrics_preference: "host_metrics_preferences", + host_metrics_preferences: "host_metrics_preferences", + hostmetricspreference: "host_metrics_preferences", + hostmetricspreferences: "host_metrics_preferences", + metrics_preferences: "host_metrics_preferences", + health: "host_health", + host_health: "host_health", + host_health_checks: "host_health", + host_health_history: "host_health", + hosthealth: "host_health", + host_metrics_history: "host_metrics_history", + hostmetricshistory: "host_metrics_history", + metrics_history: "host_metrics_history", + open_tab: "open_tabs", + open_tabs: "open_tabs", + opentab: "open_tabs", + opentabs: "open_tabs", + setting: "settings", + settings: "settings", + session: "sessions", + sessions: "sessions", + trusted_device: "trusted_devices", + trusted_devices: "trusted_devices", + trusteddevice: "trusted_devices", + trusteddevices: "trusted_devices", + preference: "user_preferences", + preferences: "user_preferences", + user_preference: "user_preferences", + user_preferences: "user_preferences", + userpreference: "user_preferences", + userpreferences: "user_preferences", + role: "roles", + roles: "roles", + rbac: "rbac_access", + rbac_access: "rbac_access", + rbacaccess: "rbac_access", + shared_credential: "shared_credentials", + shared_credentials: "shared_credentials", + sharedcredential: "shared_credentials", + sharedcredentials: "shared_credentials", + sso: "sso_providers", + sso_provider: "sso_providers", + sso_providers: "sso_providers", + ssoprovider: "sso_providers", + ssoproviders: "sso_providers", + user: "users", + users: "users", +}; + +const DISABLED_VALUES = new Set(["0", "false", "none", "off", "disabled"]); +const ENABLED_VALUES = new Set(["1", "true", "all", "on", "enabled"]); + +function parseDomainList(value: string): RepositoryRolloutDomain[] { + const domains = value + .split(",") + .map((part) => part.trim().toLowerCase().replaceAll("-", "_")) + .filter(Boolean) + .map((part) => { + const domain = DOMAIN_ALIASES[part]; + if (!domain) { + throw new Error( + `Unsupported ${REPOSITORY_ROLLOUT_ENV} domain '${part}'. Expected one of: ${REPOSITORY_ROLLOUT_DOMAINS.join(", ")}.`, + ); + } + return domain; + }); + + return Array.from(new Set(domains)); +} + +export function parseRepositoryRolloutConfig( + env: EnvLike = process.env, +): RepositoryRolloutConfig { + const raw = env[REPOSITORY_ROLLOUT_ENV]; + const normalized = raw?.trim().toLowerCase(); + + if (!normalized) { + return { + mode: "all", + enabledDomains: [...REPOSITORY_ROLLOUT_DOMAINS], + explicit: false, + }; + } + + if (ENABLED_VALUES.has(normalized)) { + return { + mode: "all", + enabledDomains: [...REPOSITORY_ROLLOUT_DOMAINS], + explicit: true, + }; + } + + if (DISABLED_VALUES.has(normalized)) { + return { mode: "none", enabledDomains: [], explicit: true }; + } + + const enabledDomains = parseDomainList(normalized); + return { + mode: + enabledDomains.length === REPOSITORY_ROLLOUT_DOMAINS.length + ? "all" + : "partial", + enabledDomains, + explicit: true, + }; +} + +export function isRepositoryRolloutDomainEnabled( + domain: RepositoryRolloutDomain, + env: EnvLike = process.env, +): boolean { + return parseRepositoryRolloutConfig(env).enabledDomains.includes(domain); +} + +export function getRepositoryRolloutStatus( + env: EnvLike = process.env, +): RepositoryRolloutStatus { + const config = parseRepositoryRolloutConfig(env); + return { + ...config, + envKey: REPOSITORY_ROLLOUT_ENV, + supportedDomains: [...REPOSITORY_ROLLOUT_DOMAINS], + warnings: getRepositoryRolloutWarnings(config), + }; +} + +export function getRepositoryRolloutWarnings( + config: RepositoryRolloutConfig, +): string[] { + const warnings: string[] = []; + + if (!config.explicit) { + warnings.push( + `${REPOSITORY_ROLLOUT_ENV} is not explicitly set; gray targets should set it so rollout state is visible in deployment config.`, + ); + } + + if (config.mode === "none") { + warnings.push( + "All migrated repository domains are disabled; migrated auth/settings/session paths will fail closed.", + ); + } + + if (config.mode === "partial") { + warnings.push( + `Partial repository rollout enabled for domains: ${config.enabledDomains.join(", ")}.`, + ); + } + + return warnings; +} + +export function assertRepositoryRolloutDomainEnabled( + domain: RepositoryRolloutDomain, +): void { + if (isRepositoryRolloutDomainEnabled(domain)) return; + + throw new Error( + `Repository domain '${domain}' is disabled by ${REPOSITORY_ROLLOUT_ENV}.`, + ); +} + +export function logRepositoryRolloutConfig(env: EnvLike = process.env): void { + const config = getRepositoryRolloutStatus(env); + databaseLogger.info("Database repository rollout configuration loaded", { + operation: "repository_rollout_config", + mode: config.mode, + enabledDomains: config.enabledDomains, + explicit: config.explicit, + envKey: REPOSITORY_ROLLOUT_ENV, + }); + + for (const warning of config.warnings) { + databaseLogger.warn(warning, { + operation: "repository_rollout_warning", + mode: config.mode, + enabledDomains: config.enabledDomains, + explicit: config.explicit, + envKey: REPOSITORY_ROLLOUT_ENV, + }); + } +} diff --git a/src/backend/database/repositories/role-repository.test.ts b/src/backend/database/repositories/role-repository.test.ts new file mode 100644 index 00000000..f60bd45b --- /dev/null +++ b/src/backend/database/repositories/role-repository.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { RoleRepository } from "./role-repository.js"; + +describe("RoleRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + permissions TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE user_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + role_id INTEGER NOT NULL, + granted_by TEXT, + granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE host_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT, + role_id INTEGER, + granted_by TEXT NOT NULL, + permission_level TEXT NOT NULL DEFAULT 'view', + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash, is_admin, is_oidc) + VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0); + `); + + return new RoleRepository(context, onWrite); + } + + it("creates, lists, updates, and finds roles", async () => { + const repo = await createRepository(); + + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + description: "Ops access", + isSystem: false, + permissions: null, + }); + + expect((await repo.findRoleByName("ops"))?.id).toBe(roleId); + expect((await repo.findRoleById(roleId))?.displayName).toBe("Operations"); + + await repo.updateRole(roleId, { + displayName: "Ops", + description: null, + updatedAt: "2026-06-26T00:00:00.000Z", + }); + + const roles = await repo.listRoles(); + expect(roles.map((role) => role.name)).toEqual(["ops"]); + expect(roles[0].displayName).toBe("Ops"); + expect(roles[0].description).toBeNull(); + }); + + it("assigns, lists, and removes user roles", async () => { + const repo = await createRepository(); + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: JSON.stringify(["hosts.read", "hosts.*"]), + }); + + await repo.assignRoleToUser({ + userId: "user-1", + roleId, + grantedBy: "admin", + }); + + expect(await repo.findUserRole("user-1", roleId)).not.toBeNull(); + expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]); + expect(await repo.listRoleUserIds(roleId)).toEqual(["user-1"]); + expect((await repo.listUserRoles("user-1"))[0]).toMatchObject({ + roleId, + roleName: "ops", + roleDisplayName: "Operations", + isSystem: false, + }); + expect(await repo.listUserRolePermissions("user-1")).toEqual([ + { permissions: JSON.stringify(["hosts.read", "hosts.*"]) }, + ]); + expect(await repo.userHasAnyRoleName("user-1", ["admin", "ops"])).toBe( + true, + ); + expect(await repo.userHasAnyRoleName("user-1", ["admin"])).toBe(false); + expect(await repo.userHasAnyRoleName("user-1", [])).toBe(false); + + await repo.removeRoleFromUser("user-1", roleId); + expect(await repo.findUserRole("user-1", roleId)).toBeNull(); + }); + + it("assigns roles by name", async () => { + const repo = await createRepository(); + const roleId = await repo.createRole({ + name: "user", + displayName: "User", + isSystem: true, + permissions: null, + }); + + expect( + await repo.assignRoleNameToUser({ + userId: "user-1", + roleName: "missing", + grantedBy: "admin", + }), + ).toBe(false); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + + expect( + await repo.assignRoleNameToUser({ + userId: "user-1", + roleName: "user", + grantedBy: "admin", + }), + ).toBe(true); + expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]); + }); + + it("switches user roles by role name", async () => { + const repo = await createRepository(); + const userRoleId = await repo.createRole({ + name: "user", + displayName: "User", + isSystem: true, + permissions: null, + }); + const adminRoleId = await repo.createRole({ + name: "admin", + displayName: "Admin", + isSystem: true, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId: userRoleId, + grantedBy: "admin", + }); + + await expect( + repo.switchUserRoleName({ + userId: "user-1", + addRoleName: "admin", + removeRoleName: "user", + grantedBy: "admin", + }), + ).resolves.toEqual({ added: true, removed: true }); + expect(await repo.listUserRoleIds("user-1")).toEqual([adminRoleId]); + + await expect( + repo.switchUserRoleName({ + userId: "user-1", + addRoleName: "missing", + removeRoleName: "admin", + grantedBy: "admin", + }), + ).resolves.toEqual({ added: false, removed: true }); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + }); + + it("deletes role assignments and returns affected users", async () => { + const repo = await createRepository(); + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId, + grantedBy: "admin", + }); + + const result = await repo.deleteRole(roleId); + + expect(result.deletedUserIds).toEqual(["user-1"]); + expect(await repo.findRoleById(roleId)).toBeNull(); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + }); + + it("removes all roles for a user only when assignments exist", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + const opsRoleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: null, + }); + const auditRoleId = await repo.createRole({ + name: "audit", + displayName: "Audit", + isSystem: false, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId: opsRoleId, + grantedBy: "admin", + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId: auditRoleId, + grantedBy: "admin", + }); + + expect(await repo.removeAllRolesFromUser("missing-user")).toBe(0); + expect(writeCount).toBe(4); + + expect(await repo.removeAllRolesFromUser("user-1")).toBe(2); + expect(await repo.listUserRoleIds("user-1")).toEqual([]); + expect(writeCount).toBe(5); + }); + + it("runs the write hook after writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const roleId = await repo.createRole({ + name: "ops", + displayName: "Operations", + isSystem: false, + permissions: null, + }); + await repo.assignRoleToUser({ + userId: "user-1", + roleId, + grantedBy: "admin", + }); + await repo.updateRole(roleId, { displayName: "Ops" }); + await repo.removeRoleFromUser("user-1", roleId); + await repo.deleteRole(roleId); + + expect(writeCount).toBe(5); + }); +}); diff --git a/src/backend/database/repositories/role-repository.ts b/src/backend/database/repositories/role-repository.ts new file mode 100644 index 00000000..c47c68f8 --- /dev/null +++ b/src/backend/database/repositories/role-repository.ts @@ -0,0 +1,276 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { hostAccess, roles, userRoles } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type RoleRecord = typeof roles.$inferSelect; +export type NewRoleRecord = typeof roles.$inferInsert; +export type RoleUpdate = Pick< + Partial, + "displayName" | "description" | "updatedAt" +>; + +export type UserRoleWithRole = { + id: number; + roleId: number; + roleName: string; + roleDisplayName: string; + description: string | null; + isSystem: boolean; + grantedAt: string; +}; + +export type UserRolePermissionRecord = { + permissions: string | null; +}; + +export type UserRoleNameSwitchResult = { + added: boolean; + removed: boolean; +}; + +export class RoleRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listRoles(): Promise { + return this.context.drizzle + .select() + .from(roles) + .orderBy(roles.isSystem, roles.name); + } + + async findRoleById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(roles) + .where(eq(roles.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async findRoleByName(name: string): Promise { + const rows = await this.context.drizzle + .select() + .from(roles) + .where(eq(roles.name, name)) + .limit(1); + + return rows[0] ?? null; + } + + async createRole(role: NewRoleRecord): Promise { + const result = await this.context.drizzle.insert(roles).values(role); + await this.afterWrite(); + return Number(result.lastInsertRowid); + } + + async updateRole(id: number, update: RoleUpdate): Promise { + const rows = await this.context.drizzle + .update(roles) + .set(update) + .where(eq(roles.id, id)) + .returning({ id: roles.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> { + const deletedUserRoles = await this.context.drizzle + .delete(userRoles) + .where(eq(userRoles.roleId, id)) + .returning({ userId: userRoles.userId }); + + await this.context.drizzle + .delete(hostAccess) + .where(eq(hostAccess.roleId, id)); + + await this.context.drizzle.delete(roles).where(eq(roles.id, id)); + await this.afterWrite(); + + return { + deletedUserIds: deletedUserRoles.map((row) => row.userId), + }; + } + + async findUserRole( + userId: string, + roleId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(userRoles) + .where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))) + .limit(1); + + return rows[0] ?? null; + } + + async assignRoleToUser(input: { + userId: string; + roleId: number; + grantedBy: string; + }): Promise { + await this.context.drizzle.insert(userRoles).values(input); + await this.afterWrite(); + } + + async assignRoleNameToUser(input: { + userId: string; + roleName: string; + grantedBy: string; + }): Promise { + const role = await this.findRoleByName(input.roleName); + if (!role) { + return false; + } + + await this.context.drizzle.insert(userRoles).values({ + userId: input.userId, + roleId: role.id, + grantedBy: input.grantedBy, + }); + await this.afterWrite(); + return true; + } + + async switchUserRoleName(input: { + userId: string; + addRoleName: string; + removeRoleName: string; + grantedBy: string; + }): Promise { + const [addRole, removeRole] = await Promise.all([ + this.findRoleByName(input.addRoleName), + this.findRoleByName(input.removeRoleName), + ]); + + let added = false; + let removed = false; + + if (addRole) { + await this.context.drizzle + .delete(userRoles) + .where( + and( + eq(userRoles.userId, input.userId), + eq(userRoles.roleId, addRole.id), + ), + ); + await this.context.drizzle.insert(userRoles).values({ + userId: input.userId, + roleId: addRole.id, + grantedBy: input.grantedBy, + }); + added = true; + } + + if (removeRole) { + const rows = await this.context.drizzle + .delete(userRoles) + .where( + and( + eq(userRoles.userId, input.userId), + eq(userRoles.roleId, removeRole.id), + ), + ) + .returning({ id: userRoles.id }); + removed = rows.length > 0; + } + + if (added || removed) { + await this.afterWrite(); + } + + return { added, removed }; + } + + async removeRoleFromUser(userId: string, roleId: number): Promise { + await this.context.drizzle + .delete(userRoles) + .where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId))); + await this.afterWrite(); + } + + async removeAllRolesFromUser(userId: string): Promise { + const rows = await this.context.drizzle + .delete(userRoles) + .where(eq(userRoles.userId, userId)) + .returning({ id: userRoles.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async listUserRoleIds(userId: string): Promise { + const rows = await this.context.drizzle + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + + return rows.map((row) => row.roleId); + } + + async listRoleUserIds(roleId: number): Promise { + const rows = await this.context.drizzle + .select({ userId: userRoles.userId }) + .from(userRoles) + .where(eq(userRoles.roleId, roleId)); + + return rows.map((row) => row.userId); + } + + async listUserRolePermissions( + userId: string, + ): Promise { + return this.context.drizzle + .select({ permissions: roles.permissions }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + } + + async userHasAnyRoleName( + userId: string, + roleNames: string[], + ): Promise { + if (roleNames.length === 0) { + return false; + } + + const rows = await this.context.drizzle + .select({ roleName: roles.name }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(and(eq(userRoles.userId, userId), inArray(roles.name, roleNames))) + .limit(1); + + return rows.length > 0; + } + + async listUserRoles(userId: string): Promise { + return this.context.drizzle + .select({ + id: userRoles.id, + roleId: roles.id, + roleName: roles.name, + roleDisplayName: roles.displayName, + description: roles.description, + isSystem: roles.isSystem, + grantedAt: userRoles.grantedAt, + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/session-recording-repository.test.ts b/src/backend/database/repositories/session-recording-repository.test.ts new file mode 100644 index 00000000..96025ab2 --- /dev/null +++ b/src/backend/database/repositories/session-recording-repository.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { SessionRecordingRepository } from "./session-recording-repository.js"; + +describe("SessionRecordingRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + ip TEXT + ); + + CREATE TABLE session_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + access_id INTEGER, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at TEXT, + duration INTEGER, + commands TEXT, + dangerous_actions TEXT, + recording_path TEXT, + protocol TEXT NOT NULL DEFAULT 'ssh', + format TEXT NOT NULL DEFAULT 'text', + terminated_by_owner INTEGER DEFAULT 0, + termination_reason TEXT + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip) + VALUES (1, 'user-1', 'one', '10.0.0.1'), (2, 'user-1', 'two', '10.0.0.2'), (3, 'user-2', 'other', '10.0.0.3'); + `); + + return new SessionRecordingRepository(context, onWrite); + } + + it("creates and lists session recordings with host metadata", async () => { + const repo = await createRepository(); + + const first = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-06-27T00:00:00.000Z", + endedAt: "2026-06-27T00:01:00.000Z", + duration: 60, + recordingPath: "/tmp/one.log", + }); + await repo.create({ + userId: "user-1", + hostId: 2, + startedAt: "2026-06-27T00:02:00.000Z", + recordingPath: "/tmp/two.log", + }); + await repo.create({ + userId: "user-2", + hostId: 3, + startedAt: "2026-06-27T00:03:00.000Z", + }); + + expect(first).toMatchObject({ + userId: "user-1", + hostId: 1, + duration: 60, + recordingPath: "/tmp/one.log", + }); + + const rows = await repo.listByUserIdWithHost("user-1"); + expect(rows.map((row) => row.hostName)).toEqual(["two", "one"]); + expect(rows[0]).toMatchObject({ + hostIp: "10.0.0.2", + recordingPath: "/tmp/two.log", + }); + }); + + it("finds paths and prunes old recordings", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const old = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-01-01T00:00:00.000Z", + recordingPath: "/tmp/old.log", + }); + const current = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-06-27T00:00:00.000Z", + recordingPath: "/tmp/current.log", + }); + expect(writeCount).toBe(2); + + expect(await repo.findPathByIdForUser("user-2", old.id)).toBeNull(); + expect(await repo.findPathByIdForUser("user-1", old.id)).toMatchObject({ + recordingPath: "/tmp/old.log", + }); + expect(await repo.listPathsOlderThan("2026-02-01T00:00:00.000Z")).toEqual([ + { id: old.id, recordingPath: "/tmp/old.log" }, + ]); + + expect(await repo.deleteById(old.id)).toBe(true); + expect(await repo.deleteById(old.id)).toBe(false); + expect(await repo.findByIdForUser("user-1", current.id)).toMatchObject({ + id: current.id, + }); + expect(writeCount).toBe(3); + }); + + it("deletes recordings by user and host references", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const first = await repo.create({ + userId: "user-1", + hostId: 1, + startedAt: "2026-06-27T00:00:00.000Z", + }); + await repo.create({ + userId: "user-1", + hostId: 2, + startedAt: "2026-06-27T00:01:00.000Z", + }); + await repo.create({ + userId: "user-2", + hostId: 3, + startedAt: "2026-06-27T00:02:00.000Z", + }); + expect(writeCount).toBe(3); + + expect(await repo.deleteForUser("user-2", first.id)).toBe(false); + expect(await repo.deleteForUser("user-1", first.id)).toBe(true); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2])).toBe(1); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByHostId(3)).toBe(0); + expect(writeCount).toBe(6); + }); +}); diff --git a/src/backend/database/repositories/session-recording-repository.ts b/src/backend/database/repositories/session-recording-repository.ts new file mode 100644 index 00000000..295e1922 --- /dev/null +++ b/src/backend/database/repositories/session-recording-repository.ts @@ -0,0 +1,239 @@ +import { and, desc, eq, inArray, lt } from "drizzle-orm"; +import { hosts, sessionRecordings } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect; + +export interface SessionRecordingCreateInput { + hostId: number; + userId: string; + startedAt: string; + endedAt?: string | null; + duration?: number | null; + commands?: string | null; + dangerousActions?: string | null; + recordingPath?: string | null; + protocol?: string | null; + format?: string | null; + terminatedByOwner?: boolean | null; + terminationReason?: string | null; +} + +export interface SessionRecordingListRecord { + id: number; + hostId: number; + userId: string; + startedAt: string; + endedAt: string | null; + duration: number | null; + recordingPath: string | null; + hostName: string | null; + hostIp: string | null; +} + +export interface SessionRecordingPathRecord { + id: number; + recordingPath: string | null; +} + +export class SessionRecordingRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create( + input: SessionRecordingCreateInput, + ): Promise { + const [created] = await this.context.drizzle + .insert(sessionRecordings) + .values(input) + .returning(); + + await this.afterWrite(); + return created; + } + + async updateEnded( + id: number, + input: { endedAt: string; duration: number | null }, + ): Promise { + await this.context.drizzle + .update(sessionRecordings) + .set(input) + .where(eq(sessionRecordings.id, id)); + await this.afterWrite(); + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(sessionRecordings) + .where(eq(sessionRecordings.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async findPathById( + id: number, + ): Promise< + | (SessionRecordingPathRecord & { + userId: string; + format?: string | null; + }) + | null + > { + const rows = await this.context.drizzle + .select({ + id: sessionRecordings.id, + recordingPath: sessionRecordings.recordingPath, + userId: sessionRecordings.userId, + format: sessionRecordings.format, + }) + .from(sessionRecordings) + .where(eq(sessionRecordings.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async listByUserIdWithHost( + userId: string, + ): Promise { + return this.context.drizzle + .select({ + id: sessionRecordings.id, + hostId: sessionRecordings.hostId, + userId: sessionRecordings.userId, + startedAt: sessionRecordings.startedAt, + endedAt: sessionRecordings.endedAt, + duration: sessionRecordings.duration, + recordingPath: sessionRecordings.recordingPath, + hostName: hosts.name, + hostIp: hosts.ip, + }) + .from(sessionRecordings) + .leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id)) + .where(eq(sessionRecordings.userId, userId)) + .orderBy(desc(sessionRecordings.startedAt)); + } + + async findByIdForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sessionRecordings) + .where( + and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), + ) + .limit(1); + + return rows[0] ?? null; + } + + async findPathByIdForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select({ + id: sessionRecordings.id, + recordingPath: sessionRecordings.recordingPath, + }) + .from(sessionRecordings) + .where( + and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), + ) + .limit(1); + + return rows[0] ?? null; + } + + async listPathsOlderThan( + cutoff: string, + ): Promise { + return this.context.drizzle + .select({ + id: sessionRecordings.id, + recordingPath: sessionRecordings.recordingPath, + }) + .from(sessionRecordings) + .where(lt(sessionRecordings.startedAt, cutoff)); + } + + async deleteById(id: number): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where(eq(sessionRecordings.id, id)) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteForUser(userId: string, id: number): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where( + and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)), + ) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where(eq(sessionRecordings.userId, userId)) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where(eq(sessionRecordings.hostId, hostId)) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostIds(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .delete(sessionRecordings) + .where(inArray(sessionRecordings.hostId, hostIds)) + .returning({ id: sessionRecordings.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/session-repository.ts b/src/backend/database/repositories/session-repository.ts new file mode 100644 index 00000000..8197db3c --- /dev/null +++ b/src/backend/database/repositories/session-repository.ts @@ -0,0 +1,114 @@ +import { and, eq, lte, ne } from "drizzle-orm"; +import { sessions } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type SessionRecord = typeof sessions.$inferSelect; +export type NewSessionRecord = typeof sessions.$inferInsert; + +export class SessionRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(session: NewSessionRecord): Promise { + const rows = await this.context.drizzle + .insert(sessions) + .values(session) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async listAll(): Promise { + return this.context.drizzle.select().from(sessions); + } + + async findById(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(sessions) + .where(eq(sessions.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); + } + + async listExpired(now = new Date()): Promise { + return this.context.drizzle + .select() + .from(sessions) + .where(lte(sessions.expiresAt, now.toISOString())); + } + + async touch( + id: string, + lastActiveAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(sessions) + .set({ lastActiveAt }) + .where(eq(sessions.id, id)); + await this.afterWrite(); + } + + async updateToken( + id: string, + jwtToken: string, + lastActiveAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(sessions) + .set({ jwtToken, lastActiveAt }) + .where(eq(sessions.id, id)); + await this.afterWrite(); + } + + async revoke(id: string): Promise { + const rows = await this.context.drizzle + .delete(sessions) + .where(eq(sessions.id, id)) + .returning({ id: sessions.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async revokeAllForUser( + userId: string, + exceptSessionId?: string, + ): Promise { + const where = exceptSessionId + ? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId)) + : eq(sessions.userId, userId); + + const rows = await this.context.drizzle + .delete(sessions) + .where(where) + .returning({ id: sessions.id }); + + await this.afterWrite(); + return rows.length; + } + + async deleteExpired(now = new Date()): Promise { + const rows = await this.context.drizzle + .delete(sessions) + .where(lte(sessions.expiresAt, now.toISOString())) + .returning({ id: sessions.id }); + + await this.afterWrite(); + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/settings-repository.test.ts b/src/backend/database/repositories/settings-repository.test.ts new file mode 100644 index 00000000..831f093a --- /dev/null +++ b/src/backend/database/repositories/settings-repository.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { SettingsRepository } from "./settings-repository.js"; + +describe("SettingsRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + return new SettingsRepository(context); + } + + it("creates and updates settings", async () => { + const repo = await createRepository(); + + await repo.set("allow_registration", "true"); + expect(await repo.get("allow_registration")).toBe("true"); + + await repo.set("allow_registration", "false"); + expect(await repo.get("allow_registration")).toBe("false"); + }); + + it("lists and upserts settings", async () => { + const repo = await createRepository(); + + await repo.upsert("theme", "dark"); + await repo.upsert("allow_registration", "true"); + await repo.upsert("theme", "light"); + + expect(await repo.get("theme")).toBe("light"); + expect(await repo.listAll()).toEqual( + expect.arrayContaining([ + { key: "theme", value: "light" }, + { key: "allow_registration", value: "true" }, + ]), + ); + }); + + it("returns null or fallback when setting is missing", async () => { + const repo = await createRepository(); + + expect(await repo.get("missing")).toBeNull(); + expect(await repo.getBoolean("missing", true)).toBe(true); + }); + + it("reads boolean settings", async () => { + const repo = await createRepository(); + + await repo.set("enabled", "1"); + await repo.set("disabled", "false"); + + expect(await repo.getBoolean("enabled")).toBe(true); + expect(await repo.getBoolean("disabled", true)).toBe(false); + }); + + it("deletes settings", async () => { + const repo = await createRepository(); + + await repo.set("theme", "dark"); + await repo.delete("theme"); + + expect(await repo.get("theme")).toBeNull(); + }); + + it("deletes settings by SQL LIKE pattern", async () => { + const repo = await createRepository(); + + await repo.set("user_kek_salt_user-1", "salt"); + await repo.set("user_encrypted_dek_user-1", "dek"); + await repo.set("user_kek_salt_user-2", "other"); + + expect(await repo.deleteLike("user_%_user-1")).toBe(2); + expect(await repo.get("user_kek_salt_user-1")).toBeNull(); + expect(await repo.get("user_encrypted_dek_user-1")).toBeNull(); + expect(await repo.get("user_kek_salt_user-2")).toBe("other"); + }); +}); diff --git a/src/backend/database/repositories/settings-repository.ts b/src/backend/database/repositories/settings-repository.ts new file mode 100644 index 00000000..95d05b72 --- /dev/null +++ b/src/backend/database/repositories/settings-repository.ts @@ -0,0 +1,69 @@ +import { eq, like } from "drizzle-orm"; +import { settings } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export class SettingsRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async get(key: string): Promise { + const rows = await this.context.drizzle + .select({ value: settings.value }) + .from(settings) + .where(eq(settings.key, key)) + .limit(1); + + return rows[0]?.value ?? null; + } + + async listAll(): Promise> { + return this.context.drizzle + .select({ key: settings.key, value: settings.value }) + .from(settings); + } + + async getBoolean(key: string, fallback = false): Promise { + const value = await this.get(key); + if (value === null) return fallback; + return value === "true" || value === "1"; + } + + async set(key: string, value: string): Promise { + const existing = await this.get(key); + if (existing === null) { + await this.context.drizzle.insert(settings).values({ key, value }); + await this.afterWrite(); + return; + } + + await this.context.drizzle + .update(settings) + .set({ value }) + .where(eq(settings.key, key)); + await this.afterWrite(); + } + + async upsert(key: string, value: string): Promise { + await this.set(key, value); + } + + async delete(key: string): Promise { + await this.context.drizzle.delete(settings).where(eq(settings.key, key)); + await this.afterWrite(); + } + + async deleteLike(pattern: string): Promise { + const rows = await this.context.drizzle + .delete(settings) + .where(like(settings.key, pattern)) + .returning({ key: settings.key }); + await this.afterWrite(); + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/shared-credential-repository.test.ts b/src/backend/database/repositories/shared-credential-repository.test.ts new file mode 100644 index 00000000..480e7539 --- /dev/null +++ b/src/backend/database/repositories/shared-credential-repository.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { SharedCredentialRepository } from "./shared-credential-repository.js"; + +describe("SharedCredentialRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise<{ + repository: SharedCredentialRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE shared_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_access_id INTEGER NOT NULL, + original_credential_id INTEGER NOT NULL, + target_user_id TEXT NOT NULL, + encrypted_username TEXT NOT NULL, + encrypted_auth_type TEXT NOT NULL, + encrypted_password TEXT, + encrypted_key TEXT, + encrypted_key_password TEXT, + encrypted_key_type TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + needs_re_encryption INTEGER NOT NULL DEFAULT 0 + ); + + INSERT INTO shared_credentials ( + id, + host_access_id, + original_credential_id, + target_user_id, + encrypted_username, + encrypted_auth_type, + encrypted_password, + needs_re_encryption + ) + VALUES + (1, 10, 100, 'user-1', 'u1', 'password', 'p1', 0), + (2, 10, 100, 'user-2', 'u2', 'password', 'p2', 1), + (3, 11, 101, 'user-2', 'u3', 'key', NULL, 1); + `); + + return { + repository: new SharedCredentialRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("checks existence and creates shared credentials", async () => { + let writes = 0; + const { repository } = await createRepository(() => { + writes += 1; + }); + + await expect( + repository.existsForHostAccessAndTargetUser(10, "user-1"), + ).resolves.toBe(true); + await expect( + repository.existsForHostAccessAndTargetUser(10, "missing"), + ).resolves.toBe(false); + + const created = await repository.create({ + hostAccessId: 12, + originalCredentialId: 102, + targetUserId: "user-3", + encryptedUsername: "u4", + encryptedAuthType: "password", + encryptedPassword: "p4", + needsReEncryption: false, + }); + + expect(created).toMatchObject({ + hostAccessId: 12, + originalCredentialId: 102, + targetUserId: "user-3", + encryptedUsername: "u4", + }); + expect(writes).toBe(1); + }); + + it("finds, lists, and updates shared credentials", async () => { + let writes = 0; + const { repository } = await createRepository(() => { + writes += 1; + }); + + await expect(repository.findById(1)).resolves.toMatchObject({ + targetUserId: "user-1", + }); + await expect(repository.findById(999)).resolves.toBeNull(); + await expect( + repository.listByOriginalCredentialId(100), + ).resolves.toHaveLength(2); + await expect( + repository.listPendingByTargetUserId("user-2"), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 2 }), + expect.objectContaining({ id: 3 }), + ]), + ); + + await expect( + repository.updateById(2, { + encryptedPassword: "updated", + needsReEncryption: false, + }), + ).resolves.toMatchObject({ + encryptedPassword: "updated", + needsReEncryption: false, + }); + expect(writes).toBe(1); + }); + + it("marks and deletes shared credentials", async () => { + let writes = 0; + const { repository, sqlite } = await createRepository(() => { + writes += 1; + }); + + await expect( + repository.markNeedsReEncryptionByOriginalCredentialId(100), + ).resolves.toBe(2); + await expect(repository.deleteByTargetUserId("user-1")).resolves.toBe(1); + await expect(repository.deleteByOriginalCredentialId(101)).resolves.toBe(1); + + expect( + sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(), + ).toEqual([{ id: 2 }]); + expect(writes).toBe(3); + }); +}); diff --git a/src/backend/database/repositories/shared-credential-repository.ts b/src/backend/database/repositories/shared-credential-repository.ts new file mode 100644 index 00000000..38df52b3 --- /dev/null +++ b/src/backend/database/repositories/shared-credential-repository.ts @@ -0,0 +1,142 @@ +import { and, eq } from "drizzle-orm"; +import { sharedCredentials } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type SharedCredentialRecord = typeof sharedCredentials.$inferSelect; +export type NewSharedCredentialRecord = typeof sharedCredentials.$inferInsert; +export type SharedCredentialUpdate = Partial< + Omit +>; + +export class SharedCredentialRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async existsForHostAccessAndTargetUser( + hostAccessId: number, + targetUserId: string, + ): Promise { + const rows = await this.context.drizzle + .select({ id: sharedCredentials.id }) + .from(sharedCredentials) + .where( + and( + eq(sharedCredentials.hostAccessId, hostAccessId), + eq(sharedCredentials.targetUserId, targetUserId), + ), + ) + .limit(1); + + return rows.length > 0; + } + + async create( + sharedCredential: NewSharedCredentialRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(sharedCredentials) + .values(sharedCredential) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(sharedCredentials) + .where(eq(sharedCredentials.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async listByOriginalCredentialId( + credentialId: number, + ): Promise { + return this.context.drizzle + .select() + .from(sharedCredentials) + .where(eq(sharedCredentials.originalCredentialId, credentialId)); + } + + async listPendingByTargetUserId( + userId: string, + ): Promise { + return this.context.drizzle + .select() + .from(sharedCredentials) + .where( + and( + eq(sharedCredentials.targetUserId, userId), + eq(sharedCredentials.needsReEncryption, true), + ), + ); + } + + async updateById( + id: number, + update: SharedCredentialUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(sharedCredentials) + .set(update) + .where(eq(sharedCredentials.id, id)) + .returning(); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows[0] ?? null; + } + + async markNeedsReEncryptionByOriginalCredentialId( + credentialId: number, + ): Promise { + const rows = await this.context.drizzle + .update(sharedCredentials) + .set({ needsReEncryption: true }) + .where(eq(sharedCredentials.originalCredentialId, credentialId)) + .returning({ id: sharedCredentials.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByOriginalCredentialId(credentialId: number): Promise { + const rows = await this.context.drizzle + .delete(sharedCredentials) + .where(eq(sharedCredentials.originalCredentialId, credentialId)) + .returning({ id: sharedCredentials.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByTargetUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sharedCredentials) + .where(eq(sharedCredentials.targetUserId, userId)) + .returning({ id: sharedCredentials.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/snippet-repository.test.ts b/src/backend/database/repositories/snippet-repository.test.ts new file mode 100644 index 00000000..645b034b --- /dev/null +++ b/src/backend/database/repositories/snippet-repository.test.ts @@ -0,0 +1,383 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { SnippetRepository } from "./snippet-repository.js"; + +describe("SnippetRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite?: () => void): Promise<{ + repository: SnippetRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE snippets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + folder TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + host_filter TEXT + ); + + CREATE TABLE snippet_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO snippets ( + id, user_id, name, content, description, folder, "order", host_filter + ) + VALUES + (1, 'user-1', 'root', 'uptime', NULL, NULL, 2, NULL), + (2, 'user-1', 'deploy', 'make deploy', 'Deploy app', 'ops', 1, 'linux'), + (3, 'user-2', 'other', 'whoami', NULL, NULL, 1, NULL); + + INSERT INTO snippet_folders (id, user_id, name, color, icon) + VALUES + (1, 'user-1', 'ops', '#123456', 'terminal'), + (2, 'user-1', 'db', NULL, NULL), + (3, 'user-2', 'other', NULL, NULL); + `); + + return { + repository: new SnippetRepository(context, onWrite), + sqlite: context.sqlite!, + }; + } + + it("finds owned snippets only", async () => { + const { repository } = await createRepository(); + + await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({ + id: 1, + userId: "user-1", + name: "root", + }); + await expect(repository.findOwnedById("user-1", 3)).resolves.toBeNull(); + await expect(repository.findOwnedById("user-1", 999)).resolves.toBeNull(); + }); + + it("lists folders by name for a user", async () => { + const { repository } = await createRepository(); + + const rows = await repository.listFolders("user-1"); + + expect(rows.map((row) => row.name)).toEqual(["db", "ops"]); + }); + + it("lists export data for a user", async () => { + const { repository } = await createRepository(); + + const snippets = await repository.listSnippetsForExport("user-1"); + const folders = await repository.listFoldersForExport("user-1"); + + expect(snippets.map((row) => row.name)).toEqual(["root", "deploy"]); + expect(folders.map((row) => row.name)).toEqual(["db", "ops"]); + }); + + it("lists owned snippets for route merging", async () => { + const { repository } = await createRepository(); + + const rows = await repository.listOwnedSnippets("user-1"); + + expect(rows.map((row) => row.name)).toEqual(["root", "deploy"]); + }); + + it("reorders snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + await repository.reorderSnippets("user-1", [ + { id: 1, order: 9, folder: " ops " }, + { id: 999, order: 1 }, + ]); + + await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({ + order: 9, + folder: "ops", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("creates snippets with the next folder order", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const created = await repository.createSnippet("user-1", { + name: " new ", + content: " echo ok ", + description: " desc ", + folder: "ops", + hostFilter: { os: "linux" }, + }); + + expect(created).toMatchObject({ + userId: "user-1", + name: "new", + content: "echo ok", + description: "desc", + folder: "ops", + order: 2, + hostFilter: JSON.stringify({ os: "linux" }), + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("updates snippets and returns the original row for audit names", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const result = await repository.updateSnippet("user-1", 2, { + name: " deploy new ", + content: " make deploy2 ", + description: null, + folder: null, + order: 7, + hostFilter: null, + }); + const missing = await repository.updateSnippet("user-1", 3, { + name: "nope", + }); + + expect(result?.existing).toMatchObject({ name: "deploy" }); + expect(result?.updated).toMatchObject({ + name: "deploy new", + content: "make deploy2", + description: null, + folder: null, + order: 7, + hostFilter: null, + }); + expect(missing).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes snippets and returns the deleted row for audit names", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const deleted = await repository.deleteSnippet("user-1", 2); + const missing = await repository.deleteSnippet("user-1", 3); + + expect(deleted).toMatchObject({ id: 2, name: "deploy" }); + expect(missing).toBeNull(); + await expect(repository.findOwnedById("user-1", 2)).resolves.toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes all snippets and folders for a user", async () => { + const onWrite = vi.fn(); + const { repository, sqlite } = await createRepository(onWrite); + + await expect(repository.deleteByUserId("user-1")).resolves.toEqual({ + snippetsDeleted: 2, + foldersDeleted: 2, + }); + + expect(sqlite.prepare("SELECT id FROM snippets ORDER BY id").all()).toEqual( + [{ id: 3 }], + ); + expect( + sqlite.prepare("SELECT id FROM snippet_folders ORDER BY id").all(), + ).toEqual([{ id: 3 }]); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("bulk imports folders and snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const result = await repository.bulkImport( + "user-1", + [ + { + name: " new snippet ", + content: " echo hi ", + description: " desc ", + folder: " new folder ", + hostFilter: "linux", + }, + { name: "", content: "bad" }, + { name: "deploy", content: "skip", folder: "ops" }, + ], + [ + { name: " new folder ", color: " #fff ", icon: " star " }, + { name: "ops" }, + { name: "" }, + ], + false, + ); + + expect(result).toEqual({ + snippetsImported: 1, + snippetsSkipped: 1, + snippetsUpdated: 0, + foldersImported: 1, + foldersSkipped: 1, + failed: 2, + errors: [ + "Folder missing name", + "Snippet 2: name and content are required", + ], + }); + await expect(repository.findOwnedById("user-1", 4)).resolves.toMatchObject({ + name: "new snippet", + content: "echo hi", + description: "desc", + folder: "new folder", + order: 0, + hostFilter: "linux", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("bulk import overwrites existing snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const result = await repository.bulkImport( + "user-1", + [ + { + name: "deploy", + content: "make deploy v2", + folder: "ops", + order: 5, + hostFilter: "prod", + }, + ], + undefined, + true, + ); + + expect(result).toMatchObject({ + snippetsImported: 0, + snippetsSkipped: 0, + snippetsUpdated: 1, + failed: 0, + }); + await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({ + content: "make deploy v2", + order: 5, + hostFilter: "prod", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("creates folders and rejects duplicate names", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const created = await repository.createFolder( + "user-1", + " new ", + " #fff ", + " star ", + ); + const duplicate = await repository.createFolder( + "user-1", + "ops", + null, + null, + ); + + expect(created).toMatchObject({ + userId: "user-1", + name: "new", + color: "#fff", + icon: "star", + }); + expect(duplicate).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("updates folder metadata", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + const updated = await repository.updateFolderMetadata( + "user-1", + "ops", + " #abc ", + undefined, + ); + const missing = await repository.updateFolderMetadata( + "user-1", + "missing", + null, + null, + ); + + expect(updated).toMatchObject({ + name: "ops", + color: "#abc", + icon: "terminal", + }); + expect(missing).toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("renames folders and attached snippets", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + await expect( + repository.renameFolder("user-1", "missing", "new"), + ).resolves.toEqual({ status: "missing" }); + await expect( + repository.renameFolder("user-1", "ops", "db"), + ).resolves.toEqual({ + status: "conflict", + }); + await expect( + repository.renameFolder("user-1", "ops", "deploys"), + ).resolves.toEqual({ status: "renamed" }); + + await expect(repository.listFolders("user-1")).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ name: "deploys" })]), + ); + await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({ + folder: "deploys", + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes folders and moves snippets to the root", async () => { + const onWrite = vi.fn(); + const { repository } = await createRepository(onWrite); + + await repository.deleteFolder("user-1", "ops"); + + expect( + (await repository.listFolders("user-1")).map((row) => row.name), + ).toEqual(["db"]); + await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({ + folder: null, + }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/backend/database/repositories/snippet-repository.ts b/src/backend/database/repositories/snippet-repository.ts new file mode 100644 index 00000000..b6de04bd --- /dev/null +++ b/src/backend/database/repositories/snippet-repository.ts @@ -0,0 +1,540 @@ +import { and, asc, eq, sql } from "drizzle-orm"; +import { snippetFolders, snippets } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type SnippetRecord = typeof snippets.$inferSelect; +export type SnippetFolderRecord = typeof snippetFolders.$inferSelect; +export interface RenameSnippetFolderResult { + status: "renamed" | "missing" | "conflict"; +} +export interface SnippetReorderUpdate { + id: number; + order: number; + folder?: string; +} +export interface NewSnippetInput { + name: string; + content: string; + description?: string | null; + folder?: string | null; + order?: number | null; + hostFilter?: unknown; +} +export interface SnippetUpdateInput { + name?: string; + content?: string; + description?: string | null; + folder?: string | null; + order?: number; + hostFilter?: unknown; +} +export interface UpdateSnippetResult { + existing: SnippetRecord; + updated: SnippetRecord; +} +export interface SnippetBulkImportResult { + snippetsImported: number; + snippetsSkipped: number; + snippetsUpdated: number; + foldersImported: number; + foldersSkipped: number; + failed: number; + errors: string[]; +} +interface BulkImportSnippetInput { + name?: unknown; + content?: unknown; + description?: string | null; + folder?: string | null; + order?: unknown; + hostFilter?: string | null; +} +interface BulkImportFolderInput { + name?: unknown; + color?: string | null; + icon?: string | null; +} + +export class SnippetRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findOwnedById( + userId: string, + snippetId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(snippets) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + async listFolders(userId: string): Promise { + return this.context.drizzle + .select() + .from(snippetFolders) + .where(eq(snippetFolders.userId, userId)) + .orderBy(asc(snippetFolders.name)); + } + + async listSnippetsForExport(userId: string): Promise { + return this.context.drizzle + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy(asc(snippets.folder), asc(snippets.order)); + } + + async listFoldersForExport(userId: string): Promise { + return this.listFolders(userId); + } + + async listOwnedSnippets(userId: string): Promise { + return this.context.drizzle + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy( + sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, + asc(snippets.folder), + asc(snippets.order), + sql`${snippets.updatedAt} DESC`, + ); + } + + async reorderSnippets( + userId: string, + updates: SnippetReorderUpdate[], + ): Promise { + for (const update of updates) { + const { id, order, folder } = update; + + if (!id || order === undefined) { + continue; + } + + const updateFields: Partial<{ + order: number; + folder: string | null; + }> = { + order, + }; + + if (folder !== undefined) { + updateFields.folder = folder?.trim() || null; + } + + await this.context.drizzle + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); + } + + await this.afterWrite(); + } + + async createSnippet( + userId: string, + input: NewSnippetInput, + ): Promise { + const folderValue = input.folder?.trim() || ""; + const order = + input.order === undefined || input.order === null + ? await this.nextOrderForFolder(userId, folderValue) + : input.order; + + const rows = await this.context.drizzle + .insert(snippets) + .values({ + userId, + name: input.name.trim(), + content: input.content.trim(), + description: input.description?.trim() || null, + folder: input.folder?.trim() || null, + order, + hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null, + }) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async updateSnippet( + userId: string, + snippetId: number, + input: SnippetUpdateInput, + ): Promise { + const existing = await this.findOwnedById(userId, snippetId); + if (!existing) return null; + + const updateFields: Partial<{ + updatedAt: ReturnType; + name: string; + content: string; + description: string | null; + folder: string | null; + order: number; + hostFilter: string | null; + }> = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + if (input.name !== undefined) updateFields.name = input.name.trim(); + if (input.content !== undefined) + updateFields.content = input.content.trim(); + if (input.description !== undefined) + updateFields.description = input.description?.trim() || null; + if (input.folder !== undefined) + updateFields.folder = input.folder?.trim() || null; + if (input.order !== undefined) updateFields.order = input.order; + if (input.hostFilter !== undefined) + updateFields.hostFilter = input.hostFilter + ? JSON.stringify(input.hostFilter) + : null; + + const rows = await this.context.drizzle + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) + .returning(); + + await this.afterWrite(); + return { existing, updated: rows[0] }; + } + + async deleteSnippet( + userId: string, + snippetId: number, + ): Promise { + const existing = await this.findOwnedById(userId, snippetId); + if (!existing) return null; + + await this.context.drizzle + .delete(snippets) + .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))); + + await this.afterWrite(); + return existing; + } + + async deleteByUserId(userId: string): Promise<{ + snippetsDeleted: number; + foldersDeleted: number; + }> { + const deletedSnippets = await this.context.drizzle + .delete(snippets) + .where(eq(snippets.userId, userId)) + .returning({ id: snippets.id }); + + const deletedFolders = await this.context.drizzle + .delete(snippetFolders) + .where(eq(snippetFolders.userId, userId)) + .returning({ id: snippetFolders.id }); + + if (deletedSnippets.length > 0 || deletedFolders.length > 0) { + await this.afterWrite(); + } + + return { + snippetsDeleted: deletedSnippets.length, + foldersDeleted: deletedFolders.length, + }; + } + + async bulkImport( + userId: string, + snippetsToImport: unknown[] | undefined, + foldersToImport: unknown[] | undefined, + overwrite: boolean, + ): Promise { + const results: SnippetBulkImportResult = { + snippetsImported: 0, + snippetsSkipped: 0, + snippetsUpdated: 0, + foldersImported: 0, + foldersSkipped: 0, + failed: 0, + errors: [], + }; + + let changed = false; + + if (Array.isArray(foldersToImport)) { + for (const rawFolder of foldersToImport) { + const folder = rawFolder as BulkImportFolderInput; + if (!isNonEmptyString(folder.name)) { + results.failed++; + results.errors.push(`Folder missing name`); + continue; + } + + const created = await this.createFolder( + userId, + folder.name, + folder.color, + folder.icon, + false, + ); + + if (!created) { + results.foldersSkipped++; + continue; + } + + changed = true; + results.foldersImported++; + } + } + + if (Array.isArray(snippetsToImport)) { + for (let i = 0; i < snippetsToImport.length; i++) { + const snippet = snippetsToImport[i] as BulkImportSnippetInput; + + if ( + !isNonEmptyString(snippet.name) || + !isNonEmptyString(snippet.content) + ) { + results.failed++; + results.errors.push( + `Snippet ${i + 1}: name and content are required`, + ); + continue; + } + + const folderVal = snippet.folder?.trim() || null; + const existing = await this.findByNameAndFolder( + userId, + snippet.name.trim(), + folderVal, + ); + + if (existing) { + if (!overwrite) { + results.snippetsSkipped++; + continue; + } + + await this.context.drizzle + .update(snippets) + .set({ + content: snippet.content.trim(), + description: snippet.description?.trim() || null, + folder: folderVal, + order: + typeof snippet.order === "number" + ? snippet.order + : existing.order, + hostFilter: snippet.hostFilter || null, + updatedAt: sql`CURRENT_TIMESTAMP`, + }) + .where( + and(eq(snippets.id, existing.id), eq(snippets.userId, userId)), + ); + changed = true; + results.snippetsUpdated++; + continue; + } + + const maxOrder = await this.maxOrderForFolder(userId, folderVal); + await this.context.drizzle.insert(snippets).values({ + userId, + name: snippet.name.trim(), + content: snippet.content.trim(), + description: snippet.description?.trim() || null, + folder: folderVal, + order: + typeof snippet.order === "number" ? snippet.order : maxOrder + 1, + hostFilter: snippet.hostFilter || null, + }); + changed = true; + results.snippetsImported++; + } + } + + if (changed) { + await this.afterWrite(); + } + + return results; + } + + async createFolder( + userId: string, + name: string, + color: string | null | undefined, + icon: string | null | undefined, + triggerSave = true, + ): Promise { + const existing = await this.findFolderByName(userId, name); + if (existing) return null; + + const rows = await this.context.drizzle + .insert(snippetFolders) + .values({ + userId, + name: name.trim(), + color: color?.trim() || null, + icon: icon?.trim() || null, + }) + .returning(); + + if (triggerSave) { + await this.afterWrite(); + } + return rows[0] ?? null; + } + + async updateFolderMetadata( + userId: string, + name: string, + color: string | null | undefined, + icon: string | null | undefined, + ): Promise { + const existing = await this.findFolderByName(userId, name); + if (!existing) return null; + + const updateFields: Partial<{ + color: string | null; + icon: string | null; + updatedAt: ReturnType; + }> = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + if (color !== undefined) updateFields.color = color?.trim() || null; + if (icon !== undefined) updateFields.icon = icon?.trim() || null; + + const rows = await this.context.drizzle + .update(snippetFolders) + .set(updateFields) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async renameFolder( + userId: string, + oldName: string, + newName: string, + ): Promise { + const existing = await this.findFolderByName(userId, oldName); + if (!existing) return { status: "missing" }; + + const nameExists = await this.findFolderByName(userId, newName); + if (nameExists) return { status: "conflict" }; + + await this.context.drizzle + .update(snippetFolders) + .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, oldName), + ), + ); + + await this.context.drizzle + .update(snippets) + .set({ folder: newName }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); + + await this.afterWrite(); + return { status: "renamed" }; + } + + async deleteFolder(userId: string, name: string): Promise { + await this.context.drizzle + .update(snippets) + .set({ folder: null }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); + + await this.context.drizzle + .delete(snippetFolders) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); + + await this.afterWrite(); + } + + private async findFolderByName( + userId: string, + name: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(snippetFolders) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async nextOrderForFolder( + userId: string, + folder: string, + ): Promise { + return (await this.maxOrderForFolder(userId, folder || null)) + 1; + } + + private async maxOrderForFolder( + userId: string, + folder: string | null, + ): Promise { + const maxOrderResult = await this.context.drizzle + .select({ maxOrder: sql`MAX(${snippets.order})` }) + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + folder + ? eq(snippets.folder, folder) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ); + const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; + return maxOrder; + } + + private async findByNameAndFolder( + userId: string, + name: string, + folder: string | null, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + eq(snippets.name, name), + folder + ? eq(snippets.folder, folder) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/src/backend/database/repositories/ssh-credential-usage-repository.test.ts b/src/backend/database/repositories/ssh-credential-usage-repository.test.ts new file mode 100644 index 00000000..bbd0f364 --- /dev/null +++ b/src/backend/database/repositories/ssh-credential-usage-repository.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; + +describe("SshCredentialUsageRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE ssh_credential_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + credential_id INTEGER NOT NULL, + host_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other'); + INSERT INTO ssh_credentials (id, user_id, name) + VALUES (1, 'user-1', 'cred-one'), (2, 'user-2', 'cred-two'); + `); + + return new SshCredentialUsageRepository(context, onWrite); + } + + it("creates usage records", async () => { + const repo = await createRepository(); + + const created = await repo.create(1, 1, "user-1"); + + expect(created).toMatchObject({ + credentialId: 1, + hostId: 1, + userId: "user-1", + }); + }); + + it("lists usage records by user", async () => { + const repo = await createRepository(); + + await repo.create(1, 1, "user-1"); + await repo.create(1, 2, "user-1"); + await repo.create(2, 3, "user-2"); + + expect( + (await repo.listByUserId("user-1")).map((row) => row.hostId), + ).toEqual([1, 2]); + }); + + it("deletes usage records by user, host, and host list", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create(1, 1, "user-1"); + await repo.create(1, 2, "user-1"); + await repo.create(2, 3, "user-2"); + expect(writeCount).toBe(3); + + expect(await repo.deleteByHostId(1)).toBe(1); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2])).toBe(1); + expect(writeCount).toBe(5); + + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByUserId("user-2")).toBe(0); + expect(writeCount).toBe(6); + }); +}); diff --git a/src/backend/database/repositories/ssh-credential-usage-repository.ts b/src/backend/database/repositories/ssh-credential-usage-repository.ts new file mode 100644 index 00000000..b5bea336 --- /dev/null +++ b/src/backend/database/repositories/ssh-credential-usage-repository.ts @@ -0,0 +1,79 @@ +import { eq, inArray } from "drizzle-orm"; +import { sshCredentialUsage } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect; + +export class SshCredentialUsageRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(sshCredentialUsage) + .where(eq(sshCredentialUsage.userId, userId)); + } + + async create( + credentialId: number, + hostId: number, + userId: string, + ): Promise { + const [created] = await this.context.drizzle + .insert(sshCredentialUsage) + .values({ credentialId, hostId, userId }) + .returning(); + await this.afterWrite(); + return created; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(sshCredentialUsage) + .where(eq(sshCredentialUsage.userId, userId)) + .returning({ id: sshCredentialUsage.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(sshCredentialUsage) + .where(eq(sshCredentialUsage.hostId, hostId)) + .returning({ id: sshCredentialUsage.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostIds(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .delete(sshCredentialUsage) + .where(inArray(sshCredentialUsage.hostId, hostIds)) + .returning({ id: sshCredentialUsage.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/sso-provider-repository.test.ts b/src/backend/database/repositories/sso-provider-repository.test.ts new file mode 100644 index 00000000..6b9641a1 --- /dev/null +++ b/src/backend/database/repositories/sso-provider-repository.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { SsoProviderRepository } from "./sso-provider-repository.js"; + +describe("SsoProviderRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + let sqlite: Awaited>["sqlite"]; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + sqlite = undefined; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + sqlite = context.sqlite; + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0, + sso_provider_id INTEGER + ); + + CREATE TABLE sso_providers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + display_order INTEGER NOT NULL DEFAULT 0, + config TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + + return new SsoProviderRepository(context, onWrite); + } + + it("creates, lists, finds, updates, and deletes providers", async () => { + const repo = await createRepository(); + + const disabled = await repo.create({ + name: "Disabled", + type: "oidc", + enabled: false, + displayOrder: 1, + config: "{}", + }); + const enabled = await repo.create({ + name: "GitHub", + type: "github", + enabled: true, + displayOrder: 0, + config: '{"client_id":"id"}', + }); + + expect((await repo.listEnabledPublic()).map((row) => row.id)).toEqual([ + enabled.id, + ]); + expect((await repo.listAll()).map((row) => row.id)).toEqual([ + enabled.id, + disabled.id, + ]); + expect((await repo.findById(enabled.id))?.name).toBe("GitHub"); + expect((await repo.findFirstEnabledOidcLike())?.id).toBe(enabled.id); + + const updated = await repo.update(enabled.id, { + name: "GitHub SSO", + config: '{"client_id":"updated"}', + updatedAt: "2026-06-27T00:00:00.000Z", + }); + expect(updated?.name).toBe("GitHub SSO"); + expect(updated?.config).toContain("updated"); + + expect(await repo.delete(enabled.id)).toBe(true); + expect(await repo.findById(enabled.id)).toBeNull(); + expect(await repo.delete(enabled.id)).toBe(false); + }); + + it("counts users associated with a provider", async () => { + const repo = await createRepository(); + const provider = await repo.create({ + name: "LDAP", + type: "ldap", + enabled: true, + displayOrder: 0, + config: "{}", + }); + + sqlite?.exec(` + INSERT INTO users (id, username, password_hash, sso_provider_id) + VALUES ('user-1', 'u1', 'hash', ${provider.id}), + ('user-2', 'u2', 'hash', ${provider.id}), + ('user-3', 'u3', 'hash', NULL); + `); + + expect(await repo.countUsersByProviderId(provider.id)).toBe(2); + }); + + it("runs the write hook after provider writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const provider = await repo.create({ + name: "OIDC", + type: "oidc", + enabled: true, + displayOrder: 0, + config: "{}", + }); + await repo.update(provider.id, { enabled: false }); + await repo.delete(provider.id); + await repo.delete(provider.id); + + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/database/repositories/sso-provider-repository.ts b/src/backend/database/repositories/sso-provider-repository.ts new file mode 100644 index 00000000..35063e87 --- /dev/null +++ b/src/backend/database/repositories/sso-provider-repository.ts @@ -0,0 +1,119 @@ +import { asc, eq } from "drizzle-orm"; +import { ssoProviders, users } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type SsoProviderRecord = typeof ssoProviders.$inferSelect; +export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert; +export type SsoProviderUpdate = Partial< + Pick< + NewSsoProviderRecord, + "name" | "type" | "enabled" | "displayOrder" | "config" | "updatedAt" + > +>; + +export type PublicSsoProviderRecord = Pick< + SsoProviderRecord, + "id" | "name" | "type" | "displayOrder" +>; + +export class SsoProviderRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listEnabledPublic(): Promise { + return this.context.drizzle + .select({ + id: ssoProviders.id, + name: ssoProviders.name, + type: ssoProviders.type, + displayOrder: ssoProviders.displayOrder, + }) + .from(ssoProviders) + .where(eq(ssoProviders.enabled, true)) + .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + } + + async listAll(): Promise { + return this.context.drizzle + .select() + .from(ssoProviders) + .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(ssoProviders) + .where(eq(ssoProviders.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async findFirstEnabledOidcLike(): Promise { + const rows = await this.context.drizzle + .select() + .from(ssoProviders) + .where(eq(ssoProviders.enabled, true)) + .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + + return ( + rows.find( + (row) => + row.type === "oidc" || row.type === "github" || row.type === "google", + ) ?? null + ); + } + + async create(provider: NewSsoProviderRecord): Promise { + const rows = await this.context.drizzle + .insert(ssoProviders) + .values(provider) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async update( + id: number, + update: SsoProviderUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(ssoProviders) + .set(update) + .where(eq(ssoProviders.id, id)) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async delete(id: number): Promise { + const rows = await this.context.drizzle + .delete(ssoProviders) + .where(eq(ssoProviders.id, id)) + .returning({ id: ssoProviders.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async countUsersByProviderId(providerId: number): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users) + .where(eq(users.ssoProviderId, providerId)); + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/termix-identity-ca-repository.test.ts b/src/backend/database/repositories/termix-identity-ca-repository.test.ts new file mode 100644 index 00000000..b57cd8d0 --- /dev/null +++ b/src/backend/database/repositories/termix-identity-ca-repository.test.ts @@ -0,0 +1,278 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; +import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js"; + +describe("TermixIdentityCaRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + vi.restoreAllMocks(); + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite = vi.fn()): Promise<{ + repo: TermixIdentityCaRepository; + sqlite: NonNullable< + Awaited>["sqlite"] + >; + onWrite: ReturnType; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE termix_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + handle TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE termix_identity_ca ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id INTEGER NOT NULL UNIQUE, + user_id TEXT NOT NULL, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + validity_days INTEGER NOT NULL DEFAULT 90, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'); + INSERT INTO termix_identities (id, user_id, handle) + VALUES (7, 'user-1', 'alice'); + `); + + return { + repo: new TermixIdentityCaRepository(context, onWrite), + sqlite: context.sqlite!, + onWrite, + }; + } + + function mockCrypto(): void { + vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn(DataCrypto, "encryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + privateKey: "encrypted-ca-private", + }) as typeof record, + ); + vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( + (_tableName, record) => + ({ + ...record, + privateKey: + record.privateKey === "encrypted-ca-private" + ? "decrypted-ca-private" + : record.privateKey, + }) as typeof record, + ); + } + + it("creates CA private keys with the real row id before encryption", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + mockCrypto(); + + const created = await repo.createEncryptedForUser("user-1", { + identityId: 7, + userId: "user-1", + publicKey: "ssh-ed25519 public", + privateKey: "plain-ca-private", + validityDays: 120, + }); + + const raw = sqlite + .prepare( + "SELECT id, public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", + ) + .get(7) as { + id: number; + public_key: string; + private_key: string; + validity_days: number; + }; + + expect(created.privateKey).toBe("decrypted-ca-private"); + expect(raw.private_key).toBe("encrypted-ca-private"); + expect(raw.public_key).toBe("ssh-ed25519 public"); + expect(raw.validity_days).toBe(120); + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( + "termix_identity_ca", + { id: raw.id, privateKey: "plain-ca-private" }, + "user-1", + Buffer.from("user-key"), + ); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("reads public CA metadata without decrypting private key material", async () => { + const { repo, sqlite } = await createRepository(); + const decryptSpy = vi.spyOn(DataCrypto, "decryptRecord"); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + + await expect(repo.findPublicByIdentityId(7)).resolves.toEqual({ + publicKey: "ssh-ed25519 public", + validityDays: 45, + }); + expect(decryptSpy).not.toHaveBeenCalled(); + }); + + it("decrypts CA private keys through the user data boundary", async () => { + const { repo, sqlite } = await createRepository(); + mockCrypto(); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + + const ca = await repo.findDecryptedByIdentityId("user-1", 7); + + expect(ca).toMatchObject({ + identityId: 7, + publicKey: "ssh-ed25519 public", + privateKey: "decrypted-ca-private", + validityDays: 45, + }); + expect(DataCrypto.decryptRecord).toHaveBeenCalledWith( + "termix_identity_ca", + expect.objectContaining({ identityId: 7 }), + "user-1", + Buffer.from("user-key"), + ); + }); + + it("updates CA private keys through encrypted writes", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + mockCrypto(); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 old", "encrypted-ca-private", 45); + onWrite.mockClear(); + + const updated = await repo.updateEncryptedForIdentity("user-1", 7, { + publicKey: "ssh-ed25519 new", + privateKey: "plain-updated-ca-private", + validityDays: 90, + }); + + const raw = sqlite + .prepare( + "SELECT public_key, private_key, validity_days FROM termix_identity_ca WHERE identity_id = ?", + ) + .get(7) as { + public_key: string; + private_key: string; + validity_days: number; + }; + + expect(updated).toMatchObject({ + publicKey: "ssh-ed25519 new", + privateKey: "decrypted-ca-private", + validityDays: 90, + }); + expect(raw).toEqual({ + public_key: "ssh-ed25519 new", + private_key: "encrypted-ca-private", + validity_days: 90, + }); + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( + "termix_identity_ca", + expect.objectContaining({ + privateKey: "plain-updated-ca-private", + }), + "user-1", + Buffer.from("user-key"), + ); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes CA rows through the write boundary", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + onWrite.mockClear(); + + await expect(repo.deleteByIdentityId(7)).resolves.toBe(true); + await expect(repo.deleteByIdentityId(7)).resolves.toBe(false); + expect( + sqlite.prepare("SELECT COUNT(*) AS count FROM termix_identity_ca").get(), + ).toEqual({ count: 0 }); + expect(onWrite).toHaveBeenCalledTimes(1); + }); + + it("deletes CA rows for a user", async () => { + const { repo, sqlite, onWrite } = await createRepository(); + sqlite + .prepare( + "INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)", + ) + .run("user-2", "bob", "hash"); + sqlite + .prepare( + "INSERT INTO termix_identities (id, user_id, handle) VALUES (?, ?, ?)", + ) + .run(8, "user-2", "bob"); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(7, "user-1", "ssh-ed25519 public", "encrypted-ca-private", 45); + sqlite + .prepare( + "INSERT INTO termix_identity_ca (identity_id, user_id, public_key, private_key, validity_days) VALUES (?, ?, ?, ?, ?)", + ) + .run(8, "user-2", "ssh-ed25519 other", "encrypted-other", 90); + onWrite.mockClear(); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(1); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect( + sqlite + .prepare( + "SELECT user_id, public_key FROM termix_identity_ca ORDER BY user_id", + ) + .all(), + ).toEqual([{ user_id: "user-2", public_key: "ssh-ed25519 other" }]); + expect(onWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/backend/database/repositories/termix-identity-ca-repository.ts b/src/backend/database/repositories/termix-identity-ca-repository.ts new file mode 100644 index 00000000..7660ebd5 --- /dev/null +++ b/src/backend/database/repositories/termix-identity-ca-repository.ts @@ -0,0 +1,163 @@ +import { eq } from "drizzle-orm"; +import { termixIdentityCa } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; +import { DataCrypto } from "../../utils/data-crypto.js"; + +export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect; +export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert; +export type TermixIdentityCaUpdate = Partial< + Pick< + NewTermixIdentityCaRecord, + "publicKey" | "privateKey" | "validityDays" | "updatedAt" + > +>; + +export class TermixIdentityCaRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findPublicByIdentityId( + identityId: number, + ): Promise | null> { + const rows = await this.context.drizzle + .select({ + publicKey: termixIdentityCa.publicKey, + validityDays: termixIdentityCa.validityDays, + }) + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identityId)) + .limit(1); + + return rows[0] ?? null; + } + + async findDecryptedByIdentityId( + userId: string, + identityId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identityId)) + .limit(1); + + return this.decryptOne(rows[0] ?? null, userId); + } + + async createEncryptedForUser( + userId: string, + ca: NewTermixIdentityCaRecord, + ): Promise { + const userDataKey = DataCrypto.validateUserAccess(userId); + const result = this.context.drizzle.transaction((tx) => { + const inserted = tx + .insert(termixIdentityCa) + .values({ ...ca, privateKey: "" }) + .returning() + .all(); + const row = inserted[0]; + const encrypted = DataCrypto.encryptRecord( + "termix_identity_ca", + { id: row.id, privateKey: ca.privateKey }, + userId, + userDataKey, + ); + + return tx + .update(termixIdentityCa) + .set({ privateKey: encrypted.privateKey }) + .where(eq(termixIdentityCa.id, row.id)) + .returning() + .all()[0]; + }); + + await this.afterWrite(); + return DataCrypto.decryptRecord( + "termix_identity_ca", + result, + userId, + userDataKey, + ); + } + + async updateEncryptedForIdentity( + userId: string, + identityId: number, + update: TermixIdentityCaUpdate, + ): Promise { + const existing = await this.findDecryptedByIdentityId(userId, identityId); + if (!existing) return null; + + const userDataKey = DataCrypto.validateUserAccess(userId); + const encryptedPrivateKey = update.privateKey + ? DataCrypto.encryptRecord( + "termix_identity_ca", + { id: existing.id, privateKey: update.privateKey }, + userId, + userDataKey, + ).privateKey + : undefined; + + const rows = await this.context.drizzle + .update(termixIdentityCa) + .set({ + ...update, + ...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}), + }) + .where(eq(termixIdentityCa.identityId, identityId)) + .returning(); + + await this.afterWrite(); + return this.decryptOne(rows[0] ?? null, userId); + } + + async deleteByIdentityId(identityId: number): Promise { + const rows = await this.context.drizzle + .delete(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identityId)) + .returning({ id: termixIdentityCa.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(termixIdentityCa) + .where(eq(termixIdentityCa.userId, userId)) + .returning({ id: termixIdentityCa.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private decryptOne>( + record: T | null, + userId: string, + ): T | null { + if (!record) return null; + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) return null; + return DataCrypto.decryptRecord( + "termix_identity_ca", + record, + userId, + userDataKey, + ); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/termix-identity-repository.test.ts b/src/backend/database/repositories/termix-identity-repository.test.ts new file mode 100644 index 00000000..75197133 --- /dev/null +++ b/src/backend/database/repositories/termix-identity-repository.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { TermixIdentityRepository } from "./termix-identity-repository.js"; + +describe("TermixIdentityRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite = vi.fn()): Promise<{ + repo: TermixIdentityRepository; + onWrite: ReturnType; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE termix_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + handle TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE termix_identity_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + public_key TEXT NOT NULL, + key_type TEXT NOT NULL, + algorithm TEXT NOT NULL, + label TEXT, + comment TEXT, + source TEXT NOT NULL DEFAULT 'manual', + credential_id INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_id) REFERENCES termix_identities(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + `); + + return { + repo: new TermixIdentityRepository(context, onWrite), + onWrite, + }; + } + + it("creates, updates, finds, and deletes identities", async () => { + const { repo, onWrite } = await createRepository(); + + const created = await repo.createIdentity({ + userId: "user-1", + handle: "alice", + description: "workstation keys", + }); + + expect(created.id).toBeGreaterThan(0); + expect(await repo.isHandleTaken("alice")).toBe(true); + expect(await repo.findIdentityForUser("user-1")).toMatchObject({ + handle: "alice", + }); + expect(await repo.findIdentityByHandle("alice")).toMatchObject({ + userId: "user-1", + }); + + const updated = await repo.updateIdentityForUser("user-1", { + handle: "alice-renamed", + description: null, + }); + expect(updated).toMatchObject({ + handle: "alice-renamed", + description: null, + }); + + await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(true); + await expect(repo.deleteIdentityForUser("user-1")).resolves.toBe(false); + await expect(repo.findIdentityForUser("user-1")).resolves.toBeNull(); + expect(onWrite).toHaveBeenCalledTimes(3); + }); + + it("creates, lists, updates, deletes, and links public keys", async () => { + const { repo, onWrite } = await createRepository(); + const identity = await repo.createIdentity({ + userId: "user-1", + handle: "alice", + description: null, + }); + onWrite.mockClear(); + + const first = await repo.createKey({ + identityId: identity.id, + userId: "user-1", + publicKey: "ssh-ed25519 AAAA1", + keyType: "ssh-ed25519", + algorithm: "ED25519", + label: "laptop", + comment: null, + source: "credential", + credentialId: 10, + }); + await repo.createKey({ + identityId: identity.id, + userId: "user-1", + publicKey: "ssh-rsa AAAA2", + keyType: "ssh-rsa", + algorithm: "RSA", + label: "disabled", + comment: null, + source: "manual", + credentialId: 20, + enabled: false, + }); + + expect(await repo.listKeysByIdentityId(identity.id)).toHaveLength(2); + expect(await repo.listEnabledKeysByIdentityId(identity.id)).toMatchObject([ + { id: first.id, publicKey: "ssh-ed25519 AAAA1" }, + ]); + expect(await repo.listLinkedCredentialIds(identity.id)).toEqual([10]); + + const updated = await repo.updateKeyForUser("user-1", first.id, { + enabled: false, + label: "revoked", + }); + expect(updated).toMatchObject({ enabled: false, label: "revoked" }); + await expect( + repo.findKeyForUser("user-1", first.id), + ).resolves.toMatchObject({ label: "revoked" }); + + await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe(true); + await expect(repo.deleteKeyForUser("user-1", first.id)).resolves.toBe( + false, + ); + expect(onWrite).toHaveBeenCalledTimes(4); + }); + + it("deletes identities and keys for a user", async () => { + const { repo, onWrite } = await createRepository(); + const userIdentity = await repo.createIdentity({ + userId: "user-1", + handle: "alice", + description: null, + }); + const otherIdentity = await repo.createIdentity({ + userId: "user-2", + handle: "bob", + description: null, + }); + await repo.createKey({ + identityId: userIdentity.id, + userId: "user-1", + publicKey: "ssh-ed25519 AAAA1", + keyType: "ssh-ed25519", + algorithm: "ED25519", + source: "manual", + }); + await repo.createKey({ + identityId: otherIdentity.id, + userId: "user-2", + publicKey: "ssh-ed25519 AAAA2", + keyType: "ssh-ed25519", + algorithm: "ED25519", + source: "manual", + }); + onWrite.mockClear(); + + await expect(repo.deleteByUserId("user-1")).resolves.toEqual({ + identitiesDeleted: 1, + keysDeleted: 1, + }); + await expect(repo.deleteByUserId("missing")).resolves.toEqual({ + identitiesDeleted: 0, + keysDeleted: 0, + }); + + expect(await repo.findIdentityForUser("user-1")).toBeNull(); + expect(await repo.listKeysByIdentityId(userIdentity.id)).toEqual([]); + expect(await repo.findIdentityForUser("user-2")).toMatchObject({ + handle: "bob", + }); + expect(await repo.listKeysByIdentityId(otherIdentity.id)).toHaveLength(1); + expect(onWrite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/backend/database/repositories/termix-identity-repository.ts b/src/backend/database/repositories/termix-identity-repository.ts new file mode 100644 index 00000000..596d8ffa --- /dev/null +++ b/src/backend/database/repositories/termix-identity-repository.ts @@ -0,0 +1,244 @@ +import { and, asc, eq } from "drizzle-orm"; +import { termixIdentities, termixIdentityKeys } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type TermixIdentityRecord = typeof termixIdentities.$inferSelect; +export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert; +export type TermixIdentityUpdate = Partial< + Pick +>; + +export type TermixIdentityKeyRecord = typeof termixIdentityKeys.$inferSelect; +export type NewTermixIdentityKeyRecord = typeof termixIdentityKeys.$inferInsert; +export type TermixIdentityKeyUpdate = Partial< + Pick +>; + +export class TermixIdentityRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findIdentityForUser( + userId: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentities) + .where(eq(termixIdentities.userId, userId)) + .limit(1); + + return rows[0] ?? null; + } + + async findIdentityByHandle( + handle: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + + return rows[0] ?? null; + } + + async isHandleTaken(handle: string): Promise { + const rows = await this.context.drizzle + .select({ id: termixIdentities.id }) + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + + return rows.length > 0; + } + + async createIdentity( + identity: NewTermixIdentityRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(termixIdentities) + .values(identity) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async updateIdentityForUser( + userId: string, + update: TermixIdentityUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(termixIdentities) + .set(update) + .where(eq(termixIdentities.userId, userId)) + .returning(); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows[0] ?? null; + } + + async deleteIdentityForUser(userId: string): Promise { + const rows = await this.context.drizzle + .delete(termixIdentities) + .where(eq(termixIdentities.userId, userId)) + .returning({ id: termixIdentities.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise<{ + identitiesDeleted: number; + keysDeleted: number; + }> { + const keyRows = await this.context.drizzle + .delete(termixIdentityKeys) + .where(eq(termixIdentityKeys.userId, userId)) + .returning({ id: termixIdentityKeys.id }); + + const identityRows = await this.context.drizzle + .delete(termixIdentities) + .where(eq(termixIdentities.userId, userId)) + .returning({ id: termixIdentities.id }); + + if (keyRows.length > 0 || identityRows.length > 0) { + await this.afterWrite(); + } + + return { + identitiesDeleted: identityRows.length, + keysDeleted: keyRows.length, + }; + } + + async listKeysByIdentityId( + identityId: number, + ): Promise { + return this.context.drizzle + .select() + .from(termixIdentityKeys) + .where(eq(termixIdentityKeys.identityId, identityId)) + .orderBy(asc(termixIdentityKeys.id)); + } + + async listEnabledKeysByIdentityId( + identityId: number, + ): Promise { + return this.context.drizzle + .select() + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.identityId, identityId), + eq(termixIdentityKeys.enabled, true), + ), + ) + .orderBy(asc(termixIdentityKeys.id)); + } + + async listLinkedCredentialIds(identityId: number): Promise { + const rows = await this.context.drizzle + .select({ credentialId: termixIdentityKeys.credentialId }) + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.identityId, identityId), + eq(termixIdentityKeys.enabled, true), + ), + ); + + return Array.from( + new Set( + rows + .map((row) => row.credentialId) + .filter( + (credentialId): credentialId is number => credentialId !== null, + ), + ), + ); + } + + async createKey( + key: NewTermixIdentityKeyRecord, + ): Promise { + const rows = await this.context.drizzle + .insert(termixIdentityKeys) + .values(key) + .returning(); + + await this.afterWrite(); + return rows[0]; + } + + async updateKeyForUser( + userId: string, + id: number, + update: TermixIdentityKeyUpdate, + ): Promise { + const rows = await this.context.drizzle + .update(termixIdentityKeys) + .set(update) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .returning(); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows[0] ?? null; + } + + async deleteKeyForUser(userId: string, id: number): Promise { + const rows = await this.context.drizzle + .delete(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .returning({ id: termixIdentityKeys.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async findKeyForUser( + userId: string, + id: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/tmux-session-tag-repository.test.ts b/src/backend/database/repositories/tmux-session-tag-repository.test.ts new file mode 100644 index 00000000..e5737ce2 --- /dev/null +++ b/src/backend/database/repositories/tmux-session-tag-repository.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js"; + +describe("TmuxSessionTagRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE tmux_session_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + session_name TEXT NOT NULL, + tag TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag) + VALUES + ('user-1', 1, 'api', 'prod'), + ('user-1', 1, 'api', 'critical'), + ('user-1', 1, 'worker', 'batch'), + ('user-2', 2, 'api', 'other'); + `); + + return new TmuxSessionTagRepository(context, onWrite); + } + + it("groups tags by session for a user and host", async () => { + const repo = await createRepository(); + + const tags = await repo.listByUserAndHost("user-1", 1); + + expect(tags.get("api")).toEqual(["prod", "critical"]); + expect(tags.get("worker")).toEqual(["batch"]); + expect(tags.has("missing")).toBe(false); + }); + + it("renames and deletes session tags by host/session", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect(await repo.renameSessionForHost(1, "api", "api-renamed")).toBe(2); + expect(await repo.renameSessionForHost(1, "missing", "noop")).toBe(0); + + const renamed = await repo.listByUserAndHost("user-1", 1); + expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]); + expect(renamed.has("api")).toBe(false); + + expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(2); + expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(0); + expect(writeCount).toBe(2); + }); + + it("replaces tags for one user/host/session", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.replaceForUserHostSession("user-1", 1, "api", [ + "blue", + "green", + ]), + ).toBe(4); + + const tags = await repo.listByUserAndHost("user-1", 1); + expect(tags.get("api")).toEqual(["blue", "green"]); + expect(tags.get("worker")).toEqual(["batch"]); + + expect( + await repo.replaceForUserHostSession("user-1", 1, "worker", []), + ).toBe(1); + expect( + await repo.replaceForUserHostSession("user-1", 1, "missing", []), + ).toBe(0); + expect(writeCount).toBe(2); + }); + + it("deletes all tags for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(3); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.listByUserAndHost("user-1", 1)).toEqual(new Map()); + expect(await repo.listByUserAndHost("user-2", 2)).toEqual( + new Map([["api", ["other"]]]), + ); + expect(writeCount).toBe(1); + }); +}); diff --git a/src/backend/database/repositories/tmux-session-tag-repository.ts b/src/backend/database/repositories/tmux-session-tag-repository.ts new file mode 100644 index 00000000..836da4b3 --- /dev/null +++ b/src/backend/database/repositories/tmux-session-tag-repository.ts @@ -0,0 +1,139 @@ +import { and, eq } from "drizzle-orm"; +import { tmuxSessionTags } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect; + +export interface TmuxSessionTagInput { + userId: string; + hostId: number; + sessionName: string; + tag: string; +} + +export class TmuxSessionTagRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserAndHost( + userId: string, + hostId: number, + ): Promise> { + const rows = await this.context.drizzle + .select() + .from(tmuxSessionTags) + .where( + and( + eq(tmuxSessionTags.userId, userId), + eq(tmuxSessionTags.hostId, hostId), + ), + ); + + const bySession = new Map(); + for (const row of rows) { + const tags = bySession.get(row.sessionName) ?? []; + tags.push(row.tag); + bySession.set(row.sessionName, tags); + } + return bySession; + } + + async renameSessionForHost( + hostId: number, + sessionName: string, + newSessionName: string, + ): Promise { + const rows = await this.context.drizzle + .update(tmuxSessionTags) + .set({ sessionName: newSessionName }) + .where( + and( + eq(tmuxSessionTags.hostId, hostId), + eq(tmuxSessionTags.sessionName, sessionName), + ), + ) + .returning({ id: tmuxSessionTags.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteSessionForHost( + hostId: number, + sessionName: string, + ): Promise { + const rows = await this.context.drizzle + .delete(tmuxSessionTags) + .where( + and( + eq(tmuxSessionTags.hostId, hostId), + eq(tmuxSessionTags.sessionName, sessionName), + ), + ) + .returning({ id: tmuxSessionTags.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async replaceForUserHostSession( + userId: string, + hostId: number, + sessionName: string, + tags: string[], + ): Promise { + const deletedRows = await this.context.drizzle + .delete(tmuxSessionTags) + .where( + and( + eq(tmuxSessionTags.userId, userId), + eq(tmuxSessionTags.hostId, hostId), + eq(tmuxSessionTags.sessionName, sessionName), + ), + ) + .returning({ id: tmuxSessionTags.id }); + + if (tags.length > 0) { + await this.context.drizzle.insert(tmuxSessionTags).values( + tags.map((tag) => ({ + userId, + hostId, + sessionName, + tag, + })), + ); + } + + const changedRows = deletedRows.length + tags.length; + if (changedRows > 0) { + await this.afterWrite(); + } + + return changedRows; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(tmuxSessionTags) + .where(eq(tmuxSessionTags.userId, userId)) + .returning({ id: tmuxSessionTags.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/transfer-recent-repository.test.ts b/src/backend/database/repositories/transfer-recent-repository.test.ts new file mode 100644 index 00000000..9dc29d8e --- /dev/null +++ b/src/backend/database/repositories/transfer-recent-repository.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { TransferRecentRepository } from "./transfer-recent-repository.js"; + +describe("TransferRecentRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE transfer_recent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + source_host_id INTEGER NOT NULL, + dest_host_id INTEGER NOT NULL, + dest_path TEXT NOT NULL, + dest_path_label TEXT NOT NULL, + last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO hosts (id, user_id, name) + VALUES (1, 'user-1', 'source'), (2, 'user-1', 'dest-a'), (3, 'user-1', 'dest-b'), (4, 'user-2', 'other'); + `); + + return new TransferRecentRepository(context, onWrite); + } + + it("upserts, lists, and prunes recent transfer destinations", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForDestination( + "user-1", + { sourceHostId: 1, destHostId: 2, destPath: "/var/www" }, + "2026-01-01T00:00:00.000Z", + ); + await repo.upsertForDestination( + "user-1", + { + sourceHostId: 1, + destHostId: 3, + destPath: "/opt/app", + destPathLabel: "App", + }, + "2026-01-02T00:00:00.000Z", + ); + await repo.upsertForDestination( + "user-1", + { sourceHostId: 1, destHostId: 2, destPath: "/var/www" }, + "2026-01-03T00:00:00.000Z", + ); + + const recent = await repo.listBySourceHost("user-1", 1); + + expect(recent).toHaveLength(2); + expect(recent.map((entry) => entry.destPath)).toEqual([ + "/var/www", + "/opt/app", + ]); + expect(recent[0].lastUsed).toBe("2026-01-03T00:00:00.000Z"); + expect(recent[0].destPathLabel).toBe("/var/www"); + expect(writeCount).toBe(3); + + expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(1); + expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(0); + expect(await repo.listBySourceHost("user-1", 1)).toHaveLength(1); + expect(writeCount).toBe(4); + }); + + it("deletes recent transfer destinations by user and host references", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsertForDestination("user-1", { + sourceHostId: 1, + destHostId: 2, + destPath: "/one", + }); + await repo.upsertForDestination("user-1", { + sourceHostId: 2, + destHostId: 3, + destPath: "/two", + }); + await repo.upsertForDestination("user-2", { + sourceHostId: 4, + destHostId: 1, + destPath: "/other", + }); + expect(writeCount).toBe(3); + + expect(await repo.deleteByHostId(1)).toBe(2); + expect(await repo.deleteByHostId(1)).toBe(0); + expect(writeCount).toBe(4); + + await repo.upsertForDestination("user-1", { + sourceHostId: 1, + destHostId: 2, + destPath: "/three", + }); + expect(await repo.deleteByHostIds([])).toBe(0); + expect(await repo.deleteByHostIds([2, 3])).toBe(2); + expect(writeCount).toBe(6); + + await repo.upsertForDestination("user-2", { + sourceHostId: 4, + destHostId: 1, + destPath: "/last", + }); + expect(await repo.deleteByUserId("user-2")).toBe(1); + expect(await repo.deleteByUserId("user-2")).toBe(0); + expect(writeCount).toBe(8); + }); +}); diff --git a/src/backend/database/repositories/transfer-recent-repository.ts b/src/backend/database/repositories/transfer-recent-repository.ts new file mode 100644 index 00000000..4c804e5a --- /dev/null +++ b/src/backend/database/repositories/transfer-recent-repository.ts @@ -0,0 +1,171 @@ +import { and, desc, eq, inArray, or } from "drizzle-orm"; +import { transferRecent } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type TransferRecentRecord = typeof transferRecent.$inferSelect; + +export interface TransferRecentDestinationInput { + sourceHostId: number; + destHostId: number; + destPath: string; + destPathLabel?: string | null; +} + +export class TransferRecentRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(transferRecent) + .where(eq(transferRecent.userId, userId)); + } + + async listBySourceHost( + userId: string, + sourceHostId: number, + limit = 10, + ): Promise { + return this.context.drizzle + .select() + .from(transferRecent) + .where( + and( + eq(transferRecent.userId, userId), + eq(transferRecent.sourceHostId, sourceHostId), + ), + ) + .orderBy(desc(transferRecent.lastUsed)) + .limit(limit); + } + + async upsertForDestination( + userId: string, + input: TransferRecentDestinationInput, + lastUsed = new Date().toISOString(), + ): Promise { + const [existing] = await this.context.drizzle + .select({ id: transferRecent.id }) + .from(transferRecent) + .where( + and( + eq(transferRecent.userId, userId), + eq(transferRecent.sourceHostId, input.sourceHostId), + eq(transferRecent.destHostId, input.destHostId), + eq(transferRecent.destPath, input.destPath), + ), + ) + .limit(1); + + if (existing) { + await this.context.drizzle + .update(transferRecent) + .set({ lastUsed }) + .where(eq(transferRecent.id, existing.id)); + } else { + await this.context.drizzle.insert(transferRecent).values({ + userId, + sourceHostId: input.sourceHostId, + destHostId: input.destHostId, + destPath: input.destPath, + destPathLabel: input.destPathLabel || input.destPath, + lastUsed, + }); + } + + await this.afterWrite(); + } + + async pruneSourceHost( + userId: string, + sourceHostId: number, + keep = 10, + ): Promise { + const rows = await this.context.drizzle + .select({ id: transferRecent.id }) + .from(transferRecent) + .where( + and( + eq(transferRecent.userId, userId), + eq(transferRecent.sourceHostId, sourceHostId), + ), + ) + .orderBy(desc(transferRecent.lastUsed)); + + const idsToDelete = rows.slice(keep).map((row) => row.id); + if (idsToDelete.length === 0) { + return 0; + } + + const deleted = await this.context.drizzle + .delete(transferRecent) + .where(inArray(transferRecent.id, idsToDelete)) + .returning({ id: transferRecent.id }); + + if (deleted.length > 0) { + await this.afterWrite(); + } + + return deleted.length; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(transferRecent) + .where(eq(transferRecent.userId, userId)) + .returning({ id: transferRecent.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostId(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(transferRecent) + .where( + or( + eq(transferRecent.sourceHostId, hostId), + eq(transferRecent.destHostId, hostId), + ), + ) + .returning({ id: transferRecent.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + async deleteByHostIds(hostIds: number[]): Promise { + if (hostIds.length === 0) { + return 0; + } + + const rows = await this.context.drizzle + .delete(transferRecent) + .where( + or( + inArray(transferRecent.sourceHostId, hostIds), + inArray(transferRecent.destHostId, hostIds), + ), + ) + .returning({ id: transferRecent.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/trusted-device-repository.test.ts b/src/backend/database/repositories/trusted-device-repository.test.ts new file mode 100644 index 00000000..475a865c --- /dev/null +++ b/src/backend/database/repositories/trusted-device-repository.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { TrustedDeviceRepository } from "./trusted-device-repository.js"; + +describe("TrustedDeviceRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(onWrite?: () => void): Promise<{ + trustedDevices: TrustedDeviceRepository; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE trusted_devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + device_fingerprint TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + INSERT INTO users (id, username, password_hash) VALUES + ('user-1', 'admin', 'hash'), + ('user-2', 'user', 'hash'); + `); + + return { + trustedDevices: new TrustedDeviceRepository(context, onWrite), + }; + } + + it("upserts, touches, finds, and deletes trusted devices", async () => { + const repo = await createRepository(); + + await repo.trustedDevices.upsert({ + id: "device-1", + userId: "user-1", + deviceFingerprint: "fingerprint", + deviceType: "desktop", + deviceInfo: "Firefox", + createdAt: "2026-06-26T00:00:00.000Z", + expiresAt: "2026-07-26T00:00:00.000Z", + lastUsedAt: "2026-06-26T00:00:00.000Z", + }); + + expect( + ( + await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ) + )?.deviceType, + ).toBe("desktop"); + + await repo.trustedDevices.touch( + "user-1", + "fingerprint", + "2026-06-26T01:00:00.000Z", + ); + expect( + ( + await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ) + )?.lastUsedAt, + ).toBe("2026-06-26T01:00:00.000Z"); + + await repo.trustedDevices.upsert({ + id: "device-ignored", + userId: "user-1", + deviceFingerprint: "fingerprint", + deviceType: "mobile", + deviceInfo: "Safari", + expiresAt: "2026-08-26T00:00:00.000Z", + lastUsedAt: "2026-06-26T02:00:00.000Z", + }); + + const updated = await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ); + expect(updated?.id).toBe("device-1"); + expect(updated?.deviceType).toBe("desktop"); + expect(updated?.expiresAt).toBe("2026-08-26T00:00:00.000Z"); + + await repo.trustedDevices.deleteByUserAndFingerprint( + "user-1", + "fingerprint", + ); + expect( + await repo.trustedDevices.findByUserAndFingerprint( + "user-1", + "fingerprint", + ), + ).toBeNull(); + }); + + it("deletes all trusted devices for a user", async () => { + const repo = await createRepository(); + + for (const id of ["device-1", "device-2"]) { + await repo.trustedDevices.upsert({ + id, + userId: "user-2", + deviceFingerprint: id, + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-07-26T00:00:00.000Z", + }); + } + + await repo.trustedDevices.deleteByUserId("user-2"); + + expect( + await repo.trustedDevices.findByUserAndFingerprint("user-2", "device-1"), + ).toBeNull(); + expect( + await repo.trustedDevices.findByUserAndFingerprint("user-2", "device-2"), + ).toBeNull(); + }); + + it("runs the write hook after trusted device writes", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.trustedDevices.upsert({ + id: "device-1", + userId: "user-1", + deviceFingerprint: "fingerprint", + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-07-26T00:00:00.000Z", + }); + await repo.trustedDevices.touch("user-1", "fingerprint"); + await repo.trustedDevices.deleteByUserAndFingerprint( + "user-1", + "fingerprint", + ); + + expect(writeCount).toBe(3); + }); +}); diff --git a/src/backend/database/repositories/trusted-device-repository.ts b/src/backend/database/repositories/trusted-device-repository.ts new file mode 100644 index 00000000..187096c0 --- /dev/null +++ b/src/backend/database/repositories/trusted-device-repository.ts @@ -0,0 +1,100 @@ +import { and, eq } from "drizzle-orm"; +import { trustedDevices } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type TrustedDeviceRecord = typeof trustedDevices.$inferSelect; +export type NewTrustedDeviceRecord = typeof trustedDevices.$inferInsert; + +export class TrustedDeviceRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findByUserAndFingerprint( + userId: string, + deviceFingerprint: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(trustedDevices) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async touch( + userId: string, + deviceFingerprint: string, + lastUsedAt = new Date().toISOString(), + ): Promise { + await this.context.drizzle + .update(trustedDevices) + .set({ lastUsedAt }) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ); + await this.afterWrite(); + } + + async upsert(device: NewTrustedDeviceRecord): Promise { + const existing = await this.findByUserAndFingerprint( + device.userId, + device.deviceFingerprint, + ); + + if (existing) { + await this.context.drizzle + .update(trustedDevices) + .set({ + expiresAt: device.expiresAt, + lastUsedAt: device.lastUsedAt, + }) + .where( + and( + eq(trustedDevices.userId, device.userId), + eq(trustedDevices.deviceFingerprint, device.deviceFingerprint), + ), + ); + } else { + await this.context.drizzle.insert(trustedDevices).values(device); + } + + await this.afterWrite(); + } + + async deleteByUserAndFingerprint( + userId: string, + deviceFingerprint: string, + ): Promise { + await this.context.drizzle + .delete(trustedDevices) + .where( + and( + eq(trustedDevices.userId, userId), + eq(trustedDevices.deviceFingerprint, deviceFingerprint), + ), + ); + await this.afterWrite(); + } + + async deleteByUserId(userId: string): Promise { + await this.context.drizzle + .delete(trustedDevices) + .where(eq(trustedDevices.userId, userId)); + await this.afterWrite(); + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/user-data-export-repository.test.ts b/src/backend/database/repositories/user-data-export-repository.test.ts new file mode 100644 index 00000000..046f6185 --- /dev/null +++ b/src/backend/database/repositories/user-data-export-repository.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { UserDataExportRepository } from "./user-data-export-repository.js"; + +describe("UserDataExportRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository(): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + connection_type TEXT NOT NULL DEFAULT 'ssh', + name TEXT, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + folder TEXT, + tags TEXT, + pin INTEGER NOT NULL DEFAULT 0, + auth_type TEXT NOT NULL, + use_warpgate INTEGER NOT NULL DEFAULT 0, + force_keyboard_interactive TEXT, + password TEXT, + key TEXT, + key_password TEXT, + key_type TEXT, + sudo_password TEXT, + autostart_password TEXT, + autostart_key TEXT, + autostart_key_password TEXT, + credential_id INTEGER, + override_credential_username INTEGER, + vault_profile_id INTEGER, + enable_terminal INTEGER NOT NULL DEFAULT 1, + enable_session_logging INTEGER NOT NULL DEFAULT 1, + enable_command_history INTEGER NOT NULL DEFAULT 1, + enable_tunnel INTEGER NOT NULL DEFAULT 1, + tunnel_connections TEXT, + jump_hosts TEXT, + enable_file_manager INTEGER NOT NULL DEFAULT 1, + scp_legacy INTEGER NOT NULL DEFAULT 0, + enable_docker INTEGER NOT NULL DEFAULT 0, + enable_tmux_monitor INTEGER NOT NULL DEFAULT 0, + show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, + show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, + show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, + default_path TEXT, + stats_config TEXT, + docker_config TEXT, + enable_proxmox INTEGER NOT NULL DEFAULT 0, + proxmox_config TEXT, + terminal_config TEXT, + quick_actions TEXT, + notes TEXT, + enable_ssh INTEGER NOT NULL DEFAULT 1, + enable_rdp INTEGER NOT NULL DEFAULT 0, + enable_vnc INTEGER NOT NULL DEFAULT 0, + enable_telnet INTEGER NOT NULL DEFAULT 0, + ssh_port INTEGER DEFAULT 22, + rdp_port INTEGER DEFAULT 3389, + vnc_port INTEGER DEFAULT 5900, + telnet_port INTEGER DEFAULT 23, + rdp_credential_id INTEGER, + rdp_user TEXT, + rdp_password TEXT, + rdp_domain TEXT, + rdp_security TEXT, + rdp_ignore_cert INTEGER DEFAULT 0, + vnc_credential_id INTEGER, + vnc_password TEXT, + vnc_user TEXT, + telnet_user TEXT, + telnet_password TEXT, + telnet_credential_id INTEGER, + rdp_auth_type TEXT, + vnc_auth_type TEXT, + telnet_auth_type TEXT, + domain TEXT, + security TEXT, + ignore_cert INTEGER DEFAULT 0, + guacamole_config TEXT, + use_socks5 INTEGER, + socks5_host TEXT, + socks5_port INTEGER, + socks5_username TEXT, + socks5_password TEXT, + socks5_proxy_chain TEXT, + mac_address TEXT, + wol_broadcast_address TEXT, + port_knock_sequence TEXT, + host_key_fingerprint TEXT, + host_key_type TEXT, + host_key_algorithm TEXT DEFAULT 'sha256', + host_key_first_seen TEXT, + host_key_last_verified TEXT, + host_key_changed_count INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE ssh_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + auth_type TEXT NOT NULL, + username TEXT, + password TEXT, + key TEXT, + private_key TEXT, + public_key TEXT, + key_password TEXT, + key_type TEXT, + detected_key_type TEXT, + cert_public_key TEXT, + system_password TEXT, + system_key TEXT, + system_key_password TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + + INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) + VALUES + (1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password'), + (2, 'user-2', 'db', '10.0.0.2', 22, 'root', 'password'); + + INSERT INTO ssh_credentials (id, user_id, name, auth_type, username, password) + VALUES + (1, 'user-1', 'prod', 'password', 'root', 'secret'), + (2, 'user-2', 'other', 'password', 'root', 'secret'); + `); + + return new UserDataExportRepository(context); + } + + it("lists only the current user's exportable hosts and credentials", async () => { + const repository = await createRepository(); + + expect(await repository.listHostsByUserId("user-1")).toMatchObject([ + { + id: 1, + userId: "user-1", + name: "web", + ip: "10.0.0.1", + }, + ]); + + expect(await repository.listCredentialsByUserId("user-1")).toMatchObject([ + { + id: 1, + userId: "user-1", + name: "prod", + username: "root", + }, + ]); + }); +}); diff --git a/src/backend/database/repositories/user-data-export-repository.ts b/src/backend/database/repositories/user-data-export-repository.ts new file mode 100644 index 00000000..ce3a3157 --- /dev/null +++ b/src/backend/database/repositories/user-data-export-repository.ts @@ -0,0 +1,26 @@ +import { eq } from "drizzle-orm"; +import { hosts, sshCredentials } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type UserDataExportHostRecord = typeof hosts.$inferSelect; +export type UserDataExportCredentialRecord = typeof sshCredentials.$inferSelect; + +export class UserDataExportRepository { + constructor(private readonly context: DatabaseContext) {} + + async listHostsByUserId(userId: string): Promise { + return this.context.drizzle + .select() + .from(hosts) + .where(eq(hosts.userId, userId)); + } + + async listCredentialsByUserId( + userId: string, + ): Promise { + return this.context.drizzle + .select() + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)); + } +} diff --git a/src/backend/database/repositories/user-preference-repository.test.ts b/src/backend/database/repositories/user-preference-repository.test.ts new file mode 100644 index 00000000..de3ede49 --- /dev/null +++ b/src/backend/database/repositories/user-preference-repository.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { UserPreferenceRepository } from "./user-preference-repository.js"; + +describe("UserPreferenceRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE user_preferences ( + user_id TEXT PRIMARY KEY, + reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0, + theme TEXT, + font_size TEXT, + accent_color TEXT, + language TEXT, + storage_mode TEXT, + command_autocomplete INTEGER, + command_palette_enabled INTEGER, + show_host_tags INTEGER, + host_tray_on_click INTEGER, + pin_app_rail INTEGER, + expand_app_rail_on_hover INTEGER, + folders_collapsed INTEGER, + confirm_snippet_execution INTEGER, + disable_update_check INTEGER, + confirm_tab_close INTEGER, + hidden_rail_tabs TEXT, + compact_host_view INTEGER, + status_color_scheme TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'); + `); + + return new UserPreferenceRepository(context, onWrite); + } + + it("finds, creates, and updates preferences by user id", async () => { + const repo = await createRepository(); + + expect(await repo.findByUserId("user-1")).toBeNull(); + + const created = await repo.upsert("user-1", { + reopenTabsOnLogin: true, + theme: "dark", + storageMode: "local", + commandAutocomplete: true, + }); + expect(created).toMatchObject({ + userId: "user-1", + reopenTabsOnLogin: true, + theme: "dark", + storageMode: "local", + commandAutocomplete: true, + }); + + const updated = await repo.upsert("user-1", { + theme: "light", + commandAutocomplete: false, + updatedAt: "2026-06-27T00:00:00.000Z", + }); + expect(updated).toMatchObject({ + userId: "user-1", + reopenTabsOnLogin: true, + theme: "light", + storageMode: "local", + commandAutocomplete: false, + }); + }); + + it("deletes preferences by user id only when rows exist", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsert("user-1", { theme: "dark" }); + expect(writeCount).toBe(1); + + expect(await repo.deleteByUserId("missing")).toBe(0); + expect(writeCount).toBe(1); + + expect(await repo.deleteByUserId("user-1")).toBe(1); + expect(writeCount).toBe(2); + expect(await repo.findByUserId("user-1")).toBeNull(); + }); +}); diff --git a/src/backend/database/repositories/user-preference-repository.ts b/src/backend/database/repositories/user-preference-repository.ts new file mode 100644 index 00000000..d96703da --- /dev/null +++ b/src/backend/database/repositories/user-preference-repository.ts @@ -0,0 +1,67 @@ +import { eq } from "drizzle-orm"; +import { userPreferences } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type UserPreferenceRecord = typeof userPreferences.$inferSelect; +export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert; +export type UserPreferenceUpdate = Partial< + Omit +>; + +export class UserPreferenceRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async findByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .limit(1); + + return rows[0] ?? null; + } + + async upsert( + userId: string, + update: UserPreferenceUpdate, + ): Promise { + const existing = await this.findByUserId(userId); + + if (!existing) { + const rows = await this.context.drizzle + .insert(userPreferences) + .values({ userId, ...update }) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + const rows = await this.context.drizzle + .update(userPreferences) + .set(update) + .where(eq(userPreferences.userId, userId)) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(userPreferences) + .where(eq(userPreferences.userId, userId)) + .returning({ userId: userPreferences.userId }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/user-repository.ts b/src/backend/database/repositories/user-repository.ts new file mode 100644 index 00000000..b36a4483 --- /dev/null +++ b/src/backend/database/repositories/user-repository.ts @@ -0,0 +1,161 @@ +import { eq, inArray } from "drizzle-orm"; +import { users } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type UserRecord = typeof users.$inferSelect; +export type NewUserRecord = typeof users.$inferInsert; +export type UserUpdate = Partial>; +export type NewFirstLocalUserRecord = Omit; + +export class UserRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listAll(): Promise { + return this.context.drizzle.select().from(users); + } + + async findById(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(users) + .where(eq(users.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async findByUsername(username: string): Promise { + const rows = await this.context.drizzle + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + + return rows[0] ?? null; + } + + async findByOidcIdentifier( + oidcIdentifier: string, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(users) + .where(eq(users.oidcIdentifier, oidcIdentifier)) + .limit(1); + + return rows[0] ?? null; + } + + async listByIds(ids: string[]): Promise { + const uniqueIds = Array.from(new Set(ids.filter(Boolean))); + if (uniqueIds.length === 0) { + return []; + } + + return this.context.drizzle + .select() + .from(users) + .where(inArray(users.id, uniqueIds)); + } + + async create(user: NewUserRecord): Promise { + const rows = await this.context.drizzle + .insert(users) + .values(user) + .returning(); + await this.afterWrite(); + return rows[0]; + } + + async createFirstLocalUser( + user: NewFirstLocalUserRecord, + ): Promise<{ user: UserRecord; isFirstUser: boolean }> { + const result = this.context.drizzle.transaction((tx) => { + const existingUsers = tx.select({ id: users.id }).from(users).all(); + const isFirstUser = existingUsers.length === 0; + const rows = tx + .insert(users) + .values({ ...user, isAdmin: isFirstUser }) + .returning() + .all(); + + return { user: rows[0], isFirstUser }; + }); + + await this.afterWrite(); + return result; + } + + async createFirstSsoUser( + user: NewUserRecord, + ): Promise<{ user: UserRecord; isFirstUser: boolean }> { + const result = this.context.drizzle.transaction((tx) => { + const existingUsers = tx.select({ id: users.id }).from(users).all(); + const isFirstUser = existingUsers.length === 0; + const rows = tx + .insert(users) + .values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) }) + .returning() + .all(); + + return { user: rows[0], isFirstUser }; + }); + + await this.afterWrite(); + return result; + } + + async update(id: string, update: UserUpdate): Promise { + const rows = await this.context.drizzle + .update(users) + .set(update) + .where(eq(users.id, id)) + .returning(); + + await this.afterWrite(); + return rows[0] ?? null; + } + + async delete(id: string): Promise { + const rows = await this.context.drizzle + .delete(users) + .where(eq(users.id, id)) + .returning({ id: users.id }); + + await this.afterWrite(); + return rows.length > 0; + } + + async countAdmins(): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users) + .where(eq(users.isAdmin, true)); + + return rows.length; + } + + async countTotpEnabled(): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users) + .where(eq(users.totpEnabled, true)); + + return rows.length; + } + + async countAll(): Promise { + const rows = await this.context.drizzle + .select({ id: users.id }) + .from(users); + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/user-session-repositories.test.ts b/src/backend/database/repositories/user-session-repositories.test.ts new file mode 100644 index 00000000..a90a8af6 --- /dev/null +++ b/src/backend/database/repositories/user-session-repositories.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { SessionRepository } from "./session-repository.js"; +import { UserRepository } from "./user-repository.js"; + +describe("UserRepository and SessionRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepositories(): Promise<{ + users: UserRepository; + sessions: SessionRepository; + }>; + async function createRepositories(options: { + onUserWrite?: () => void | Promise; + onSessionWrite?: () => void | Promise; + }): Promise<{ + users: UserRepository; + sessions: SessionRepository; + }>; + async function createRepositories( + options: { + onUserWrite?: () => void | Promise; + onSessionWrite?: () => void | Promise; + } = {}, + ): Promise<{ + users: UserRepository; + sessions: SessionRepository; + }> { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_oidc INTEGER NOT NULL DEFAULT 0, + oidc_identifier TEXT, + sso_provider_id INTEGER, + client_id TEXT, + client_secret TEXT, + issuer_url TEXT, + authorization_url TEXT, + token_url TEXT, + identifier_path TEXT, + name_path TEXT, + scopes TEXT DEFAULT 'openid email profile', + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + totp_backup_codes TEXT + ); + + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + jwt_token TEXT NOT NULL, + device_type TEXT NOT NULL, + device_info TEXT NOT NULL, + oidc_sub TEXT, + oidc_sid TEXT, + sso_provider_id INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); + + return { + users: new UserRepository(context, options.onUserWrite), + sessions: new SessionRepository(context, options.onSessionWrite), + }; + } + + it("creates, finds, updates, and deletes users", async () => { + const repo = await createRepositories(); + + await repo.users.create({ + id: "user-1", + username: "admin", + passwordHash: "hash", + isAdmin: true, + isOidc: false, + }); + + expect(await repo.users.countAdmins()).toBe(1); + expect(await repo.users.countTotpEnabled()).toBe(0); + expect((await repo.users.listAll()).map((user) => user.id)).toEqual([ + "user-1", + ]); + expect((await repo.users.findByUsername("admin"))?.id).toBe("user-1"); + expect( + (await repo.users.listByIds(["user-1", "user-1"])).map((u) => u.id), + ).toEqual(["user-1"]); + expect(await repo.users.listByIds([])).toEqual([]); + + const updated = await repo.users.update("user-1", { + oidcIdentifier: "oidc:admin", + totpEnabled: true, + }); + expect(updated?.oidcIdentifier).toBe("oidc:admin"); + expect(await repo.users.countTotpEnabled()).toBe(1); + expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe( + "user-1", + ); + + expect(await repo.users.delete("user-1")).toBe(true); + expect(await repo.users.findById("user-1")).toBeNull(); + }); + + it("creates the first local user as admin inside the repository", async () => { + const repo = await createRepositories(); + + const first = await repo.users.createFirstLocalUser({ + id: "user-1", + username: "first", + passwordHash: "hash", + isOidc: false, + }); + const second = await repo.users.createFirstLocalUser({ + id: "user-2", + username: "second", + passwordHash: "hash", + isOidc: false, + }); + + expect(first.isFirstUser).toBe(true); + expect(first.user.isAdmin).toBe(true); + expect(second.isFirstUser).toBe(false); + expect(second.user.isAdmin).toBe(false); + expect(await repo.users.countAll()).toBe(2); + }); + + it("creates SSO users with first-user and provider admin semantics", async () => { + const repo = await createRepositories(); + + const first = await repo.users.createFirstSsoUser({ + id: "user-1", + username: "first", + passwordHash: "", + isAdmin: false, + isOidc: true, + oidcIdentifier: "ldap:provider:first", + ssoProviderId: 1, + }); + const providerAdmin = await repo.users.createFirstSsoUser({ + id: "user-2", + username: "provider-admin", + passwordHash: "", + isAdmin: true, + isOidc: true, + oidcIdentifier: "ldap:provider:admin", + ssoProviderId: 1, + }); + const regular = await repo.users.createFirstSsoUser({ + id: "user-3", + username: "regular", + passwordHash: "", + isAdmin: false, + isOidc: true, + oidcIdentifier: "ldap:provider:regular", + ssoProviderId: 1, + }); + + expect(first.isFirstUser).toBe(true); + expect(first.user.isAdmin).toBe(true); + expect(providerAdmin.isFirstUser).toBe(false); + expect(providerAdmin.user.isAdmin).toBe(true); + expect(regular.isFirstUser).toBe(false); + expect(regular.user.isAdmin).toBe(false); + expect(await repo.users.countAll()).toBe(3); + }); + + it("runs the user write hook after user writes", async () => { + let writeCount = 0; + const repo = await createRepositories({ + onUserWrite: () => { + writeCount += 1; + }, + }); + + await repo.users.create({ + id: "user-1", + username: "admin", + passwordHash: "hash", + isAdmin: true, + isOidc: false, + }); + await repo.users.update("user-1", { isAdmin: false }); + await repo.users.delete("user-1"); + + expect(writeCount).toBe(3); + }); + + it("creates, touches, lists, and revokes sessions", async () => { + const repo = await createRepositories(); + await repo.users.create({ + id: "user-1", + username: "user", + passwordHash: "hash", + isAdmin: false, + isOidc: false, + }); + + await repo.sessions.create({ + id: "session-1", + userId: "user-1", + jwtToken: "token", + deviceType: "desktop", + deviceInfo: "Firefox", + createdAt: "2026-06-26T00:00:00.000Z", + expiresAt: "2026-06-27T00:00:00.000Z", + lastActiveAt: "2026-06-26T00:00:00.000Z", + }); + + await repo.sessions.touch("session-1", "2026-06-26T01:00:00.000Z"); + + expect((await repo.sessions.findById("session-1"))?.lastActiveAt).toBe( + "2026-06-26T01:00:00.000Z", + ); + expect(await repo.sessions.listByUserId("user-1")).toHaveLength(1); + expect(await repo.sessions.revoke("session-1")).toBe(true); + expect(await repo.sessions.findById("session-1")).toBeNull(); + }); + + it("revokes all user sessions except an optional current session", async () => { + const repo = await createRepositories(); + await repo.users.create({ + id: "user-1", + username: "user", + passwordHash: "hash", + isAdmin: false, + isOidc: false, + }); + + for (const id of ["keep", "drop-1", "drop-2"]) { + await repo.sessions.create({ + id, + userId: "user-1", + jwtToken: `${id}-token`, + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-06-27T00:00:00.000Z", + }); + } + + expect(await repo.sessions.revokeAllForUser("user-1", "keep")).toBe(2); + expect( + (await repo.sessions.listByUserId("user-1")).map((s) => s.id), + ).toEqual(["keep"]); + }); + + it("deletes expired sessions", async () => { + const repo = await createRepositories(); + await repo.users.create({ + id: "user-1", + username: "user", + passwordHash: "hash", + isAdmin: false, + isOidc: false, + }); + + await repo.sessions.create({ + id: "expired", + userId: "user-1", + jwtToken: "expired-token", + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-06-25T00:00:00.000Z", + }); + await repo.sessions.create({ + id: "active", + userId: "user-1", + jwtToken: "active-token", + deviceType: "desktop", + deviceInfo: "Firefox", + expiresAt: "2026-06-27T00:00:00.000Z", + }); + + expect( + await repo.sessions.deleteExpired(new Date("2026-06-26T00:00:00.000Z")), + ).toBe(1); + expect( + (await repo.sessions.listByUserId("user-1")).map((s) => s.id), + ).toEqual(["active"]); + }); +}); diff --git a/src/backend/database/repositories/vault-profile-repository.test.ts b/src/backend/database/repositories/vault-profile-repository.test.ts new file mode 100644 index 00000000..027d87c6 --- /dev/null +++ b/src/backend/database/repositories/vault-profile-repository.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { VaultProfileRepository } from "./vault-profile-repository.js"; + +describe("VaultProfileRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE vault_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + vault_addr TEXT NOT NULL, + vault_namespace TEXT, + oidc_mount TEXT, + oidc_role TEXT, + ssh_mount TEXT, + ssh_role TEXT NOT NULL, + valid_principals TEXT, + key_type TEXT, + shared INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO vault_profiles ( + id, user_id, name, vault_addr, ssh_role, shared, updated_at + ) + VALUES + (1, 'user-1', 'owned', 'https://vault.one', 'role-one', 0, '2026-01-01T00:00:00.000Z'), + (2, 'user-2', 'shared', 'https://vault.two', 'role-two', 1, '2026-01-02T00:00:00.000Z'), + (3, 'user-2', 'hidden', 'https://vault.three', 'role-three', 0, '2026-01-03T00:00:00.000Z'); + `); + + return new VaultProfileRepository(context, onWrite); + } + + it("lists profiles owned by or shared with the user", async () => { + const repo = await createRepository(); + + const rows = await repo.listVisibleToUser("user-1"); + + expect(rows.map((row) => row.id)).toEqual([2, 1]); + }); + + it("creates and reads a profile", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const created = await repo.create({ + userId: "user-1", + name: "new", + description: "desc", + folder: "folder", + tags: "prod,ssh", + vaultAddr: "https://vault.new", + vaultNamespace: "ns", + oidcMount: "oidc", + oidcRole: "oidc-role", + sshMount: "ssh", + sshRole: "ssh-role", + validPrincipals: "root", + keyType: "ed25519", + shared: true, + }); + + const found = await repo.findById(created.id); + expect(found).toMatchObject({ + userId: "user-1", + name: "new", + vaultAddr: "https://vault.new", + sshRole: "ssh-role", + shared: true, + }); + expect(writeCount).toBe(1); + }); + + it("updates and deletes profiles", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const updated = await repo.updateById(1, { + name: "renamed", + shared: true, + updatedAt: "2026-02-01T00:00:00.000Z", + }); + expect(updated).toMatchObject({ + id: 1, + name: "renamed", + shared: true, + updatedAt: "2026-02-01T00:00:00.000Z", + }); + + expect(await repo.updateById(999, { name: "missing" })).toBeNull(); + expect(await repo.deleteById(1)).toBe(true); + expect(await repo.deleteById(1)).toBe(false); + expect(await repo.findById(1)).toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes all profiles for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await expect(repo.deleteByUserId("user-2")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findById(2)).toBeNull(); + expect(await repo.findById(3)).toBeNull(); + expect((await repo.findById(1))?.userId).toBe("user-1"); + expect(writeCount).toBe(1); + }); +}); diff --git a/src/backend/database/repositories/vault-profile-repository.ts b/src/backend/database/repositories/vault-profile-repository.ts new file mode 100644 index 00000000..cf063cbe --- /dev/null +++ b/src/backend/database/repositories/vault-profile-repository.ts @@ -0,0 +1,130 @@ +import { desc, eq, or } from "drizzle-orm"; +import { vaultProfiles } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type VaultProfileRecord = typeof vaultProfiles.$inferSelect; + +export interface VaultProfileCreateInput { + userId: string; + name: string; + description?: string | null; + folder?: string | null; + tags?: string | null; + vaultAddr: string; + vaultNamespace?: string | null; + oidcMount?: string | null; + oidcRole?: string | null; + sshMount?: string | null; + sshRole: string; + validPrincipals?: string | null; + keyType?: string | null; + shared?: boolean; +} + +export type VaultProfileUpdateInput = Partial< + Omit +> & { + updatedAt?: string; +}; + +export class VaultProfileRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async listVisibleToUser(userId: string): Promise { + return this.context.drizzle + .select() + .from(vaultProfiles) + .where( + or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)), + ) + .orderBy(desc(vaultProfiles.updatedAt)); + } + + async create(input: VaultProfileCreateInput): Promise { + const [created] = await this.context.drizzle + .insert(vaultProfiles) + .values({ + userId: input.userId, + name: input.name, + description: input.description, + folder: input.folder, + tags: input.tags, + vaultAddr: input.vaultAddr, + vaultNamespace: input.vaultNamespace, + oidcMount: input.oidcMount, + oidcRole: input.oidcRole, + sshMount: input.sshMount, + sshRole: input.sshRole, + validPrincipals: input.validPrincipals, + keyType: input.keyType, + shared: input.shared ?? false, + }) + .returning(); + + await this.afterWrite(); + return created; + } + + async findById(id: number): Promise { + const rows = await this.context.drizzle + .select() + .from(vaultProfiles) + .where(eq(vaultProfiles.id, id)) + .limit(1); + + return rows[0] ?? null; + } + + async updateById( + id: number, + input: VaultProfileUpdateInput, + ): Promise { + const [updated] = await this.context.drizzle + .update(vaultProfiles) + .set({ + ...input, + updatedAt: input.updatedAt ?? new Date().toISOString(), + }) + .where(eq(vaultProfiles.id, id)) + .returning(); + + if (updated) { + await this.afterWrite(); + } + + return updated ?? null; + } + + async deleteById(id: number): Promise { + const rows = await this.context.drizzle + .delete(vaultProfiles) + .where(eq(vaultProfiles.id, id)) + .returning({ id: vaultProfiles.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(vaultProfiles) + .where(eq(vaultProfiles.userId, userId)) + .returning({ id: vaultProfiles.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/repositories/vault-token-repository.test.ts b/src/backend/database/repositories/vault-token-repository.test.ts new file mode 100644 index 00000000..ce9c01ce --- /dev/null +++ b/src/backend/database/repositories/vault-token-repository.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js"; +import { VaultTokenRepository } from "./vault-token-repository.js"; + +describe("VaultTokenRepository", () => { + let adapter: SqliteDatabaseAdapter | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE vault_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL + ); + + CREATE TABLE vault_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + profile_id INTEGER NOT NULL, + ssh_cert TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used TEXT, + UNIQUE(user_id, profile_id) + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash'); + INSERT INTO vault_profiles (id, user_id, name) + VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two'); + INSERT INTO vault_tokens ( + user_id, profile_id, ssh_cert, private_key, expires_at + ) + VALUES + ('user-1', 1, 'cert-1', 'key-1', '2099-01-01T00:00:00.000Z'), + ('user-2', 2, 'cert-2', 'key-2', '2099-01-01T00:00:00.000Z'); + `); + + return new VaultTokenRepository(context, onWrite); + } + + it("finds and upserts a token by user and profile", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + const existing = await repo.findByUserAndProfile("user-1", 1); + expect(existing?.sshCert).toBe("cert-1"); + + await repo.upsert({ + userId: "user-1", + profileId: 1, + sshCert: "new-cert", + privateKey: "new-key", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + const updated = await repo.findByUserAndProfile("user-1", 1); + expect(updated).toMatchObject({ + sshCert: "new-cert", + privateKey: "new-key", + expiresAt: "2099-02-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(writeCount).toBe(1); + }); + + it("updates last-used and deletes one token", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + expect( + await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"), + ).toBe(true); + expect(await repo.updateLastUsed("missing", 1)).toBe(false); + expect((await repo.findByUserAndProfile("user-1", 1))?.lastUsed).toBe( + "2026-01-02T00:00:00.000Z", + ); + + expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(true); + expect(await repo.deleteByUserAndProfile("user-1", 1)).toBe(false); + expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull(); + expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull(); + expect(writeCount).toBe(2); + }); + + it("deletes all tokens for a user", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.upsert({ + userId: "user-1", + profileId: 2, + sshCert: "cert-extra", + privateKey: "key-extra", + expiresAt: "2099-03-01T00:00:00.000Z", + }); + + await expect(repo.deleteByUserId("user-1")).resolves.toBe(2); + await expect(repo.deleteByUserId("missing")).resolves.toBe(0); + + expect(await repo.findByUserAndProfile("user-1", 1)).toBeNull(); + expect(await repo.findByUserAndProfile("user-1", 2)).toBeNull(); + expect(await repo.findByUserAndProfile("user-2", 2)).not.toBeNull(); + expect(writeCount).toBe(2); + }); +}); diff --git a/src/backend/database/repositories/vault-token-repository.ts b/src/backend/database/repositories/vault-token-repository.ts new file mode 100644 index 00000000..a40d67f2 --- /dev/null +++ b/src/backend/database/repositories/vault-token-repository.ts @@ -0,0 +1,125 @@ +import { and, eq } from "drizzle-orm"; +import { vaultTokens } from "../db/schema.js"; +import type { DatabaseContext } from "../runtime/adapter.js"; + +export type VaultTokenRecord = typeof vaultTokens.$inferSelect; + +export interface VaultTokenUpsertInput { + userId: string; + profileId: number; + sshCert: string; + privateKey: string; + expiresAt: string; + createdAt?: string; +} + +export class VaultTokenRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async upsert(input: VaultTokenUpsertInput): Promise { + const createdAt = input.createdAt ?? new Date().toISOString(); + + await this.context.drizzle + .insert(vaultTokens) + .values({ + userId: input.userId, + profileId: input.profileId, + sshCert: input.sshCert, + privateKey: input.privateKey, + expiresAt: input.expiresAt, + }) + .onConflictDoUpdate({ + target: [vaultTokens.userId, vaultTokens.profileId], + set: { + sshCert: input.sshCert, + privateKey: input.privateKey, + expiresAt: input.expiresAt, + createdAt, + }, + }); + + await this.afterWrite(); + } + + async findByUserAndProfile( + userId: string, + profileId: number, + ): Promise { + const rows = await this.context.drizzle + .select() + .from(vaultTokens) + .where( + and( + eq(vaultTokens.userId, userId), + eq(vaultTokens.profileId, profileId), + ), + ) + .limit(1); + + return rows[0] ?? null; + } + + async updateLastUsed( + userId: string, + profileId: number, + lastUsed = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .update(vaultTokens) + .set({ lastUsed }) + .where( + and( + eq(vaultTokens.userId, userId), + eq(vaultTokens.profileId, profileId), + ), + ) + .returning({ id: vaultTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserAndProfile( + userId: string, + profileId: number, + ): Promise { + const rows = await this.context.drizzle + .delete(vaultTokens) + .where( + and( + eq(vaultTokens.userId, userId), + eq(vaultTokens.profileId, profileId), + ), + ) + .returning({ id: vaultTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length > 0; + } + + async deleteByUserId(userId: string): Promise { + const rows = await this.context.drizzle + .delete(vaultTokens) + .where(eq(vaultTokens.userId, userId)) + .returning({ id: vaultTokens.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts index 878c0770..d402877f 100644 --- a/src/backend/database/routes/acme-ssl-routes.ts +++ b/src/backend/database/routes/acme-ssl-routes.ts @@ -3,11 +3,11 @@ import { promises as fs } from "fs"; import path from "path"; import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; +import type { UserRecord } from "../repositories/user-repository.js"; const DATA_DIR = process.env.DATA_DIR || "./db/data"; const SSL_DIR = path.join(DATA_DIR, "ssl"); @@ -33,6 +33,14 @@ export type AcmeSettings = { certExpiresAt: string | null; }; +async function getAdminActor( + userId: string | undefined, +): Promise { + if (!userId) return null; + const user = await createCurrentUserRepository().findById(userId); + return user?.isAdmin ? user : null; +} + function getCertInfo(): { status: "none" | "valid" | "expiring" | "expired"; expiresAt: string | null; @@ -86,13 +94,11 @@ function getCertInfo(): { } } -function getAcmeSettingsFromDb(): AcmeSettings { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") - .get() as { value: string } | undefined; - +async function getAcmeSettings(): Promise { const { status, expiresAt } = getCertInfo(); - const stored = row ? JSON.parse(row.value) : {}; + const value = + await createCurrentSettingsRepository().get("acme_ssl_settings"); + const stored = value ? JSON.parse(value) : {}; return { enabled: stored.enabled ?? false, @@ -128,7 +134,7 @@ export function registerAcmeSSLRoutes( */ router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => { try { - res.json(getAcmeSettingsFromDb()); + res.json(await getAcmeSettings()); } catch (err) { authLogger.error("Failed to get ACME SSL settings", err); res.status(500).json({ error: "Failed to get ACME SSL settings" }); @@ -172,15 +178,14 @@ export function registerAcmeSSLRoutes( router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } - const existing = db.$client - .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") - .get() as { value: string } | undefined; - const current = existing ? JSON.parse(existing.value) : {}; + const settingsRepository = createCurrentSettingsRepository(); + const existing = await settingsRepository.get("acme_ssl_settings"); + const current = existing ? JSON.parse(existing) : {}; const { enabled, domain, email, challengeType, cloudflareToken } = req.body; @@ -196,21 +201,15 @@ export function registerAcmeSSLRoutes( !cloudflareToken.includes("*") && { cloudflareToken }), }; - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)", - ) - .run(JSON.stringify(updated)); + await settingsRepository.set( + "acme_ssl_settings", + JSON.stringify(updated), + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_acme_ssl_settings", resourceType: "setting", details: JSON.stringify({ @@ -225,7 +224,7 @@ export function registerAcmeSSLRoutes( success: true, }); - res.json(getAcmeSettingsFromDb()); + res.json(await getAcmeSettings()); } catch (err) { authLogger.error("Failed to update ACME SSL settings", err); res.status(500).json({ error: "Failed to update ACME SSL settings" }); @@ -252,21 +251,20 @@ export function registerAcmeSSLRoutes( */ router.post("/acme-ssl-request", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; + const actor = await getAdminActor(userId); try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'") - .get() as { value: string } | undefined; + const settingsValue = + await createCurrentSettingsRepository().get("acme_ssl_settings"); - if (!row) { + if (!settingsValue) { return res.status(400).json({ error: "ACME settings not configured" }); } - const settings = JSON.parse(row.value); + const settings = JSON.parse(settingsValue); const { domain, email, challengeType, cloudflareToken } = settings; if (!domain || !email) { @@ -372,11 +370,10 @@ export function registerAcmeSSLRoutes( await fs.chmod(certDest, 0o644); const updated = { ...settings, lastIssuedAt: new Date().toISOString() }; - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)", - ) - .run(JSON.stringify(updated)); + await createCurrentSettingsRepository().set( + "acme_ssl_settings", + JSON.stringify(updated), + ); authLogger.info("Let's Encrypt certificate issued and installed", { domain, @@ -384,14 +381,9 @@ export function registerAcmeSSLRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "acme_ssl_request", resourceType: "setting", details: JSON.stringify({ domain, challengeType, success: true }), @@ -400,20 +392,15 @@ export function registerAcmeSSLRoutes( success: true, }); - res.json({ success: true, ...getAcmeSettingsFromDb() }); + res.json({ success: true, ...(await getAcmeSettings()) }); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; authLogger.error("ACME certificate request failed", err); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor?.username ?? userId, action: "acme_ssl_request", resourceType: "setting", details: JSON.stringify({ error: message }), diff --git a/src/backend/database/routes/alert-rules-routes.ts b/src/backend/database/routes/alert-rules-routes.ts index 10b8d7e0..6bfccf90 100644 --- a/src/backend/database/routes/alert-rules-routes.ts +++ b/src/backend/database/routes/alert-rules-routes.ts @@ -1,8 +1,7 @@ import express, { type Request, type Response } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import { getDb } from "../db/index.js"; +import { createCurrentAlertRepository } from "../repositories/current-alert-repository.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { DatabaseSaveTrigger } from "../db/index.js"; import { databaseLogger } from "../../utils/logger.js"; import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js"; @@ -36,14 +35,11 @@ router.use(authenticateJWT); * 200: * description: List of notification channels. */ -router.get("/notification-channels", (req, res) => { +router.get("/notification-channels", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = getDb() - .$client.prepare( - "SELECT * FROM notification_channels WHERE user_id = ? ORDER BY id ASC", - ) - .all(userId); + const rows = + await createCurrentAlertRepository().listNotificationChannels(userId); res.json(rows); } catch (err) { databaseLogger.error("Failed to list notification channels", { @@ -62,7 +58,7 @@ router.get("/notification-channels", (req, res) => { * tags: * - Alerts */ -router.post("/notification-channels", (req, res) => { +router.post("/notification-channels", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { name, type, config, enabled } = req.body as { name: string; @@ -94,22 +90,13 @@ router.post("/notification-channels", (req, res) => { } try { - const result = getDb() - .$client.prepare( - `INSERT INTO notification_channels (user_id, name, type, config, enabled) - VALUES (?, ?, ?, ?, ?)`, - ) - .run( - userId, - name.trim(), - type, - JSON.stringify(config), - enabled !== false ? 1 : 0, - ); - const row = getDb() - .$client.prepare("SELECT * FROM notification_channels WHERE id = ?") - .get(result.lastInsertRowid); - DatabaseSaveTrigger.triggerSave("notification_channel_created"); + const row = await createCurrentAlertRepository().createNotificationChannel({ + userId, + name: name.trim(), + type, + config: JSON.stringify(config), + enabled: enabled !== false, + }); res.status(201).json(row); } catch (err) { databaseLogger.error("Failed to create notification channel", { @@ -128,69 +115,61 @@ router.post("/notification-channels", (req, res) => { * tags: * - Alerts */ -router.put("/notification-channels/:id", (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const channelId = Number(req.params.id); - const { name, type, config, enabled } = req.body as { - name?: string; - type?: string; - config?: unknown; - enabled?: boolean; - }; +router.put( + "/notification-channels/:id", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const channelId = Number(req.params.id); + const { name, type, config, enabled } = req.body as { + name?: string; + type?: string; + config?: unknown; + enabled?: boolean; + }; - const existing = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!existing) return res.status(404).json({ error: "Channel not found" }); + const repository = createCurrentAlertRepository(); + const existing = await repository.findNotificationChannelForUser( + channelId, + userId, + ); + if (!existing) return res.status(404).json({ error: "Channel not found" }); - if (type && type !== "webhook" && type !== "ntfy") { - return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" }); - } - - try { - const updates: string[] = []; - const params: unknown[] = []; - if (name !== undefined) { - updates.push("name = ?"); - params.push(name.trim()); + if (type && type !== "webhook" && type !== "ntfy") { + return res + .status(400) + .json({ error: "type must be 'webhook' or 'ntfy'" }); } - if (type !== undefined) { - updates.push("type = ?"); - params.push(type); - } - if (config !== undefined) { - updates.push("config = ?"); - params.push(JSON.stringify(config)); - } - if (enabled !== undefined) { - updates.push("enabled = ?"); - params.push(enabled ? 1 : 0); + if ( + name === undefined && + type === undefined && + config === undefined && + enabled === undefined + ) { + return res.json({ success: true }); } - if (updates.length === 0) return res.json({ success: true }); - - params.push(channelId, userId); - getDb() - .$client.prepare( - `UPDATE notification_channels SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, - ) - .run(...params); - - const row = getDb() - .$client.prepare("SELECT * FROM notification_channels WHERE id = ?") - .get(channelId); - DatabaseSaveTrigger.triggerSave("notification_channel_updated"); - res.json(row); - } catch (err) { - databaseLogger.error("Failed to update notification channel", { - operation: "update_channel", - error: err, - }); - res.status(500).json({ error: "Failed to update channel" }); - } -}); + try { + const row = await repository.updateNotificationChannel( + channelId, + userId, + { + ...(name !== undefined ? { name: name.trim() } : {}), + ...(type !== undefined ? { type } : {}), + ...(config !== undefined ? { config: JSON.stringify(config) } : {}), + ...(enabled !== undefined ? { enabled } : {}), + }, + ); + if (!row) return res.status(404).json({ error: "Channel not found" }); + res.json(row); + } catch (err) { + databaseLogger.error("Failed to update notification channel", { + operation: "update_channel", + error: err, + }); + res.status(500).json({ error: "Failed to update channel" }); + } + }, +); /** * @openapi @@ -200,21 +179,20 @@ router.put("/notification-channels/:id", (req: Request, res: Response) => { * tags: * - Alerts */ -router.delete("/notification-channels/:id", (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const channelId = Number(req.params.id); - const existing = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!existing) return res.status(404).json({ error: "Channel not found" }); - getDb() - .$client.prepare("DELETE FROM notification_channels WHERE id = ?") - .run(channelId); - DatabaseSaveTrigger.triggerSave("notification_channel_deleted"); - res.json({ success: true }); -}); +router.delete( + "/notification-channels/:id", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const channelId = Number(req.params.id); + const deleted = + await createCurrentAlertRepository().deleteNotificationChannel( + channelId, + userId, + ); + if (!deleted) return res.status(404).json({ error: "Channel not found" }); + res.json({ success: true }); + }, +); /** * @openapi @@ -229,11 +207,11 @@ router.post( async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const channelId = Number(req.params.id); - const row = getDb() - .$client.prepare( - "SELECT * FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId) as { type: string; config: string } | undefined; + const row = + await createCurrentAlertRepository().findNotificationChannelForUser( + channelId, + userId, + ); if (!row) return res.status(404).json({ error: "Channel not found" }); const testPayload = { @@ -288,33 +266,10 @@ router.post( * tags: * - Alerts */ -router.get("/alert-rules", (req, res) => { +router.get("/alert-rules", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const rules = getDb() - .$client.prepare( - "SELECT * FROM alert_rules WHERE user_id = ? ORDER BY id ASC", - ) - .all(userId) as Array<{ id: number }>; - - const channelMap = new Map(); - for (const rule of rules) { - const channels = getDb() - .$client.prepare( - "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?", - ) - .all(rule.id) as Array<{ channel_id: number }>; - channelMap.set( - rule.id, - channels.map((c) => c.channel_id), - ); - } - - const result = rules.map((r) => ({ - ...r, - channels: channelMap.get(r.id) ?? [], - })); - res.json(result); + res.json(await createCurrentAlertRepository().listAlertRules(userId)); } catch (err) { databaseLogger.error("Failed to list alert rules", { operation: "list_alert_rules", @@ -332,7 +287,7 @@ router.get("/alert-rules", (req, res) => { * tags: * - Alerts */ -router.post("/alert-rules", (req, res) => { +router.post("/alert-rules", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { name, @@ -373,45 +328,18 @@ router.post("/alert-rules", (req, res) => { try { const now = new Date().toISOString(); - const result = getDb() - .$client.prepare( - `INSERT INTO alert_rules - (user_id, host_id, name, enabled, trigger_type, threshold_value, - threshold_duration_seconds, cooldown_minutes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - userId, - hostId ?? null, - name.trim(), - enabled !== false ? 1 : 0, - triggerType, - thresholdValue ?? null, - thresholdDurationSeconds ?? null, - cooldownMinutes ?? 15, - now, - now, - ); - const ruleId = result.lastInsertRowid as number; - - for (const channelId of channels) { - const owned = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!owned) continue; - getDb() - .$client.prepare( - "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)", - ) - .run(ruleId, channelId); - } - - const row = getDb() - .$client.prepare("SELECT * FROM alert_rules WHERE id = ?") - .get(ruleId) as Record; - DatabaseSaveTrigger.triggerSave("alert_rule_created"); + const row = await createCurrentAlertRepository().createAlertRule({ + userId, + hostId: hostId ?? null, + name: name.trim(), + enabled: enabled !== false, + triggerType, + thresholdValue: thresholdValue ?? null, + thresholdDurationSeconds: thresholdDurationSeconds ?? null, + cooldownMinutes: cooldownMinutes ?? 15, + channels, + now, + }); res.status(201).json({ ...row, channels }); } catch (err) { databaseLogger.error("Failed to create alert rule", { @@ -430,13 +358,12 @@ router.post("/alert-rules", (req, res) => { * tags: * - Alerts */ -router.put("/alert-rules/:id", (req: Request, res: Response) => { +router.put("/alert-rules/:id", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const ruleId = Number(req.params.id); - const existing = getDb() - .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?") - .get(ruleId, userId); + const repository = createCurrentAlertRepository(); + const existing = await repository.findAlertRuleForUser(ruleId, userId); if (!existing) return res.status(404).json({ error: "Alert rule not found" }); const { @@ -464,76 +391,23 @@ router.put("/alert-rules/:id", (req: Request, res: Response) => { } try { - const updates: string[] = ["updated_at = ?"]; - const params: unknown[] = [new Date().toISOString()]; - if (name !== undefined) { - updates.push("name = ?"); - params.push(name.trim()); - } - if (hostId !== undefined) { - updates.push("host_id = ?"); - params.push(hostId ?? null); - } - if (enabled !== undefined) { - updates.push("enabled = ?"); - params.push(enabled ? 1 : 0); - } - if (triggerType !== undefined) { - updates.push("trigger_type = ?"); - params.push(triggerType); - } - if (thresholdValue !== undefined) { - updates.push("threshold_value = ?"); - params.push(thresholdValue ?? null); - } - if (thresholdDurationSeconds !== undefined) { - updates.push("threshold_duration_seconds = ?"); - params.push(thresholdDurationSeconds ?? null); - } - if (cooldownMinutes !== undefined) { - updates.push("cooldown_minutes = ?"); - params.push(cooldownMinutes); - } - params.push(ruleId, userId); - - getDb() - .$client.prepare( - `UPDATE alert_rules SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, - ) - .run(...params); - - if (channels !== undefined) { - getDb() - .$client.prepare("DELETE FROM alert_rule_channels WHERE rule_id = ?") - .run(ruleId); - for (const channelId of channels) { - const owned = getDb() - .$client.prepare( - "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", - ) - .get(channelId, userId); - if (!owned) continue; - getDb() - .$client.prepare( - "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)", - ) - .run(ruleId, channelId); - } - } - - const row = getDb() - .$client.prepare("SELECT * FROM alert_rules WHERE id = ?") - .get(ruleId) as Record; - const linkedChannels = ( - getDb() - .$client.prepare( - "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?", - ) - .all(ruleId) as Array<{ channel_id: number }> - ).map((c) => c.channel_id); - - DatabaseSaveTrigger.triggerSave("alert_rule_updated"); - res.json({ ...row, channels: linkedChannels }); + const row = await repository.updateAlertRule(ruleId, userId, { + ...(name !== undefined ? { name: name.trim() } : {}), + ...(hostId !== undefined ? { hostId: hostId ?? null } : {}), + ...(enabled !== undefined ? { enabled } : {}), + ...(triggerType !== undefined ? { triggerType } : {}), + ...(thresholdValue !== undefined + ? { thresholdValue: thresholdValue ?? null } + : {}), + ...(thresholdDurationSeconds !== undefined + ? { thresholdDurationSeconds: thresholdDurationSeconds ?? null } + : {}), + ...(cooldownMinutes !== undefined ? { cooldownMinutes } : {}), + ...(channels !== undefined ? { channels } : {}), + now: new Date().toISOString(), + }); + if (!row) return res.status(404).json({ error: "Alert rule not found" }); + res.json(row); } catch (err) { databaseLogger.error("Failed to update alert rule", { operation: "update_alert_rule", @@ -551,15 +425,14 @@ router.put("/alert-rules/:id", (req: Request, res: Response) => { * tags: * - Alerts */ -router.delete("/alert-rules/:id", (req: Request, res: Response) => { +router.delete("/alert-rules/:id", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const ruleId = Number(req.params.id); - const existing = getDb() - .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?") - .get(ruleId, userId); - if (!existing) return res.status(404).json({ error: "Alert rule not found" }); - getDb().$client.prepare("DELETE FROM alert_rules WHERE id = ?").run(ruleId); - DatabaseSaveTrigger.triggerSave("alert_rule_deleted"); + const deleted = await createCurrentAlertRepository().deleteAlertRule( + ruleId, + userId, + ); + if (!deleted) return res.status(404).json({ error: "Alert rule not found" }); res.json({ success: true }); }); @@ -573,42 +446,27 @@ router.delete("/alert-rules/:id", (req: Request, res: Response) => { * tags: * - Alerts */ -router.get("/alert-firings", (req, res) => { +router.get("/alert-firings", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const limit = Math.min(Number(req.query.limit) || 50, 200); const offset = Number(req.query.offset) || 0; const acknowledgedParam = req.query.acknowledged; try { - let whereClause = "af.user_id = ?"; - const params: unknown[] = [userId]; - - if (acknowledgedParam === "true") { - whereClause += " AND af.acknowledged = 1"; - } else if (acknowledgedParam === "false") { - whereClause += " AND af.acknowledged = 0"; - } - - const rows = getDb() - .$client.prepare( - `SELECT af.*, ar.name as rule_name - FROM alert_firings af - LEFT JOIN alert_rules ar ON ar.id = af.rule_id - WHERE ${whereClause} - ORDER BY af.fired_at DESC - LIMIT ? OFFSET ?`, - ) - .all(...params, limit, offset); - - const total = ( - getDb() - .$client.prepare( - `SELECT COUNT(*) as c FROM alert_firings af WHERE ${whereClause}`, - ) - .get(...params) as { c: number } - ).c; - - res.json({ firings: rows, total }); + const acknowledged = + acknowledgedParam === "true" + ? true + : acknowledgedParam === "false" + ? false + : undefined; + res.json( + await createCurrentAlertRepository().listAlertFirings({ + userId, + acknowledged, + limit, + offset, + }), + ); } catch (err) { databaseLogger.error("Failed to list alert firings", { operation: "list_alert_firings", @@ -626,17 +484,15 @@ router.get("/alert-firings", (req, res) => { * tags: * - Alerts */ -router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - const firingId = Number(req.params.id); - getDb() - .$client.prepare( - "UPDATE alert_firings SET acknowledged = 1 WHERE id = ? AND user_id = ?", - ) - .run(firingId, userId); - DatabaseSaveTrigger.triggerSave("alert_firing_acknowledged"); - res.json({ success: true }); -}); +router.post( + "/alert-firings/:id/acknowledge", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const firingId = Number(req.params.id); + await createCurrentAlertRepository().acknowledgeFiring(firingId, userId); + res.json({ success: true }); + }, +); /** * @openapi @@ -646,14 +502,9 @@ router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => { * tags: * - Alerts */ -router.post("/alert-firings/acknowledge-all", (req, res) => { +router.post("/alert-firings/acknowledge-all", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - getDb() - .$client.prepare( - "UPDATE alert_firings SET acknowledged = 1 WHERE user_id = ?", - ) - .run(userId); - DatabaseSaveTrigger.triggerSave("alert_firings_acknowledged_all"); + await createCurrentAlertRepository().acknowledgeAllFirings(userId); res.json({ success: true }); }); diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts index aa66ec57..faed11ea 100644 --- a/src/backend/database/routes/alerts.ts +++ b/src/backend/database/routes/alerts.ts @@ -4,12 +4,10 @@ import type { TermixAlert, } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { dismissedAlerts } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { getProxyAgent } from "../../utils/proxy-agent.js"; +import { createCurrentDismissedAlertRepository } from "../repositories/current-dismissed-alert-repository.js"; class AlertCache { private cache: Map = new Map(); @@ -120,13 +118,10 @@ router.get("/", authenticateJWT, async (req, res) => { const allAlerts = await fetchAlertsFromGitHub(); - const dismissedAlertRecords = await db - .select({ alertId: dismissedAlerts.alertId }) - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); - const dismissedAlertIds = new Set( - dismissedAlertRecords.map((record) => record.alertId), + await createCurrentDismissedAlertRepository().listAlertIdsByUserId( + userId, + ), ); const activeAlertsForUser = allAlerts.filter( @@ -181,25 +176,18 @@ router.post("/dismiss", authenticateJWT, async (req, res) => { return res.status(400).json({ error: "Alert ID is required" }); } - const existingDismissal = await db - .select() - .from(dismissedAlerts) - .where( - and( - eq(dismissedAlerts.userId, userId), - eq(dismissedAlerts.alertId, alertId), - ), + const existingDismissal = + await createCurrentDismissedAlertRepository().findForUser( + userId, + alertId, ); - if (existingDismissal.length > 0) { + if (existingDismissal) { authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`); return res.status(409).json({ error: "Alert already dismissed" }); } - await db.insert(dismissedAlerts).values({ - userId, - alertId, - }); + await createCurrentDismissedAlertRepository().create(userId, alertId); res.json({ message: "Alert dismissed successfully" }); } catch (error) { @@ -226,13 +214,8 @@ router.get("/dismissed", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; - const dismissedAlertRecords = await db - .select({ - alertId: dismissedAlerts.alertId, - dismissedAt: dismissedAlerts.dismissedAt, - }) - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); + const dismissedAlertRecords = + await createCurrentDismissedAlertRepository().listByUserId(userId); res.json({ dismissed_alerts: dismissedAlertRecords, @@ -280,16 +263,12 @@ router.delete("/dismiss", authenticateJWT, async (req, res) => { return res.status(400).json({ error: "Alert ID is required" }); } - const result = await db - .delete(dismissedAlerts) - .where( - and( - eq(dismissedAlerts.userId, userId), - eq(dismissedAlerts.alertId, alertId), - ), - ); + const deleted = await createCurrentDismissedAlertRepository().deleteForUser( + userId, + alertId, + ); - if (result.changes === 0) { + if (!deleted) { return res.status(404).json({ error: "Dismissed alert not found" }); } res.json({ message: "Alert undismissed successfully" }); diff --git a/src/backend/database/routes/audit-log-routes.ts b/src/backend/database/routes/audit-log-routes.ts index 0ba74eb0..1d93f2d2 100644 --- a/src/backend/database/routes/audit-log-routes.ts +++ b/src/backend/database/routes/audit-log-routes.ts @@ -1,10 +1,15 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq, and, gte, lte, sql } from "drizzle-orm"; -import { db } from "../db/index.js"; -import { auditLogs, users } from "../db/schema.js"; +import { createCurrentAuditLogRepository } from "../repositories/current-audit-log-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; import { apiLogger } from "../../utils/logger.js"; +async function isAdminUser(userId: string | undefined): Promise { + if (!userId) return false; + const user = await createCurrentUserRepository().findById(userId); + return !!user?.isAdmin; +} + export function registerAuditLogRoutes( router: Router, authenticateJWT: RequestHandler, @@ -53,13 +58,7 @@ export function registerAuditLogRoutes( router.get("/audit-logs", authenticateJWT, async (req, res) => { try { const authReq = req as AuthenticatedRequest; - const adminUser = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, authReq.userId)) - .limit(1); - - if (!adminUser[0]?.isAdmin) { + if (!(await isAdminUser(authReq.userId))) { return res.status(403).json({ error: "Not authorized" }); } @@ -73,36 +72,21 @@ export function registerAuditLogRoutes( const { userId, action, resourceType, success, startDate, endDate } = req.query as Record; - const conditions = []; - - if (userId) conditions.push(eq(auditLogs.userId, userId)); - if (action) conditions.push(eq(auditLogs.action, action)); - if (resourceType) - conditions.push(eq(auditLogs.resourceType, resourceType)); - if (success !== undefined && success !== "") { - conditions.push(eq(auditLogs.success, success === "true")); - } - if (startDate) conditions.push(gte(auditLogs.timestamp, startDate)); - if (endDate) conditions.push(lte(auditLogs.timestamp, endDate)); - - const whereClause = - conditions.length > 0 ? and(...conditions) : undefined; - - const [logs, totalResult] = await Promise.all([ - db - .select() - .from(auditLogs) - .where(whereClause) - .orderBy(sql`${auditLogs.timestamp} DESC`) - .limit(limit) - .offset(offset), - db - .select({ count: sql`COUNT(*)` }) - .from(auditLogs) - .where(whereClause), - ]); - - const total = totalResult[0]?.count ?? 0; + const { logs, total } = await createCurrentAuditLogRepository().listPage({ + filters: { + userId, + action, + resourceType, + success: + success !== undefined && success !== "" + ? success === "true" + : undefined, + startDate, + endDate, + }, + limit, + offset, + }); const totalPages = Math.ceil(total / limit); return res.json({ logs, total, page, totalPages }); @@ -131,21 +115,14 @@ export function registerAuditLogRoutes( router.get("/audit-logs/actions", authenticateJWT, async (req, res) => { try { const authReq = req as AuthenticatedRequest; - const adminUser = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, authReq.userId)) - .limit(1); - - if (!adminUser[0]?.isAdmin) { + if (!(await isAdminUser(authReq.userId))) { return res.status(403).json({ error: "Not authorized" }); } - const rows = db.$client - .prepare("SELECT DISTINCT action FROM audit_logs ORDER BY action ASC") - .all() as { action: string }[]; + const actions = + await createCurrentAuditLogRepository().listDistinctActions(); - return res.json({ actions: rows.map((r) => r.action) }); + return res.json({ actions }); } catch (err) { apiLogger.error("Failed to fetch audit log actions", err); return res diff --git a/src/backend/database/routes/c2s-tunnel-presets.ts b/src/backend/database/routes/c2s-tunnel-presets.ts index 7bca59e4..cc51321f 100644 --- a/src/backend/database/routes/c2s-tunnel-presets.ts +++ b/src/backend/database/routes/c2s-tunnel-presets.ts @@ -3,12 +3,11 @@ import type { TunnelConnection, } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { c2sTunnelPresets } from "../db/schema.js"; -import { and, asc, eq, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import type { C2sTunnelPresetRecord } from "../repositories/c2s-tunnel-preset-repository.js"; +import { createCurrentC2sTunnelPresetRepository } from "../repositories/current-c2s-tunnel-preset-repository.js"; const router = express.Router(); @@ -20,7 +19,7 @@ function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } -function parsePreset(row: typeof c2sTunnelPresets.$inferSelect) { +function parsePreset(row: C2sTunnelPresetRecord) { return { ...row, config: JSON.parse(row.config) as TunnelConnection[], @@ -58,11 +57,8 @@ router.get( } try { - const result = await db - .select() - .from(c2sTunnelPresets) - .where(eq(c2sTunnelPresets.userId, userId)) - .orderBy(asc(c2sTunnelPresets.name)); + const result = + await createCurrentC2sTunnelPresetRepository().listByUserId(userId); res.json(result.map(parsePreset)); } catch (error) { authLogger.error("Failed to fetch C2S tunnel presets", error); @@ -90,36 +86,24 @@ router.post( const trimmedName = name.trim(); try { - const existing = await db - .select() - .from(c2sTunnelPresets) - .where( - and( - eq(c2sTunnelPresets.userId, userId), - eq(c2sTunnelPresets.name, trimmedName), - ), - ); - if (existing.length > 0) { + const presetRepository = createCurrentC2sTunnelPresetRepository(); + if (await presetRepository.hasNameForUser(userId, trimmedName)) { return res.status(409).json({ error: "Preset name already exists" }); } - const result = await db - .insert(c2sTunnelPresets) - .values({ - userId, - name: trimmedName, - config: JSON.stringify(config), - platform: platform?.trim() || null, - computerName: computerName?.trim() || null, - }) - .returning(); + const created = await presetRepository.createForUser(userId, { + name: trimmedName, + config: JSON.stringify(config), + platform: platform?.trim() || null, + computerName: computerName?.trim() || null, + }); databaseLogger.info("C2S tunnel preset created", { operation: "c2s_tunnel_preset_create", userId, - presetId: result[0].id, + presetId: created.id, }); - res.status(201).json(parsePreset(result[0])); + res.status(201).json(parsePreset(created)); } catch (error) { authLogger.error("Failed to create C2S tunnel preset", error); res.status(500).json({ error: "Failed to create C2S tunnel preset" }); @@ -141,35 +125,21 @@ router.put( } try { - const existing = await db - .select() - .from(c2sTunnelPresets) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); - if (existing.length === 0) { + const presetRepository = createCurrentC2sTunnelPresetRepository(); + const existing = await presetRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Preset not found" }); } - const updateFields: Record = { - updatedAt: sql`CURRENT_TIMESTAMP`, - }; + const updateFields: Parameters[2] = + {}; if (name !== undefined) { if (!isNonEmptyString(name)) { return res.status(400).json({ error: "Preset name is required" }); } const trimmedName = name.trim(); - const duplicate = await db - .select() - .from(c2sTunnelPresets) - .where( - and( - eq(c2sTunnelPresets.userId, userId), - eq(c2sTunnelPresets.name, trimmedName), - ), - ); - if (duplicate.some((preset) => preset.id !== id)) { + if (await presetRepository.hasNameForUser(userId, trimmedName, id)) { return res.status(409).json({ error: "Preset name already exists" }); } updateFields.name = trimmedName; @@ -188,18 +158,12 @@ router.put( if (computerName !== undefined) updateFields.computerName = computerName?.trim() || null; - await db - .update(c2sTunnelPresets) - .set(updateFields) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); - - const updated = await db - .select() - .from(c2sTunnelPresets) - .where(eq(c2sTunnelPresets.id, id)); - res.json(parsePreset(updated[0])); + const updated = await presetRepository.updateForUser( + userId, + id, + updateFields, + ); + res.json(parsePreset(updated ?? existing)); } catch (error) { authLogger.error("Failed to update C2S tunnel preset", error); res.status(500).json({ error: "Failed to update C2S tunnel preset" }); @@ -220,21 +184,13 @@ router.delete( } try { - const existing = await db - .select() - .from(c2sTunnelPresets) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); - if (existing.length === 0) { + const presetRepository = createCurrentC2sTunnelPresetRepository(); + const existing = await presetRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Preset not found" }); } - await db - .delete(c2sTunnelPresets) - .where( - and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)), - ); + await presetRepository.deleteForUser(userId, id); res.json({ success: true }); } catch (error) { diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts index 1ad1b8df..fecac74d 100644 --- a/src/backend/database/routes/credential-deploy-routes.ts +++ b/src/backend/database/routes/credential-deploy-routes.ts @@ -3,12 +3,9 @@ import type { CredentialBackend, } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, eq } from "drizzle-orm"; import ssh2Pkg from "ssh2"; -import { db } from "../db/index.js"; -import { hosts, sshCredentials } from "../db/schema.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; -import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js"; const { Client } = ssh2Pkg; @@ -273,100 +270,88 @@ async function deploySSHKeyToHost( resolve({ success: false, error: errorMessage }); }); - void (async () => { - try { - const connectionConfig: Record = { - host: hostConfig.ip, - port: hostConfig.port || 22, - username: hostConfig.username, - readyTimeout: 60000, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - algorithms: { - kex: [ - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group1-sha1", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group-exchange-sha1", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521", - ], - cipher: [ - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-gcm@openssh.com", - "aes256-gcm@openssh.com", - "aes128-cbc", - "aes192-cbc", - "aes256-cbc", - "3des-cbc", - ], - hmac: [ - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256", - "hmac-sha2-512", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; + try { + const connectionConfig: Record = { + host: hostConfig.ip, + port: hostConfig.port || 22, + username: hostConfig.username, + readyTimeout: 60000, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: [ + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + "hmac-md5", + ], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; - if (hostConfig.authType === "password" && hostConfig.password) { - connectionConfig.password = hostConfig.password; - } else if (hostConfig.authType === "key" && hostConfig.privateKey) { - try { - const privateKey = hostConfig.privateKey as string; - connectionConfig.privateKey = preparePrivateKeyForSSH2( - privateKey, - hostConfig.keyPassword as string | undefined, - ); - - if (hostConfig.keyPassword) { - connectionConfig.passphrase = hostConfig.keyPassword; - } - } catch (keyError) { - clearTimeout(connectionTimeout); - resolve({ - success: false, - error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, - }); - return; - } - } else if (hostConfig.authType === "agent") { - const result = await applyAgentAuth( - connectionConfig, - hostConfig.terminalConfig as Record | undefined, + if (hostConfig.authType === "password" && hostConfig.password) { + connectionConfig.password = hostConfig.password; + } else if (hostConfig.authType === "key" && hostConfig.privateKey) { + try { + const privateKey = hostConfig.privateKey as string; + connectionConfig.privateKey = preparePrivateKeyForSSH2( + privateKey, + hostConfig.keyPassword as string | undefined, ); - if ("error" in result) { - clearTimeout(connectionTimeout); - resolve({ success: false, error: result.error }); - return; + + if (hostConfig.keyPassword) { + connectionConfig.passphrase = hostConfig.keyPassword; } - } else { + } catch (keyError) { clearTimeout(connectionTimeout); resolve({ success: false, - error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, + error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, }); return; } - - conn.connect(connectionConfig); - } catch (error) { + } else { clearTimeout(connectionTimeout); resolve({ success: false, - error: error instanceof Error ? error.message : "Connection failed", + error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, }); + return; } - })(); + + conn.connect(connectionConfig); + } catch (error) { + clearTimeout(connectionTimeout); + resolve({ + success: false, + error: error instanceof Error ? error.message : "Connection failed", + }); + } }); } @@ -435,30 +420,20 @@ export function registerCredentialDeployRoutes( }); } - const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); - const credential = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .limit(1), - "ssh_credentials", + const repository = createCurrentHostResolutionRepository(); + const credential = await repository.findCredentialByIdForUser( + credentialId, userId, ); - if (!credential || credential.length === 0) { + if (!credential) { return res.status(404).json({ success: false, error: "Credential not found", }); } - const credData = credential[0] as unknown as CredentialBackend; + const credData = credential as unknown as CredentialBackend; if (credData.authType !== "key") { return res.status(400).json({ @@ -474,25 +449,18 @@ export function registerCredentialDeployRoutes( error: "Public key is required for deployment", }); } - const targetHost = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(and(eq(hosts.id, targetHostId), eq(hosts.userId, userId))) - .limit(1), - "ssh_data", + const hostData = await repository.findHostByIdForUser( + targetHostId, userId, ); - if (!targetHost || targetHost.length === 0) { + if (!hostData) { return res.status(404).json({ success: false, error: "Target host not found", }); } - const hostData = targetHost[0]; - const hostConfig = { ip: hostData.ip, port: hostData.port, @@ -501,7 +469,6 @@ export function registerCredentialDeployRoutes( password: hostData.password, privateKey: hostData.key, keyPassword: hostData.keyPassword, - terminalConfig: hostData.terminalConfig, }; if (hostData.authType === "credential" && hostData.credentialId) { @@ -514,29 +481,21 @@ export function registerCredentialDeployRoutes( } try { - const { SimpleDBOps } = - await import("../../utils/simple-db-ops.js"); - const hostCredential = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, hostData.credentialId as number)) - .limit(1), - "ssh_credentials", + const hostCredential = await repository.findCredentialByIdForUser( + hostData.credentialId as number, userId, ); - if (hostCredential && hostCredential.length > 0) { - const cred = hostCredential[0]; + if (hostCredential) { + hostConfig.authType = hostCredential.authType; + hostConfig.username = hostCredential.username; - hostConfig.authType = cred.authType; - hostConfig.username = cred.username; - - if (cred.authType === "password") { - hostConfig.password = cred.password; - } else if (cred.authType === "key") { - hostConfig.privateKey = cred.privateKey || cred.key; - hostConfig.keyPassword = cred.keyPassword; + if (hostCredential.authType === "password") { + hostConfig.password = hostCredential.password; + } else if (hostCredential.authType === "key") { + hostConfig.privateKey = + hostCredential.privateKey || hostCredential.key; + hostConfig.keyPassword = hostCredential.keyPassword; } } else { return res.status(400).json({ diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index e8d767a3..221f4d6d 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -1,21 +1,17 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - sshCredentials, - sshCredentialUsage, - hosts, - hostAccess, -} from "../db/schema.js"; -import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; import { registerCredentialKeyRoutes } from "./credential-key-routes.js"; import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { createCurrentRbacAccessRepository } from "../repositories/current-rbac-access-repository.js"; +import { createCurrentCredentialRepository } from "../repositories/current-credential-repository.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; +import { createCurrentHostRepository } from "../repositories/current-host-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; const router = express.Router(); @@ -27,6 +23,11 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); +async function getAuditUsername(userId: string): Promise { + const actor = await createCurrentUserRepository().findById(userId); + return actor?.username ?? userId; +} + /** * @openapi * /credentials: @@ -137,7 +138,8 @@ router.post( .status(400) .json({ error: "SSH key is required for key authentication" }); } - const plainPassword = password ? password : null; + const plainPassword = + authType === "password" && password ? password : null; const plainKey = authType === "key" && key ? key : null; const plainKeyPassword = authType === "key" && keyPassword ? keyPassword : null; @@ -181,23 +183,16 @@ router.post( lastUsed: null, }; - const created = (await SimpleDBOps.insert( - sshCredentials, - "ssh_credentials", - credentialData, - userId, - )) as typeof credentialData & { id: number }; + const created = + await createCurrentCredentialRepository().createEncryptedForUser( + userId, + credentialData, + ); const { ipAddress: ccIp, userAgent: ccUa } = getRequestMeta(req); - const { users: usersTableCc } = await import("../db/schema.js"); - const ccActor = await db - .select({ username: usersTableCc.username }) - .from(usersTableCc) - .where(eq(usersTableCc.id, userId)) - .limit(1); await logAudit({ userId, - username: ccActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "create_credential", resourceType: "credential", resourceId: String(created.id), @@ -265,15 +260,8 @@ router.get( } try { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .orderBy(desc(sshCredentials.updatedAt)), - "ssh_credentials", - userId, - ); + const credentials = + await createCurrentCredentialRepository().listDecryptedByUserId(userId); res.json(credentials.map((cred) => formatCredentialOutput(cred))); } catch (err) { @@ -312,22 +300,7 @@ router.get( } try { - const result = await db - .select({ folder: sshCredentials.folder }) - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)); - - const folderCounts: Record = {}; - result.forEach((r) => { - if (r.folder && r.folder.trim() !== "") { - folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; - } - }); - - const folders = Object.keys(folderCounts).filter( - (folder) => folderCounts[folder] > 0, - ); - res.json(folders); + res.json(await createCurrentCredentialRepository().listFolders(userId)); } catch (err) { authLogger.error("Failed to fetch credential folders", err); res.status(500).json({ error: "Failed to fetch credential folders" }); @@ -373,25 +346,16 @@ router.get( } try { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); + const credential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + parseInt(id), + ); - if (credentials.length === 0) { + if (!credential) { return res.status(404).json({ error: "Credential not found" }); } - const credential = credentials[0]; const output = formatCredentialOutput(credential); if (credential.password) { @@ -468,25 +432,22 @@ router.put( authLogger.warn("Invalid request for credential update"); return res.status(400).json({ error: "Invalid request" }); } + const credentialId = parseInt(id); authLogger.info("Updating SSH credential", { operation: "credential_update", userId, - credentialId: parseInt(id), + credentialId, changes: Object.keys(updateData), }); try { - const existing = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), + const existingCredential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + credentialId, ); - if (existing.length === 0) { + if (!existingCredential) { return res.status(404).json({ error: "Credential not found" }); } @@ -513,17 +474,16 @@ router.put( if (updateData.password !== undefined) { updateFields.password = updateData.password || null; } - const nextAuthType = updateData.authType ?? existing[0].authType; if (updateData.key !== undefined) { updateFields.key = updateData.key || null; - if (updateData.key && nextAuthType === "key") { + if (updateData.key && existingCredential.authType === "key") { const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword); if (!keyInfo.success) { authLogger.warn("SSH key parsing failed during update", { operation: "credential_update", userId, - credentialId: parseInt(id), + credentialId, error: keyInfo.error, }); return res.status(400).json({ @@ -545,72 +505,50 @@ router.put( } if (Object.keys(updateFields).length === 0) { - const existing = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))), - "ssh_credentials", - userId, - ); - - return res.json(formatCredentialOutput(existing[0])); + return res.json(formatCredentialOutput(existingCredential)); } - await SimpleDBOps.update( - sshCredentials, - "ssh_credentials", - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), + const credentialRepository = createCurrentCredentialRepository(); + const updated = await credentialRepository.updateEncryptedForUser( + userId, + credentialId, updateFields, - userId, - ); - - const updated = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, parseInt(id))), - "ssh_credentials", - userId, ); + const updatedCredential = + updated ?? + (await credentialRepository.findDecryptedByIdForUser( + userId, + credentialId, + )); const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); await sharedCredManager.updateSharedCredentialsForOriginal( - parseInt(id), + credentialId, userId, ); authLogger.success("SSH credential updated", { operation: "credential_update_success", userId, - credentialId: parseInt(id), + credentialId, }); const { ipAddress: cuIp, userAgent: cuUa } = getRequestMeta(req); - const { users: usersTableCu } = await import("../db/schema.js"); - const cuActor = await db - .select({ username: usersTableCu.username }) - .from(usersTableCu) - .where(eq(usersTableCu.id, userId)) - .limit(1); await logAudit({ userId, - username: cuActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "update_credential", resourceType: "credential", resourceId: id, - resourceName: existing[0].name, + resourceName: String(existingCredential.name ?? id), ipAddress: cuIp, userAgent: cuUa, success: true, }); - res.json(formatCredentialOutput(updated[0])); + res.json(formatCredentialOutput(updatedCredential ?? existingCredential)); } catch (err) { authLogger.error("Failed to update credential", err); res.status(500).json({ @@ -664,55 +602,50 @@ router.delete( }); try { - const credentialToDelete = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), + const credentialId = parseInt(id); + const credentialToDelete = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + credentialId, ); - if (credentialToDelete.length === 0) { + if (!credentialToDelete) { return res.status(404).json({ error: "Credential not found" }); } - const hostsUsingCredential = await db - .select() - .from(hosts) - .where( - and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)), + const hostsUsingCredential = + await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser( + userId, + credentialId, ); if (hostsUsingCredential.length > 0) { - await db - .update(hosts) - .set({ + await createCurrentHostRepository().updateManyForUser( + userId, + hostsUsingCredential.map((host) => host.id), + { credentialId: null, password: null, key: null, keyPassword: null, authType: "password", - }) - .where( - and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)), - ); + }, + ); for (const host of hostsUsingCredential) { - const revokedShares = await db - .delete(hostAccess) - .where(eq(hostAccess.hostId, host.id)) - .returning({ id: hostAccess.id }); + const revokedCount = + await createCurrentRbacAccessRepository().deleteHostAccessForHost( + host.id, + ); - if (revokedShares.length > 0) { + if (revokedCount > 0) { authLogger.info( "Auto-revoked host shares due to credential deletion", { operation: "auto_revoke_shares", hostId: host.id, - credentialId: parseInt(id), - revokedCount: revokedShares.length, + credentialId, + revokedCount, reason: "credential_deleted", }, ); @@ -723,37 +656,27 @@ router.delete( const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id)); + await sharedCredManager.deleteSharedCredentialsForOriginal(credentialId); - await db - .delete(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(id)), - eq(sshCredentials.userId, userId), - ), - ); + await createCurrentCredentialRepository().deleteForUser( + userId, + credentialId, + ); authLogger.success("SSH credential deleted", { operation: "credential_delete_success", userId, - credentialId: parseInt(id), + credentialId, }); const { ipAddress: cdIp, userAgent: cdUa } = getRequestMeta(req); - const { users: usersTableCd } = await import("../db/schema.js"); - const cdActor = await db - .select({ username: usersTableCd.username }) - .from(usersTableCd) - .where(eq(usersTableCd.id, userId)) - .limit(1); await logAudit({ userId, - username: cdActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "delete_credential", resourceType: "credential", resourceId: id, - resourceName: credentialToDelete[0].name, + resourceName: String(credentialToDelete.name ?? id), ipAddress: cdIp, userAgent: cdUa, success: true, @@ -817,29 +740,20 @@ router.post( } try { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, parseInt(credentialId)), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); + const credential = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + parseInt(credentialId), + ); - if (credentials.length === 0) { + if (!credential) { return res.status(404).json({ error: "Credential not found" }); } - const credential = credentials[0]; - - await db - .update(hosts) - .set({ + await createCurrentHostRepository().updateForUser( + userId, + parseInt(hostId), + { credentialId: parseInt(credentialId), username: (credential.username as string) || "", authType: credential.authType as string, @@ -848,24 +762,14 @@ router.post( keyPassword: null, keyType: null, updatedAt: new Date().toISOString(), - }) - .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))); + }, + ); - await db.insert(sshCredentialUsage).values({ - credentialId: parseInt(credentialId), - hostId: parseInt(hostId), + await createCurrentCredentialRepository().recordUsage( userId, - }); - - await db - .update(sshCredentials) - .set({ - usageCount: sql`${sshCredentials.usageCount} - + 1`, - lastUsed: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - .where(eq(sshCredentials.id, parseInt(credentialId))); + parseInt(credentialId), + parseInt(hostId), + ); res.json({ message: "Credential applied to host successfully" }); } catch (err) { authLogger.error("Failed to apply credential to host", err); @@ -916,14 +820,10 @@ router.get( } try { - const hostsUsingCredential = await db - .select() - .from(hosts) - .where( - and( - eq(hosts.credentialId, parseInt(credentialId)), - eq(hosts.userId, userId), - ), + const hostsUsingCredential = + await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser( + userId, + parseInt(credentialId), ); res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host))); @@ -1044,15 +944,11 @@ router.put( } try { - await db - .update(sshCredentials) - .set({ folder: newName }) - .where( - and( - eq(sshCredentials.userId, userId), - eq(sshCredentials.folder, oldName), - ), - ); + await createCurrentCredentialRepository().renameFolder( + userId, + oldName, + newName, + ); res.json({ success: true, message: "Folder renamed successfully" }); } catch (error) { diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts index 20be326a..9cad34cb 100644 --- a/src/backend/database/routes/dashboard-service-links-routes.ts +++ b/src/backend/database/routes/dashboard-service-links-routes.ts @@ -1,18 +1,22 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, Response } from "express"; -import { and, asc, eq } from "drizzle-orm"; import { dashboardLogger } from "../../utils/logger.js"; -import { db, DatabaseSaveTrigger } from "../db/index.js"; -import { dashboardServiceLinks } from "../db/schema.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { isNonEmptyString } from "./host-normalizers.js"; -import { - isValidServiceLinkUrl, - normalizeServiceLinkUrl, -} from "./service-link-url.js"; import express from "express"; +import { createCurrentDashboardServiceLinkRepository } from "../repositories/current-dashboard-service-link-repository.js"; export const dashboardServiceLinksRouter = express.Router(); +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + /** * @openapi * /service-links: @@ -30,11 +34,8 @@ export const dashboardServiceLinksRouter = express.Router(); dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const links = await db - .select() - .from(dashboardServiceLinks) - .where(eq(dashboardServiceLinks.userId, userId)) - .orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id)); + const links = + await createCurrentDashboardServiceLinkRepository().listByUserId(userId); res.json(links); } catch (err) { dashboardLogger.error("Failed to fetch service links", err); @@ -79,33 +80,21 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => { if (!isNonEmptyString(label) || !isNonEmptyString(url)) { return res.status(400).json({ error: "label and url are required" }); } - const normalizedUrl = normalizeServiceLinkUrl(url); - if (!isValidServiceLinkUrl(normalizedUrl)) { + if (!isValidUrl(url)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); } try { - const existing = await db - .select({ order: dashboardServiceLinks.order }) - .from(dashboardServiceLinks) - .where(eq(dashboardServiceLinks.userId, userId)) - .orderBy(asc(dashboardServiceLinks.order)); - - const nextOrder = - existing.length > 0 ? existing[existing.length - 1].order + 1 : 0; - - const [created] = await db - .insert(dashboardServiceLinks) - .values({ + const created = + await createCurrentDashboardServiceLinkRepository().createForUser( userId, - label: label.trim(), - url: normalizedUrl, - order: nextOrder, - createdAt: new Date().toISOString(), - }) - .returning(); + { + label: label.trim(), + url: url.trim(), + }, + ); DatabaseSaveTrigger.triggerSave("dashboard_service_link_created"); res.status(201).json(created); @@ -153,28 +142,20 @@ dashboardServiceLinksRouter.delete( } try { - const existing = await db - .select() - .from(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), + const existing = + await createCurrentDashboardServiceLinkRepository().findByIdForUser( + userId, + id, ); - if (existing.length === 0) { + if (!existing) { return res.status(404).json({ error: "Not found" }); } - await db - .delete(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ); + await createCurrentDashboardServiceLinkRepository().deleteForUser( + userId, + id, + ); DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted"); res.json({ message: "Service link deleted" }); @@ -231,48 +212,37 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => { if (isNaN(id)) { return res.status(400).json({ error: "Invalid id" }); } - const normalizedUrl = isNonEmptyString(url) - ? normalizeServiceLinkUrl(url) - : undefined; - if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) { + if (url !== undefined && !isValidUrl(url)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); } try { - const existing = await db - .select() - .from(dashboardServiceLinks) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), + const existing = + await createCurrentDashboardServiceLinkRepository().findByIdForUser( + userId, + id, ); - if (existing.length === 0) { + if (!existing) { return res.status(404).json({ error: "Not found" }); } const updates: Partial<{ label: string; url: string }> = {}; if (isNonEmptyString(label)) updates.label = label.trim(); - if (normalizedUrl !== undefined) updates.url = normalizedUrl; + if (isNonEmptyString(url)) updates.url = url.trim(); if (Object.keys(updates).length === 0) { return res.status(400).json({ error: "Nothing to update" }); } - const [updated] = await db - .update(dashboardServiceLinks) - .set(updates) - .where( - and( - eq(dashboardServiceLinks.id, id), - eq(dashboardServiceLinks.userId, userId), - ), - ) - .returning(); + const updated = + await createCurrentDashboardServiceLinkRepository().updateForUser( + userId, + id, + updates, + ); DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated"); res.json(updated); diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts index 9367d3fd..f09279b4 100644 --- a/src/backend/database/routes/delete-user-data.ts +++ b/src/backend/database/routes/delete-user-data.ts @@ -1,90 +1,98 @@ -import { eq } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { - auditLogs, - commandHistory, - dismissedAlerts, - fileManagerPinned, - fileManagerRecent, - fileManagerShortcuts, - hostAccess, - hosts, - networkTopology, - opksshTokens, - recentActivity, - sessionRecordings, - sessions, - sharedCredentials, - snippetFolders, - snippets, - sshCredentialUsage, - sshCredentials, - sshFolders, - transferRecent, - userOpenTabs, - userPreferences, - userRoles, - users, -} from "../db/schema.js"; +import { createCurrentAlertRepository } from "../repositories/current-alert-repository.js"; +import { createCurrentApiKeyRepository } from "../repositories/current-api-key-repository.js"; +import { createCurrentAuditLogRepository } from "../repositories/current-audit-log-repository.js"; +import { createCurrentC2sTunnelPresetRepository } from "../repositories/current-c2s-tunnel-preset-repository.js"; +import { createCurrentCommandHistoryRepository } from "../repositories/current-command-history-repository.js"; +import { createCurrentCredentialRepository } from "../repositories/current-credential-repository.js"; +import { createCurrentDashboardServiceLinkRepository } from "../repositories/current-dashboard-service-link-repository.js"; +import { createCurrentDismissedAlertRepository } from "../repositories/current-dismissed-alert-repository.js"; +import { createCurrentFileManagerBookmarkRepository } from "../repositories/current-file-manager-bookmark-repository.js"; +import { createCurrentHomepageItemRepository } from "../repositories/current-homepage-item-repository.js"; +import { createCurrentHomepageLayoutRepository } from "../repositories/current-homepage-layout-repository.js"; +import { createCurrentHostHealthRepository } from "../repositories/current-host-health-repository.js"; +import { createCurrentHostFolderRepository } from "../repositories/current-host-folder-repository.js"; +import { createCurrentHostMetricsPreferenceRepository } from "../repositories/current-host-metrics-preference-repository.js"; +import { createCurrentHostRepository } from "../repositories/current-host-repository.js"; +import { createCurrentNetworkTopologyRepository } from "../repositories/current-network-topology-repository.js"; +import { createCurrentOpksshTokenRepository } from "../repositories/current-opkssh-token-repository.js"; +import { createCurrentOpenTabRepository } from "../repositories/current-open-tab-repository.js"; +import { createCurrentRecentActivityRepository } from "../repositories/current-recent-activity-repository.js"; +import { createCurrentRbacAccessRepository } from "../repositories/current-rbac-access-repository.js"; +import { createCurrentRoleRepository } from "../repositories/current-role-repository.js"; +import { createCurrentSessionRepository } from "../repositories/current-session-repository.js"; +import { createCurrentSessionRecordingRepository } from "../repositories/current-session-recording-repository.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; +import { createCurrentSharedCredentialRepository } from "../repositories/current-shared-credential-repository.js"; +import { createCurrentSnippetRepository } from "../repositories/current-snippet-repository.js"; +import { createCurrentSshCredentialUsageRepository } from "../repositories/current-ssh-credential-usage-repository.js"; +import { createCurrentTermixIdentityCaRepository } from "../repositories/current-termix-identity-ca-repository.js"; +import { createCurrentTermixIdentityRepository } from "../repositories/current-termix-identity-repository.js"; +import { createCurrentTmuxSessionTagRepository } from "../repositories/current-tmux-session-tag-repository.js"; +import { createCurrentTrustedDeviceRepository } from "../repositories/current-trusted-device-repository.js"; +import { createCurrentUserPreferenceRepository } from "../repositories/current-user-preference-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; +import { createCurrentTransferRecentRepository } from "../repositories/current-transfer-recent-repository.js"; +import { createCurrentVaultProfileRepository } from "../repositories/current-vault-profile-repository.js"; +import { createCurrentVaultTokenRepository } from "../repositories/current-vault-token-repository.js"; export async function deleteUserAndRelatedData(userId: string): Promise { try { - await db - .delete(sharedCredentials) - .where(eq(sharedCredentials.targetUserId, userId)); + await createCurrentSharedCredentialRepository().deleteByTargetUserId( + userId, + ); - await db - .delete(sessionRecordings) - .where(eq(sessionRecordings.userId, userId)); + await createCurrentSessionRecordingRepository().deleteByUserId(userId); - await db.delete(hostAccess).where(eq(hostAccess.userId, userId)); - await db.delete(hostAccess).where(eq(hostAccess.grantedBy, userId)); + await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences( + userId, + ); - await db.delete(sessions).where(eq(sessions.userId, userId)); + await createCurrentSessionRepository().revokeAllForUser(userId); + await createCurrentApiKeyRepository().deleteByUserId(userId); + await createCurrentTrustedDeviceRepository().deleteByUserId(userId); - await db.delete(userRoles).where(eq(userRoles.userId, userId)); - await db.delete(auditLogs).where(eq(auditLogs.userId, userId)); + await createCurrentRoleRepository().removeAllRolesFromUser(userId); + await createCurrentAlertRepository().deleteByUserId(userId); + await createCurrentAuditLogRepository().deleteByUserId(userId); - await db - .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, userId)); + await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); - await db - .delete(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)); - await db - .delete(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)); - await db - .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)); + await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId); - await db.delete(transferRecent).where(eq(transferRecent.userId, userId)); + await createCurrentTransferRecentRepository().deleteByUserId(userId); - await db.delete(recentActivity).where(eq(recentActivity.userId, userId)); - await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId)); + await createCurrentRecentActivityRepository().deleteByUserId(userId); + await createCurrentDismissedAlertRepository().deleteByUserId(userId); - await db.delete(snippets).where(eq(snippets.userId, userId)); - await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId)); + await createCurrentSnippetRepository().deleteByUserId(userId); - await db.delete(sshFolders).where(eq(sshFolders.userId, userId)); + await createCurrentHostFolderRepository().deleteByUserId(userId); - await db.delete(commandHistory).where(eq(commandHistory.userId, userId)); + await createCurrentCommandHistoryRepository().deleteByUserId(userId); - await db.delete(hosts).where(eq(hosts.userId, userId)); - await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId)); + await createCurrentHostHealthRepository().deleteByUserId(userId); + await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId); + await createCurrentHostRepository().deleteByUserId(userId); + await createCurrentCredentialRepository().deleteByUserId(userId); - await db.delete(networkTopology).where(eq(networkTopology.userId, userId)); - await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId)); - await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)); - await db.delete(userPreferences).where(eq(userPreferences.userId, userId)); + await createCurrentNetworkTopologyRepository().deleteByUserId(userId); + await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId); + await createCurrentHomepageItemRepository().deleteByUserId(userId); + await createCurrentHomepageLayoutRepository().deleteByUserId(userId); + await createCurrentC2sTunnelPresetRepository().deleteByUserId(userId); + await createCurrentOpksshTokenRepository().deleteByUserId(userId); + await createCurrentVaultTokenRepository().deleteByUserId(userId); + await createCurrentVaultProfileRepository().deleteByUserId(userId); + await createCurrentTermixIdentityCaRepository().deleteByUserId(userId); + await createCurrentTermixIdentityRepository().deleteByUserId(userId); + await createCurrentTmuxSessionTagRepository().deleteByUserId(userId); + await createCurrentOpenTabRepository().deleteByUserId(userId); + await createCurrentUserPreferenceRepository().deleteByUserId(userId); - db.$client - .prepare("DELETE FROM settings WHERE key LIKE ?") - .run(`user_%_${userId}`); + await createCurrentSettingsRepository().deleteLike(`user_%_${userId}`); - await db.delete(users).where(eq(users.id, userId)); + await createCurrentUserRepository().delete(userId); authLogger.success("User and all related data deleted successfully", { operation: "delete_user_and_related_data_complete", diff --git a/src/backend/database/routes/homepage-items-routes.ts b/src/backend/database/routes/homepage-items-routes.ts index a38a6c1f..fa6583b9 100644 --- a/src/backend/database/routes/homepage-items-routes.ts +++ b/src/backend/database/routes/homepage-items-routes.ts @@ -1,10 +1,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, Response } from "express"; -import { and, asc, eq } from "drizzle-orm"; import { homepageLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { homepageItems } from "../db/schema.js"; -import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { createCurrentHomepageItemRepository } from "../repositories/current-homepage-item-repository.js"; import express from "express"; export const homepageItemsRouter = express.Router(); @@ -26,11 +23,8 @@ export const homepageItemsRouter = express.Router(); homepageItemsRouter.get("/", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const items = await db - .select() - .from(homepageItems) - .where(eq(homepageItems.userId, userId)) - .orderBy(asc(homepageItems.id)); + const items = + await createCurrentHomepageItemRepository().listByUserId(userId); res.json(items); } catch (err) { homepageLogger.error("Failed to fetch homepage items", err); @@ -78,18 +72,14 @@ homepageItemsRouter.post("/", async (req: Request, res: Response) => { } try { - const [created] = await db - .insert(homepageItems) - .values({ - userId, + const created = await createCurrentHomepageItemRepository().createForUser( + userId, + { typeId, title: title ?? null, config: config ? JSON.stringify(config) : "{}", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - .returning(); - DatabaseSaveTrigger.triggerSave("homepage_item_created"); + }, + ); res.status(201).json(created); } catch (err) { homepageLogger.error("Failed to create homepage item", err); @@ -140,33 +130,18 @@ homepageItemsRouter.put("/:id", async (req: Request, res: Response) => { const { title, config } = req.body; try { - const existing = await db - .select() - .from(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); - - if (existing.length === 0) { + const itemRepository = createCurrentHomepageItemRepository(); + const existing = await itemRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Not found" }); } - const updates: Partial<{ - title: string | null; - config: string; - updatedAt: string; - }> = { - updatedAt: new Date().toISOString(), - }; + const updates: Parameters[2] = {}; if (title !== undefined) updates.title = title; if (config !== undefined) updates.config = JSON.stringify(config); - const [updated] = await db - .update(homepageItems) - .set(updates) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) - .returning(); - - DatabaseSaveTrigger.triggerSave("homepage_item_updated"); - res.json(updated); + const updated = await itemRepository.updateForUser(userId, id, updates); + res.json(updated ?? existing); } catch (err) { homepageLogger.error("Failed to update homepage item", err); res.status(500).json({ error: "Failed to update homepage item" }); @@ -203,20 +178,13 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => { if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const existing = await db - .select() - .from(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); - - if (existing.length === 0) { + const itemRepository = createCurrentHomepageItemRepository(); + const existing = await itemRepository.findByIdForUser(userId, id); + if (!existing) { return res.status(404).json({ error: "Not found" }); } - await db - .delete(homepageItems) - .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); - - DatabaseSaveTrigger.triggerSave("homepage_item_deleted"); + await itemRepository.deleteForUser(userId, id); res.json({ message: "Homepage item deleted" }); } catch (err) { homepageLogger.error("Failed to delete homepage item", err); diff --git a/src/backend/database/routes/homepage-layout-routes.ts b/src/backend/database/routes/homepage-layout-routes.ts index 322917cb..16566a7b 100644 --- a/src/backend/database/routes/homepage-layout-routes.ts +++ b/src/backend/database/routes/homepage-layout-routes.ts @@ -1,11 +1,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, Response } from "express"; -import { eq } from "drizzle-orm"; import { homepageLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { homepageLayouts } from "../db/schema.js"; -import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import express from "express"; +import { createCurrentHomepageLayoutRepository } from "../repositories/current-homepage-layout-repository.js"; export const homepageLayoutRouter = express.Router(); @@ -26,16 +23,13 @@ export const homepageLayoutRouter = express.Router(); homepageLayoutRouter.get("/", async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = await db - .select() - .from(homepageLayouts) - .where(eq(homepageLayouts.userId, userId)); + const row = + await createCurrentHomepageLayoutRepository().findByUserId(userId); - if (rows.length === 0) { + if (!row) { return res.json(null); } - const row = rows[0]; const parsed = JSON.parse(row.layout || "{}"); res.json({ ...row, layout: parsed }); } catch (err) { @@ -76,32 +70,15 @@ homepageLayoutRouter.put("/", async (req: Request, res: Response) => { const layoutData = req.body; try { - const existing = await db - .select({ id: homepageLayouts.id }) - .from(homepageLayouts) - .where(eq(homepageLayouts.userId, userId)); - const layoutJson = JSON.stringify(layoutData); const now = new Date().toISOString(); - - if (existing.length === 0) { - const [created] = await db - .insert(homepageLayouts) - .values({ userId, layout: layoutJson, updatedAt: now }) - .returning(); - const parsed = JSON.parse(created.layout); - DatabaseSaveTrigger.triggerSave("homepage_layout_saved"); - return res.json({ ...created, layout: parsed }); - } - - const [updated] = await db - .update(homepageLayouts) - .set({ layout: layoutJson, updatedAt: now }) - .where(eq(homepageLayouts.userId, userId)) - .returning(); + const updated = await createCurrentHomepageLayoutRepository().upsertForUser( + userId, + layoutJson, + now, + ); const parsed = JSON.parse(updated.layout); - DatabaseSaveTrigger.triggerSave("homepage_layout_saved"); res.json({ ...updated, layout: parsed }); } catch (err) { homepageLogger.error("Failed to save homepage layout", err); diff --git a/src/backend/database/routes/host-autostart-routes.ts b/src/backend/database/routes/host-autostart-routes.ts index e4466509..61d2881b 100644 --- a/src/backend/database/routes/host-autostart-routes.ts +++ b/src/backend/database/routes/host-autostart-routes.ts @@ -1,10 +1,8 @@ import type { Request, RequestHandler, Response, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import { and, eq, isNotNull, or } from "drizzle-orm"; import { DataCrypto } from "../../utils/data-crypto.js"; import { sshLogger } from "../../utils/logger.js"; -import { db, DatabaseSaveTrigger } from "../db/index.js"; -import { hosts } from "../db/schema.js"; +import { createCurrentHostRepository } from "../repositories/current-host-repository.js"; type HostAutostartRoutesDeps = { authenticateJWT: RequestHandler; @@ -79,12 +77,13 @@ export function registerHostAutostartRoutes( }); } - const sshConfig = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId))); + const hostRepository = createCurrentHostRepository(); + const config = await hostRepository.findByIdForUser( + userId, + sshConfigId, + ); - if (sshConfig.length === 0) { + if (!config) { sshLogger.warn("SSH config not found for autostart enable", { operation: "autostart_enable_failed", userId, @@ -96,8 +95,6 @@ export function registerHostAutostartRoutes( }); } - const config = sshConfig[0]; - const decryptedConfig = DataCrypto.decryptRecord( "ssh_data", config, @@ -109,6 +106,7 @@ export function registerHostAutostartRoutes( if (config.tunnelConnections) { try { const tunnelConnections = JSON.parse(config.tunnelConnections); + const endpointHosts = await hostRepository.listByUserId(userId); const resolvedConnections = await Promise.all( tunnelConnections.map(async (tunnel: Record) => { @@ -118,11 +116,6 @@ export function registerHostAutostartRoutes( !tunnel.endpointPassword && !tunnel.endpointKey ) { - const endpointHosts = await db - .select() - .from(hosts) - .where(eq(hosts.userId, userId)); - const endpointHost = endpointHosts.find( (h) => h.name === tunnel.endpointHost || @@ -160,25 +153,12 @@ export function registerHostAutostartRoutes( } } - await db - .update(hosts) - .set({ - autostartPassword: decryptedConfig.password || null, - autostartKey: decryptedConfig.key || null, - autostartKeyPassword: decryptedConfig.keyPassword || null, - tunnelConnections: updatedTunnelConnections, - }) - .where(eq(hosts.id, sshConfigId)); - - try { - await DatabaseSaveTrigger.triggerSave(); - } catch (saveError) { - sshLogger.warn("Database save failed after autostart", { - operation: "autostart_db_save_failed", - error: - saveError instanceof Error ? saveError.message : "Unknown error", - }); - } + await hostRepository.updateForUser(userId, sshConfigId, { + autostartPassword: decryptedConfig.password || null, + autostartKey: decryptedConfig.key || null, + autostartKeyPassword: decryptedConfig.keyPassword || null, + tunnelConnections: updatedTunnelConnections, + }); res.json({ message: "AutoStart enabled successfully", @@ -240,14 +220,11 @@ export function registerHostAutostartRoutes( } try { - await db - .update(hosts) - .set({ - autostartPassword: null, - autostartKey: null, - autostartKeyPassword: null, - }) - .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId))); + await createCurrentHostRepository().updateForUser(userId, sshConfigId, { + autostartPassword: null, + autostartKey: null, + autostartKeyPassword: null, + }); res.json({ message: "AutoStart disabled successfully", @@ -285,18 +262,9 @@ export function registerHostAutostartRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const autostartConfigs = await db - .select() - .from(hosts) - .where( - and( - eq(hosts.userId, userId), - or( - isNotNull(hosts.autostartPassword), - isNotNull(hosts.autostartKey), - ), - ), - ); + const autostartConfigs = ( + await createCurrentHostRepository().listByUserId(userId) + ).filter((config) => config.autostartPassword || config.autostartKey); const statusList = autostartConfigs.map((config) => ({ sshConfigId: config.id, diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 176439f4..0bd0b205 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -1,10 +1,9 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, eq, inArray } from "drizzle-orm"; import { sshLogger } from "../../utils/logger.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; -import { db, DatabaseSaveTrigger } from "../db/index.js"; -import { hosts, sshCredentials } from "../db/schema.js"; +import { createCurrentCredentialRepository } from "../repositories/current-credential-repository.js"; +import { createCurrentHostRepository } from "../repositories/current-host-repository.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; import { isNonEmptyString, isValidPort, @@ -190,15 +189,11 @@ export function registerHostBulkRoutes( } try { - const ownedHosts = await db - .select({ - id: hosts.id, - statsConfig: hosts.statsConfig, - credentialId: hosts.credentialId, - proxmoxConfig: hosts.proxmoxConfig, - }) - .from(hosts) - .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); + const hostRepository = createCurrentHostRepository(); + const ownedHosts = await hostRepository.listBulkUpdateState( + userId, + hostIds, + ); const ownedIds = ownedHosts.map((h) => h.id); const unauthorizedIds = hostIds.filter( @@ -236,10 +231,11 @@ export function registerHostBulkRoutes( simpleUpdates.enableProxmox = false; if (Object.keys(simpleUpdates).length > 0) { - await db - .update(hosts) - .set(simpleUpdates) - .where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId))); + await hostRepository.updateManyForUser( + userId, + ownedIds, + simpleUpdates, + ); } if (updates.statsConfig && typeof updates.statsConfig === "object") { @@ -249,10 +245,9 @@ export function registerHostBulkRoutes( ? JSON.parse(host.statsConfig as string) : {}; const merged = { ...existing, ...updates.statsConfig }; - await db - .update(hosts) - .set({ statsConfig: JSON.stringify(merged) }) - .where(and(eq(hosts.id, host.id), eq(hosts.userId, userId))); + await hostRepository.updateForUser(userId, host.id, { + statsConfig: JSON.stringify(merged), + }); } catch { errors.push(`Failed to update statsConfig for host ${host.id}`); } @@ -280,21 +275,16 @@ export function registerHostBulkRoutes( syncIntervalMinutes: existing.syncIntervalMinutes ?? 15, markMissingGuests: existing.markMissingGuests ?? true, }; - await db - .update(hosts) - .set({ - enableProxmox: true, - proxmoxConfig: JSON.stringify(merged), - }) - .where(and(eq(hosts.id, host.id), eq(hosts.userId, userId))); + await hostRepository.updateForUser(userId, host.id, { + enableProxmox: true, + proxmoxConfig: JSON.stringify(merged), + }); } catch { errors.push(`Failed to enable Proxmox for host ${host.id}`); } } } - DatabaseSaveTrigger.triggerSave("bulk_update"); - return res.json({ updated: ownedIds.length, failed: unauthorizedIds.length, @@ -345,16 +335,9 @@ export function registerHostBulkRoutes( }; try { - const existingCredentials = await SimpleDBOps.select< - Record - >( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)), - "ssh_credentials", - userId, - ); + const credentialRepository = createCurrentCredentialRepository(); + const existingCredentials = + await credentialRepository.listDecryptedByUserId(userId); for (const credential of existingCredentials) { addCredentialAlias(credential.name, credential.id as number); @@ -373,9 +356,8 @@ export function registerHostBulkRoutes( } const now = new Date().toISOString(); - const created = await SimpleDBOps.insert( - sshCredentials, - "ssh_credentials", + const created = await credentialRepository.createEncryptedForUser( + userId, { userId, name, @@ -398,7 +380,6 @@ export function registerHostBulkRoutes( createdAt: now, updatedAt: now, }, - userId, ); const createdCredential = created as Record; @@ -413,13 +394,13 @@ export function registerHostBulkRoutes( } let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); if (overwrite) { try { - const allHosts = await SimpleDBOps.select>( - db.select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId( + userId, + ); existingHostMap = new Map(); for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`; @@ -527,23 +508,14 @@ export function registerHostBulkRoutes( hostData.authType === "credential" && hostData.credentialId ) { - const cred = await db - .select({ id: sshCredentials.id }) - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, hostData.credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .limit(1); + const credentialRepository = createCurrentCredentialRepository(); + const cred = await credentialRepository.findByIdForUser( + userId, + hostData.credentialId, + ); - if (cred.length === 0) { - const fallback = await db - .select({ id: sshCredentials.id }) - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)) - .limit(1); + if (!cred) { + const fallback = await credentialRepository.listByUserId(userId); if (fallback.length > 0) { hostData.credentialId = fallback[0].id; @@ -678,17 +650,15 @@ export function registerHostBulkRoutes( const existing = existingHostMap?.get(lookupKey); if (existing) { - await SimpleDBOps.update( - hosts, - "ssh_data", - eq(hosts.id, existing.id), - sshDataObj, + await hostRepository.updateEncryptedForUser( userId, + existing.id, + sshDataObj, ); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); - await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId); + await hostRepository.createEncryptedForUser(userId, sshDataObj); results.success++; } } catch (error) { @@ -754,7 +724,7 @@ export function registerHostBulkRoutes( let parsed: SSHConfigHost[]; try { parsed = parseSSHConfig(content); - } catch (err) { + } catch { return res .status(400) .json({ error: "Failed to parse SSH config file" }); @@ -796,13 +766,13 @@ export function registerHostBulkRoutes( }; let existingHostMap: Map | undefined; + const hostRepository = createCurrentHostRepository(); if (overwrite) { try { - const allHosts = await SimpleDBOps.select>( - db.select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId( + userId, + ); existingHostMap = new Map(); for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`; @@ -886,17 +856,15 @@ export function registerHostBulkRoutes( const existing = existingHostMap?.get(lookupKey); if (existing) { - await SimpleDBOps.update( - hosts, - "ssh_data", - eq(hosts.id, existing.id), - sshDataObj, + await hostRepository.updateEncryptedForUser( userId, + existing.id, + sshDataObj, ); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); - await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId); + await hostRepository.createEncryptedForUser(userId, sshDataObj); results.success++; } } catch (error) { diff --git a/src/backend/database/routes/host-command-history-routes.ts b/src/backend/database/routes/host-command-history-routes.ts index d120322b..31bf1992 100644 --- a/src/backend/database/routes/host-command-history-routes.ts +++ b/src/backend/database/routes/host-command-history-routes.ts @@ -1,9 +1,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, desc, eq } from "drizzle-orm"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { commandHistory } from "../db/schema.js"; +import { createCurrentCommandHistoryRepository } from "../repositories/current-command-history-repository.js"; import { isNonEmptyString } from "./host-normalizers.js"; export function registerHostCommandHistoryRoutes( @@ -52,22 +50,13 @@ export function registerHostCommandHistoryRoutes( } try { - const history = await db - .select({ - id: commandHistory.id, - command: commandHistory.command, - }) - .from(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostId), - ), - ) - .orderBy(desc(commandHistory.executedAt)) - .limit(200); + const history = + await createCurrentCommandHistoryRepository().listCommandsForHost( + userId, + hostId, + ); - res.json(history.map((h) => h.command)); + res.json(history); } catch (err) { sshLogger.error("Failed to fetch command history from database", err, { operation: "command_history_fetch", @@ -123,15 +112,11 @@ export function registerHostCommandHistoryRoutes( } try { - await db - .delete(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostId), - eq(commandHistory.command, command), - ), - ); + await createCurrentCommandHistoryRepository().deleteCommandForHost( + userId, + hostId, + command, + ); res.json({ message: "Command deleted from history" }); } catch (err) { diff --git a/src/backend/database/routes/host-file-manager-bookmark-routes.ts b/src/backend/database/routes/host-file-manager-bookmark-routes.ts index 9260f51e..276b1eec 100644 --- a/src/backend/database/routes/host-file-manager-bookmark-routes.ts +++ b/src/backend/database/routes/host-file-manager-bookmark-routes.ts @@ -1,13 +1,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, desc, eq } from "drizzle-orm"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { - fileManagerPinned, - fileManagerRecent, - fileManagerShortcuts, -} from "../db/schema.js"; +import { createCurrentFileManagerBookmarkRepository } from "../repositories/current-file-manager-bookmark-repository.js"; import { isNonEmptyString } from "./host-normalizers.js"; export function registerHostFileManagerBookmarkRoutes( @@ -57,17 +51,12 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const recentFiles = await db - .select() - .from(fileManagerRecent) - .where( - and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - ), - ) - .orderBy(desc(fileManagerRecent.lastOpened)) - .limit(20); + const recentFiles = + await createCurrentFileManagerBookmarkRepository().listRecentForHost( + userId, + hostId, + 20, + ); res.json(recentFiles); } catch (err) { @@ -119,31 +108,14 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const existing = await db - .select() - .from(fileManagerRecent) - .where( - and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - eq(fileManagerRecent.path, path), - ), - ); - - if (existing.length > 0) { - await db - .update(fileManagerRecent) - .set({ lastOpened: new Date().toISOString() }) - .where(eq(fileManagerRecent.id, existing[0].id)); - } else { - await db.insert(fileManagerRecent).values({ - userId, + await createCurrentFileManagerBookmarkRepository().upsertRecent( + userId, + { hostId, path, - name: name || path.split("/").pop() || "Unknown", - lastOpened: new Date().toISOString(), - }); - } + name, + }, + ); res.json({ message: "Recent file added" }); } catch (err) { @@ -193,15 +165,10 @@ export function registerHostFileManagerBookmarkRoutes( } try { - await db - .delete(fileManagerRecent) - .where( - and( - eq(fileManagerRecent.userId, userId), - eq(fileManagerRecent.hostId, hostId), - eq(fileManagerRecent.path, path), - ), - ); + await createCurrentFileManagerBookmarkRepository().deleteRecentForHostPath( + userId, + { hostId, path }, + ); res.json({ message: "Recent file removed" }); } catch (err) { @@ -254,16 +221,11 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const pinnedFiles = await db - .select() - .from(fileManagerPinned) - .where( - and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - ), - ) - .orderBy(desc(fileManagerPinned.pinnedAt)); + const pinnedFiles = + await createCurrentFileManagerBookmarkRepository().listPinnedForHost( + userId, + hostId, + ); res.json(pinnedFiles); } catch (err) { @@ -317,29 +279,16 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const existing = await db - .select() - .from(fileManagerPinned) - .where( - and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - eq(fileManagerPinned.path, path), - ), + const created = + await createCurrentFileManagerBookmarkRepository().createPinned( + userId, + { hostId, path, name }, ); - if (existing.length > 0) { + if (!created) { return res.status(409).json({ error: "File already pinned" }); } - await db.insert(fileManagerPinned).values({ - userId, - hostId, - path, - name: name || path.split("/").pop() || "Unknown", - pinnedAt: new Date().toISOString(), - }); - res.json({ message: "File pinned" }); } catch (err) { sshLogger.error("Failed to pin file", err); @@ -388,15 +337,10 @@ export function registerHostFileManagerBookmarkRoutes( } try { - await db - .delete(fileManagerPinned) - .where( - and( - eq(fileManagerPinned.userId, userId), - eq(fileManagerPinned.hostId, hostId), - eq(fileManagerPinned.path, path), - ), - ); + await createCurrentFileManagerBookmarkRepository().deletePinnedForHostPath( + userId, + { hostId, path }, + ); res.json({ message: "Pinned file removed" }); } catch (err) { @@ -449,16 +393,11 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const shortcuts = await db - .select() - .from(fileManagerShortcuts) - .where( - and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - ), - ) - .orderBy(desc(fileManagerShortcuts.createdAt)); + const shortcuts = + await createCurrentFileManagerBookmarkRepository().listShortcutsForHost( + userId, + hostId, + ); res.json(shortcuts); } catch (err) { @@ -512,29 +451,16 @@ export function registerHostFileManagerBookmarkRoutes( } try { - const existing = await db - .select() - .from(fileManagerShortcuts) - .where( - and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - eq(fileManagerShortcuts.path, path), - ), + const created = + await createCurrentFileManagerBookmarkRepository().createShortcut( + userId, + { hostId, path, name }, ); - if (existing.length > 0) { + if (!created) { return res.status(409).json({ error: "Shortcut already exists" }); } - await db.insert(fileManagerShortcuts).values({ - userId, - hostId, - path, - name: name || path.split("/").pop() || "Unknown", - createdAt: new Date().toISOString(), - }); - res.json({ message: "Shortcut added" }); } catch (err) { sshLogger.error("Failed to add shortcut", err); @@ -583,15 +509,10 @@ export function registerHostFileManagerBookmarkRoutes( } try { - await db - .delete(fileManagerShortcuts) - .where( - and( - eq(fileManagerShortcuts.userId, userId), - eq(fileManagerShortcuts.hostId, hostId), - eq(fileManagerShortcuts.path, path), - ), - ); + await createCurrentFileManagerBookmarkRepository().deleteShortcutForHostPath( + userId, + { hostId, path }, + ); res.json({ message: "Shortcut removed" }); } catch (err) { diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index c6d2da55..de855442 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -1,23 +1,14 @@ import type { Request, RequestHandler, Response, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; -import { and, eq, inArray, like, or, sql } from "drizzle-orm"; import { databaseLogger, sshLogger } from "../../utils/logger.js"; -import { db, DatabaseSaveTrigger } from "../db/index.js"; -import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; -import { - commandHistory, - fileManagerPinned, - fileManagerRecent, - fileManagerShortcuts, - hostAccess, - hosts, - recentActivity, - sessionRecordings, - sshCredentialUsage, - sshCredentials, - sshFolders, - transferRecent, -} from "../db/schema.js"; +import { createCurrentCommandHistoryRepository } from "../repositories/current-command-history-repository.js"; +import { createCurrentFileManagerBookmarkRepository } from "../repositories/current-file-manager-bookmark-repository.js"; +import { createCurrentHostFolderRepository } from "../repositories/current-host-folder-repository.js"; +import { createCurrentRecentActivityRepository } from "../repositories/current-recent-activity-repository.js"; +import { createCurrentRbacAccessRepository } from "../repositories/current-rbac-access-repository.js"; +import { createCurrentSshCredentialUsageRepository } from "../repositories/current-ssh-credential-usage-repository.js"; +import { createCurrentSessionRecordingRepository } from "../repositories/current-session-recording-repository.js"; +import { createCurrentTransferRecentRepository } from "../repositories/current-transfer-recent-repository.js"; import { isNonEmptyString } from "./host-normalizers.js"; type HostFolderRoutesDeps = { @@ -75,49 +66,17 @@ export function registerHostFolderRoutes( } try { - const now = new Date().toISOString(); - const oldPrefix = `${oldName} / `; - const newPrefix = `${newName} / `; - const childLike = `${oldPrefix}%`; - - // folder is a plaintext column, so a SQL expression renames the exact - // folder and re-paths every nested child in one statement. - const renameExpr = (col: SQLiteColumn) => - sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`; - - const folderMatch = (col: SQLiteColumn) => - or(eq(col, oldName), like(col, childLike)); - - const updatedHosts = await db - .update(hosts) - .set({ folder: renameExpr(hosts.folder), updatedAt: now }) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))) - .returning(); - - const updatedCredentials = await db - .update(sshCredentials) - .set({ folder: renameExpr(sshCredentials.folder), updatedAt: now }) - .where( - and( - eq(sshCredentials.userId, userId), - folderMatch(sshCredentials.folder), - ), - ) - .returning(); - - DatabaseSaveTrigger.triggerSave("folder_rename"); - - await db - .update(sshFolders) - .set({ name: renameExpr(sshFolders.name), updatedAt: now }) - .where( - and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)), + const { updatedHosts, updatedCredentials } = + await createCurrentHostFolderRepository().renameFolder( + userId, + oldName, + newName, ); res.json({ message: "Folder renamed successfully", - updatedHosts: updatedHosts.length, - updatedCredentials: updatedCredentials.length, + updatedHosts, + updatedCredentials, }); } catch (err) { sshLogger.error("Failed to rename folder", err, { @@ -158,10 +117,8 @@ export function registerHostFolderRoutes( } try { - const folders = await db - .select() - .from(sshFolders) - .where(eq(sshFolders.userId, userId)); + const folders = + await createCurrentHostFolderRepository().listFolders(userId); res.json(folders); } catch (err) { @@ -215,46 +172,28 @@ export function registerHostFolderRoutes( } try { - const existing = await db - .select() - .from(sshFolders) - .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) - .limit(1); + const { folder, created } = + await createCurrentHostFolderRepository().upsertMetadata( + userId, + name, + color, + icon, + ); - if (existing.length > 0) { + if (!created) { databaseLogger.info("Updating SSH folder", { operation: "folder_update", userId, - folderId: existing[0].id, + folderId: folder.id, }); - await db - .update(sshFolders) - .set({ - color, - icon, - updatedAt: new Date().toISOString(), - }) - .where( - and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), - ); } else { databaseLogger.info("Creating SSH folder", { operation: "folder_create", userId, name, }); - await db.insert(sshFolders).values({ - userId, - name, - color, - icon, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); } - DatabaseSaveTrigger.triggerSave("folder_metadata_update"); - res.json({ message: "Folder metadata updated successfully" }); } catch (err) { sshLogger.error("Failed to update folder metadata", err, { @@ -308,76 +247,48 @@ export function registerHostFolderRoutes( }); try { - // Match the folder itself and any nested children (e.g. "fgh / sub"). - const childLike = `${folderName} / %`; - const folderMatch = (col: SQLiteColumn) => - or(eq(col, folderName), like(col, childLike)); - - const hostsToDelete = await db - .select() - .from(hosts) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); + const hostFolderRepository = createCurrentHostFolderRepository(); + const hostsToDelete = await hostFolderRepository.listHostsInFolder( + userId, + folderName, + ); const hostIds = hostsToDelete.map((host) => host.id); if (hostIds.length > 0) { - await db - .delete(fileManagerRecent) - .where(inArray(fileManagerRecent.hostId, hostIds)); - - await db - .delete(fileManagerPinned) - .where(inArray(fileManagerPinned.hostId, hostIds)); - - await db - .delete(fileManagerShortcuts) - .where(inArray(fileManagerShortcuts.hostId, hostIds)); - - await db - .delete(transferRecent) - .where( - or( - inArray(transferRecent.sourceHostId, hostIds), - inArray(transferRecent.destHostId, hostIds), - ), - ); - - await db - .delete(commandHistory) - .where(inArray(commandHistory.hostId, hostIds)); - - await db - .delete(sshCredentialUsage) - .where(inArray(sshCredentialUsage.hostId, hostIds)); - - await db - .delete(recentActivity) - .where(inArray(recentActivity.hostId, hostIds)); - - await db - .delete(hostAccess) - .where(inArray(hostAccess.hostId, hostIds)); - - await db - .delete(sessionRecordings) - .where(inArray(sessionRecordings.hostId, hostIds)); - } - - if (hostIds.length > 0) { - await db - .delete(hosts) - .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); - } - - // Always remove the folder records (and nested children), even when the - // folder held no hosts, so empty folders don't reappear on reload. - await db - .delete(sshFolders) - .where( - and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)), + await createCurrentFileManagerBookmarkRepository().deleteByHostIds( + hostIds, ); - DatabaseSaveTrigger.triggerSave("folder_hosts_delete"); + await createCurrentTransferRecentRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentCommandHistoryRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentSshCredentialUsageRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentRecentActivityRepository().deleteByHostIds( + hostIds, + ); + + await createCurrentRbacAccessRepository().deleteHostAccessForHosts( + hostIds, + ); + + await createCurrentSessionRecordingRepository().deleteByHostIds( + hostIds, + ); + } + + await hostFolderRepository.deleteHostsAndFolderRecords( + userId, + folderName, + ); try { const axios = (await import("axios")).default; diff --git a/src/backend/database/routes/host-internal-routes.ts b/src/backend/database/routes/host-internal-routes.ts index 32988f61..392e12e3 100644 --- a/src/backend/database/routes/host-internal-routes.ts +++ b/src/backend/database/routes/host-internal-routes.ts @@ -1,9 +1,7 @@ import type { Request, Response, Router } from "express"; -import { and, eq, isNotNull } from "drizzle-orm"; import { SystemCrypto } from "../../utils/system-crypto.js"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { hosts } from "../db/schema.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; export function registerHostInternalRoutes(router: Router): void { /** @@ -45,12 +43,8 @@ export function registerHostInternalRoutes(router: Router): void { } try { - const autostartHosts = await db - .select() - .from(hosts) - .where( - and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)), - ); + const autostartHosts = + await createCurrentHostResolutionRepository().listHostsWithTunnelConnections(); const result = autostartHosts .map((host) => { @@ -134,7 +128,8 @@ export function registerHostInternalRoutes(router: Router): void { .json({ error: "Invalid internal authentication token" }); } - const allHosts = await db.select().from(hosts); + const allHosts = + await createCurrentHostResolutionRepository().listAllHosts(); const result = allHosts.map((host) => { const tunnelConnections = host.tunnelConnections diff --git a/src/backend/database/routes/host-network-routes.ts b/src/backend/database/routes/host-network-routes.ts index 654aa7c6..db5ca0c0 100644 --- a/src/backend/database/routes/host-network-routes.ts +++ b/src/backend/database/routes/host-network-routes.ts @@ -1,10 +1,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; -import { and, eq } from "drizzle-orm"; import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js"; import { sshLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { hosts } from "../db/schema.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; interface HostNetworkRoutesDeps { authenticateJWT: RequestHandler; @@ -100,16 +98,12 @@ export function registerHostNetworkRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const host = await db - .select({ - macAddress: hosts.macAddress, - wolBroadcastAddress: hosts.wolBroadcastAddress, - }) - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .then((rows) => rows[0]); + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); - if (!host) { + if (!host || host.userId !== userId) { return res.status(404).json({ error: "Host not found" }); } diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index b71b3ee6..92c7cae0 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -1,26 +1,9 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - hosts, - sshCredentials, - sshCredentialUsage, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - transferRecent, - commandHistory, - recentActivity, - hostAccess, - userRoles, - sessionRecordings, -} from "../db/schema.js"; -import { eq, and, or, isNull, gte, sql, inArray, desc } from "drizzle-orm"; import type { Request, Response } from "express"; import axios from "axios"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -29,6 +12,13 @@ import { pickResolvedPassword, pickResolvedUsername, } from "../../ssh/credential-username.js"; +import { createCurrentCommandHistoryRepository } from "../repositories/current-command-history-repository.js"; +import { createCurrentFileManagerBookmarkRepository } from "../repositories/current-file-manager-bookmark-repository.js"; +import { createCurrentOpksshTokenRepository } from "../repositories/current-opkssh-token-repository.js"; +import { createCurrentRecentActivityRepository } from "../repositories/current-recent-activity-repository.js"; +import { createCurrentSshCredentialUsageRepository } from "../repositories/current-ssh-credential-usage-repository.js"; +import { createCurrentSessionRecordingRepository } from "../repositories/current-session-recording-repository.js"; +import { createCurrentTransferRecentRepository } from "../repositories/current-transfer-recent-repository.js"; import { isNonEmptyString, isValidPort, @@ -48,6 +38,11 @@ import { requireHostEnrollmentAccessForPath, } from "./host-enrollment-auth.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { createCurrentRbacAccessRepository } from "../repositories/current-rbac-access-repository.js"; +import { createCurrentRoleRepository } from "../repositories/current-role-repository.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; +import { createCurrentHostRepository } from "../repositories/current-host-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; const router = express.Router(); @@ -55,6 +50,11 @@ const upload = multer({ storage: multer.memoryStorage() }); const STATS_SERVER_URL = "http://localhost:30005"; +async function getAuditUsername(userId: string): Promise { + const actor = await createCurrentUserRepository().findById(userId); + return actor?.username ?? userId; +} + function notifyStatsHostUpdated( hostId: number, headers: Pick, @@ -86,69 +86,6 @@ const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -type ShareCredentialExport = { - alias: string; - name: string; - authType: "password" | "key"; - username: string | null; - description: string | null; - folder: string | null; - tags: string[]; - keyType: string | null; -}; - -function parseJsonField(value: unknown, fallback: unknown) { - if (!value || typeof value !== "string") return fallback; - try { - return JSON.parse(value); - } catch { - return fallback; - } -} - -function splitTags(value: unknown): string[] { - if (Array.isArray(value)) - return value.filter((tag) => typeof tag === "string"); - if (typeof value !== "string") return []; - return value.split(",").filter(Boolean); -} - -function uniqueAlias(base: string, used: Set): string { - const normalized = - base - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") || "credential"; - let alias = normalized; - let suffix = 2; - while (used.has(alias)) { - alias = `${normalized}-${suffix++}`; - } - used.add(alias); - return alias; -} - -function safeCredentialExport( - credential: Record, - alias: string, -): ShareCredentialExport { - return { - alias, - name: String(credential.name || alias), - authType: credential.authType === "key" ? "key" : "password", - username: - typeof credential.username === "string" ? credential.username : null, - description: - typeof credential.description === "string" - ? credential.description - : null, - folder: typeof credential.folder === "string" ? credential.folder : null, - tags: splitTags(credential.tags), - keyType: typeof credential.keyType === "string" ? credential.keyType : null, - }; -} - registerHostInternalRoutes(router); /** @@ -482,11 +419,9 @@ router.post( sshDataObj.telnetPassword = telnetPassword || null; try { - const result = await SimpleDBOps.insert( - hosts, - "ssh_data", - sshDataObj, + const result = await createCurrentHostRepository().createEncryptedForUser( userId, + sshDataObj, ); if (!result) { @@ -513,15 +448,9 @@ router.post( }); const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req); - const { users: usersTable } = await import("../db/schema.js"); - const chActor = await db - .select({ username: usersTable.username }) - .from(usersTable) - .where(eq(usersTable.id, userId)) - .limit(1); await logAudit({ userId, - username: chActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "create_host", resourceType: "host", resourceId: String(createdHost.id), @@ -714,27 +643,19 @@ router.post( let resolvedUsername = username; if (authType === "credential" && credentialId) { - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); + const cred = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + Number(credentialId), + userId, + ); - if (!credentials || credentials.length === 0) { + if (!cred) { return res.status(404).json({ error: "Credential not found" }); } - const cred = credentials[0]; - - resolvedPassword = pickResolvedPassword(password, cred.password); + resolvedPassword = pickResolvedPassword(password, cred.password) as + | string + | undefined; resolvedKey = cred.privateKey as string | undefined; resolvedKeyPassword = cred.keyPassword as string | undefined; resolvedKeyType = cred.keyType as string | undefined; @@ -1170,20 +1091,12 @@ router.put( }); } - const hostRecord = await db - .select({ - userId: hosts.userId, - credentialId: hosts.credentialId, - rdpCredentialId: hosts.rdpCredentialId, - vncCredentialId: hosts.vncCredentialId, - telnetCredentialId: hosts.telnetCredentialId, - authType: hosts.authType, - }) - .from(hosts) - .where(eq(hosts.id, Number(hostId))) - .limit(1); + const hostRecord = + await createCurrentHostResolutionRepository().findHostUpdateState( + Number(hostId), + ); - if (hostRecord.length === 0) { + if (!hostRecord) { sshLogger.warn("Host not found for update", { operation: "host_update", hostId: parseInt(hostId), @@ -1192,12 +1105,12 @@ router.put( return res.status(404).json({ error: "Host not found" }); } - const ownerId = hostRecord[0].userId; + const ownerId = hostRecord.userId; if ( !accessInfo.isOwner && sshDataObj.credentialId !== undefined && - sshDataObj.credentialId !== hostRecord[0].credentialId + sshDataObj.credentialId !== hostRecord.credentialId ) { return res.status(403).json({ error: "Only the host owner can change the credential", @@ -1207,7 +1120,7 @@ router.put( if ( !accessInfo.isOwner && sshDataObj.authType !== undefined && - sshDataObj.authType !== hostRecord[0].authType + sshDataObj.authType !== hostRecord.authType ) { return res.status(403).json({ error: "Only the host owner can change the authentication type", @@ -1218,54 +1131,49 @@ router.put( const newCredId = sshDataObj.credentialId !== undefined ? sshDataObj.credentialId - : hostRecord[0].credentialId; + : hostRecord.credentialId; const newRdpCredId = sshDataObj.rdpCredentialId !== undefined ? sshDataObj.rdpCredentialId - : hostRecord[0].rdpCredentialId; + : hostRecord.rdpCredentialId; const newVncCredId = sshDataObj.vncCredentialId !== undefined ? sshDataObj.vncCredentialId - : hostRecord[0].vncCredentialId; + : hostRecord.vncCredentialId; const newTelnetCredId = sshDataObj.telnetCredentialId !== undefined ? sshDataObj.telnetCredentialId - : hostRecord[0].telnetCredentialId; + : hostRecord.telnetCredentialId; const hadCredential = - hostRecord[0].credentialId !== null || - hostRecord[0].rdpCredentialId !== null || - hostRecord[0].vncCredentialId !== null || - hostRecord[0].telnetCredentialId !== null; + hostRecord.credentialId !== null || + hostRecord.rdpCredentialId !== null || + hostRecord.vncCredentialId !== null || + hostRecord.telnetCredentialId !== null; const willHaveCredential = newCredId !== null || newRdpCredId !== null || newVncCredId !== null || newTelnetCredId !== null; if (hadCredential && !willHaveCredential) { - await db - .delete(hostAccess) - .where(eq(hostAccess.hostId, Number(hostId))); + await createCurrentRbacAccessRepository().deleteHostAccessForHost( + Number(hostId), + ); } } - await SimpleDBOps.update( - hosts, - "ssh_data", - eq(hosts.id, Number(hostId)), + await createCurrentHostRepository().updateEncryptedForUser( + ownerId, + Number(hostId), sshDataObj, - ownerId, ); - const updatedHosts = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(eq(hosts.id, Number(hostId))), - "ssh_data", - ownerId, - ); + const updatedHost = + await createCurrentHostResolutionRepository().findHostById( + Number(hostId), + ownerId, + ); - if (updatedHosts.length === 0) { + if (!updatedHost) { sshLogger.warn("Updated host not found after update", { operation: "host_update", hostId: parseInt(hostId), @@ -1274,7 +1182,6 @@ router.put( return res.status(404).json({ error: "Host not found after update" }); } - const updatedHost = updatedHosts[0]; const baseHost = transformHostResponse(updatedHost); const resolvedHost = @@ -1286,15 +1193,9 @@ router.put( }); const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req); - const { users: usersTableUpd } = await import("../db/schema.js"); - const uhActor = await db - .select({ username: usersTableUpd.username }) - .from(usersTableUpd) - .where(eq(usersTableUpd.id, userId)) - .limit(1); await logAudit({ userId, - username: uhActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "update_host", resourceType: "host", resourceId: hostId, @@ -1353,133 +1254,19 @@ router.get( try { const now = new Date().toISOString(); - const userRoleIds = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const accessEntries = + await createCurrentRbacAccessRepository().listVisibleHostAccessEntries( + userId, + roleIds, + now, + ); - const rawData = await db - .select({ - id: hosts.id, - userId: hosts.userId, - connectionType: hosts.connectionType, - name: hosts.name, - ip: hosts.ip, - port: hosts.port, - username: hosts.username, - folder: hosts.folder, - tags: hosts.tags, - pin: hosts.pin, - authType: hosts.authType, - password: hosts.password, - key: hosts.key, - keyPassword: hosts.keyPassword, - keyType: hosts.keyType, - enableTerminal: hosts.enableTerminal, - enableTunnel: hosts.enableTunnel, - tunnelConnections: hosts.tunnelConnections, - jumpHosts: hosts.jumpHosts, - enableFileManager: hosts.enableFileManager, - scpLegacy: hosts.scpLegacy, - defaultPath: hosts.defaultPath, - autostartPassword: hosts.autostartPassword, - autostartKey: hosts.autostartKey, - autostartKeyPassword: hosts.autostartKeyPassword, - forceKeyboardInteractive: hosts.forceKeyboardInteractive, - statsConfig: hosts.statsConfig, - terminalConfig: hosts.terminalConfig, - sudoPassword: hosts.sudoPassword, - createdAt: hosts.createdAt, - updatedAt: hosts.updatedAt, - credentialId: hosts.credentialId, - vaultProfileId: hosts.vaultProfileId, - overrideCredentialUsername: hosts.overrideCredentialUsername, - quickActions: hosts.quickActions, - notes: hosts.notes, - enableDocker: hosts.enableDocker, - enableProxmox: hosts.enableProxmox, - enableTmuxMonitor: hosts.enableTmuxMonitor, - showTerminalInSidebar: hosts.showTerminalInSidebar, - showFileManagerInSidebar: hosts.showFileManagerInSidebar, - showTunnelInSidebar: hosts.showTunnelInSidebar, - showDockerInSidebar: hosts.showDockerInSidebar, - showServerStatsInSidebar: hosts.showServerStatsInSidebar, - useSocks5: hosts.useSocks5, - socks5Host: hosts.socks5Host, - socks5Port: hosts.socks5Port, - socks5Username: hosts.socks5Username, - socks5Password: hosts.socks5Password, - socks5ProxyChain: hosts.socks5ProxyChain, - portKnockSequence: hosts.portKnockSequence, - domain: hosts.domain, - security: hosts.security, - ignoreCert: hosts.ignoreCert, - guacamoleConfig: hosts.guacamoleConfig, - macAddress: hosts.macAddress, - wolBroadcastAddress: hosts.wolBroadcastAddress, - dockerConfig: hosts.dockerConfig, - proxmoxConfig: hosts.proxmoxConfig, - enableSsh: hosts.enableSsh, - enableRdp: hosts.enableRdp, - enableVnc: hosts.enableVnc, - enableTelnet: hosts.enableTelnet, - sshPort: hosts.sshPort, - rdpPort: hosts.rdpPort, - vncPort: hosts.vncPort, - telnetPort: hosts.telnetPort, - rdpAuthType: hosts.rdpAuthType, - rdpCredentialId: hosts.rdpCredentialId, - rdpUser: hosts.rdpUser, - rdpPassword: hosts.rdpPassword, - rdpDomain: hosts.rdpDomain, - rdpSecurity: hosts.rdpSecurity, - rdpIgnoreCert: hosts.rdpIgnoreCert, - vncAuthType: hosts.vncAuthType, - vncCredentialId: hosts.vncCredentialId, - vncUser: hosts.vncUser, - vncPassword: hosts.vncPassword, - telnetAuthType: hosts.telnetAuthType, - telnetCredentialId: hosts.telnetCredentialId, - telnetUser: hosts.telnetUser, - telnetPassword: hosts.telnetPassword, - - ownerId: hosts.userId, - isShared: sql`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`, - permissionLevel: hostAccess.permissionLevel, - expiresAt: hostAccess.expiresAt, - }) - .from(hosts) - .leftJoin( - hostAccess, - and( - eq(hostAccess.hostId, hosts.id), - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? inArray(hostAccess.roleId, roleIds) - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - ) - .where( - or( - eq(hosts.userId, userId), - and( - eq(hostAccess.userId, userId), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - roleIds.length > 0 - ? and( - inArray(hostAccess.roleId, roleIds), - or( - isNull(hostAccess.expiresAt), - gte(hostAccess.expiresAt, now), - ), - ) - : sql`false`, - ), + const rawData = + await createCurrentHostResolutionRepository().listHostRowsForAccessList( + userId, + accessEntries, ); const ownHosts = rawData.filter((row) => row.userId === userId); @@ -1581,16 +1368,13 @@ router.get( return res.status(400).json({ error: "Invalid userId or hostId" }); } try { - const data = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); + const host = + await createCurrentHostResolutionRepository().findHostByIdForUser( + Number(hostId), + userId, + ); - if (data.length === 0) { + if (!host) { sshLogger.warn("SSH host not found", { operation: "host_fetch_by_id", hostId: parseInt(hostId), @@ -1599,7 +1383,6 @@ router.get( return res.status(404).json({ error: "SSH host not found" }); } - const host = data[0]; const result = transformHostResponse(host); const resolved = (await resolveHostCredentials(result, userId)) || result; @@ -1654,17 +1437,15 @@ router.get( } try { - const data = await SimpleDBOps.select( - db.select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, userId, ); - if (data.length === 0) { + if (!host) { return res.status(404).json({ error: "Host not found" }); } - const host = data[0]; const resolved = (await resolveHostCredentials(host, userId)) || host; let value = resolved[field]; @@ -1735,21 +1516,16 @@ router.get( } try { - const hostResults = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); + const host = + await createCurrentHostResolutionRepository().findHostByIdForUser( + Number(hostId), + userId, + ); - if (hostResults.length === 0) { + if (!host) { return res.status(404).json({ error: "SSH host not found" }); } - const host = hostResults[0]; - const resolvedHost = (await resolveHostCredentials(host, userId)) || host; const exportedConnectionType = @@ -1901,208 +1677,14 @@ router.get( requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const shareExport = - req.query.share === "1" || - req.query.share === "true" || - req.query.safe === "1" || - req.query.safe === "true"; if (!isNonEmptyString(userId)) { return res.status(400).json({ error: "Invalid userId" }); } try { - const allHosts = await SimpleDBOps.select( - db.select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); - - if (shareExport) { - const credentials = await SimpleDBOps.select>( - db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)), - "ssh_credentials", - userId, - ); - const credentialsById = new Map>(); - for (const credential of credentials) { - if (typeof credential.id === "number") { - credentialsById.set(credential.id, credential); - } - } - - const usedAliases = new Set(); - const exportedCredentials = new Map(); - const credentialIdAliases = new Map(); - const directCredentialAliases = new Map(); - - const addCredential = ( - credential: Record, - fallbackName: string, - ) => { - if (typeof credential.id === "number") { - const existing = credentialIdAliases.get(credential.id); - if (existing) return existing; - } - - const alias = uniqueAlias( - String(credential.name || fallbackName), - usedAliases, - ); - exportedCredentials.set( - alias, - safeCredentialExport(credential, alias), - ); - if (typeof credential.id === "number") { - credentialIdAliases.set(credential.id, alias); - } - return alias; - }; - - const addDirectCredential = ( - authType: "password" | "key", - username: unknown, - keyType?: unknown, - ) => { - const usernameText = - typeof username === "string" && username.trim() - ? username.trim() - : "user"; - const key = `${authType}:${usernameText}:${typeof keyType === "string" ? keyType : ""}`; - const existing = directCredentialAliases.get(key); - if (existing) return existing; - - const alias = uniqueAlias(`${usernameText}-${authType}`, usedAliases); - directCredentialAliases.set(key, alias); - exportedCredentials.set( - alias, - safeCredentialExport( - { - name: `${usernameText} ${authType}`, - authType, - username: usernameText, - keyType, - }, - alias, - ), - ); - return alias; - }; - - const exportedHosts = allHosts.map((host) => { - const exportedConnectionType = - (host.connectionType as string) || "ssh"; - const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes( - exportedConnectionType, - ); - - const baseExportData: Record = { - connectionType: exportedConnectionType, - name: host.name, - ip: host.ip, - port: host.port, - username: host.username, - folder: host.folder, - tags: splitTags(host.tags), - notes: host.notes || null, - }; - - if (isRemoteDesktop) { - return { - ...baseExportData, - domain: host.domain || null, - security: host.security || null, - ignoreCert: !!host.ignoreCert, - guacamoleConfig: parseJsonField(host.guacamoleConfig, null), - }; - } - - const exportData: Record = { - ...baseExportData, - authType: host.authType || "none", - enableTerminal: !!host.enableTerminal, - enableTunnel: !!host.enableTunnel, - enableFileManager: host.enableFileManager !== false, - enableDocker: !!host.enableDocker, - enableProxmox: !!host.enableProxmox, - enableTmuxMonitor: !!host.enableTmuxMonitor, - showTerminalInSidebar: !!host.showTerminalInSidebar, - showFileManagerInSidebar: !!host.showFileManagerInSidebar, - showTunnelInSidebar: !!host.showTunnelInSidebar, - showDockerInSidebar: !!host.showDockerInSidebar, - showServerStatsInSidebar: !!host.showServerStatsInSidebar, - defaultPath: host.defaultPath, - tunnelConnections: parseJsonField(host.tunnelConnections, []), - jumpHosts: parseJsonField(host.jumpHosts, null), - quickActions: parseJsonField(host.quickActions, null), - statsConfig: parseJsonField(host.statsConfig, null), - dockerConfig: parseJsonField(host.dockerConfig, null), - proxmoxConfig: parseJsonField(host.proxmoxConfig, null), - forceKeyboardInteractive: host.forceKeyboardInteractive === "true", - useSocks5: !!host.useSocks5, - socks5Host: host.socks5Host || null, - socks5Port: host.socks5Port || null, - socks5Username: host.socks5Username || null, - socks5ProxyChain: parseJsonField(host.socks5ProxyChain, null), - portKnockSequence: parseJsonField(host.portKnockSequence, null), - overrideCredentialUsername: !!host.overrideCredentialUsername, - }; - - if (typeof host.credentialId === "number") { - const credential = credentialsById.get(host.credentialId); - if (credential) { - exportData.authType = "credential"; - exportData.credentialAlias = addCredential( - credential, - String(host.username || "credential"), - ); - return exportData; - } - } - - if (host.authType === "password") { - exportData.authType = "credential"; - exportData.credentialAlias = addDirectCredential( - "password", - host.username, - ); - return exportData; - } - - if (host.authType === "key") { - exportData.authType = "credential"; - exportData.credentialAlias = addDirectCredential( - "key", - host.username, - host.keyType, - ); - return exportData; - } - - if (host.authType === "credential") { - exportData.authType = "none"; - } - - return exportData; - }); - - sshLogger.success("All hosts exported for sharing", { - operation: "hosts_export_share", - count: exportedHosts.length, - credentialCount: exportedCredentials.size, - userId, - }); - - return res.json({ - version: "termix-host-share-v1", - exportedAt: new Date().toISOString(), - credentials: Array.from(exportedCredentials.values()), - hosts: exportedHosts, - }); - } + const allHosts = + await createCurrentHostResolutionRepository().findHostsByUserId(userId); const exportedHosts = []; @@ -2265,12 +1847,13 @@ router.delete( hostId: parseInt(hostId), }); try { - const hostToDelete = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))); + const hostToDelete = + await createCurrentHostResolutionRepository().findHostByIdForUser( + Number(hostId), + userId, + ); - if (hostToDelete.length === 0) { + if (!hostToDelete) { sshLogger.warn("SSH host not found for deletion", { operation: "host_delete", hostId: parseInt(hostId), @@ -2281,48 +1864,35 @@ router.delete( const numericHostId = Number(hostId); - await db - .delete(fileManagerRecent) - .where(eq(fileManagerRecent.hostId, numericHostId)); + await createCurrentFileManagerBookmarkRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(fileManagerPinned) - .where(eq(fileManagerPinned.hostId, numericHostId)); + await createCurrentTransferRecentRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.hostId, numericHostId)); + await createCurrentCommandHistoryRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(transferRecent) - .where( - or( - eq(transferRecent.sourceHostId, numericHostId), - eq(transferRecent.destHostId, numericHostId), - ), - ); + await createCurrentSshCredentialUsageRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(commandHistory) - .where(eq(commandHistory.hostId, numericHostId)); + await createCurrentRecentActivityRepository().deleteByHostId( + numericHostId, + ); - await db - .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.hostId, numericHostId)); + await createCurrentRbacAccessRepository().deleteHostAccessForHost( + numericHostId, + ); - await db - .delete(recentActivity) - .where(eq(recentActivity.hostId, numericHostId)); + await createCurrentSessionRecordingRepository().deleteByHostId( + numericHostId, + ); - await db.delete(hostAccess).where(eq(hostAccess.hostId, numericHostId)); - - await db - .delete(sessionRecordings) - .where(eq(sessionRecordings.hostId, numericHostId)); - - await db - .delete(hosts) - .where(and(eq(hosts.id, numericHostId), eq(hosts.userId, userId))); + await createCurrentHostRepository().deleteForUser(userId, numericHostId); databaseLogger.success("SSH host deleted", { operation: "host_delete_success", @@ -2331,19 +1901,13 @@ router.delete( }); const { ipAddress: dhIp, userAgent: dhUa } = getRequestMeta(req); - const { users: usersTableDel } = await import("../db/schema.js"); - const dhActor = await db - .select({ username: usersTableDel.username }) - .from(usersTableDel) - .where(eq(usersTableDel.id, userId)) - .limit(1); await logAudit({ userId, - username: dhActor[0]?.username ?? userId, + username: await getAuditUsername(userId), action: "delete_host", resourceType: "host", resourceId: hostId, - resourceName: hostToDelete[0].name ?? hostToDelete[0].ip, + resourceName: hostToDelete.name ?? hostToDelete.ip, ipAddress: dhIp, userAgent: dhUa, success: true, @@ -2405,17 +1969,12 @@ router.get( } try { - const recent = await db - .select() - .from(transferRecent) - .where( - and( - eq(transferRecent.userId, userId), - eq(transferRecent.sourceHostId, sourceHostId), - ), - ) - .orderBy(desc(transferRecent.lastUsed)) - .limit(10); + const recent = + await createCurrentTransferRecentRepository().listBySourceHost( + userId, + sourceHostId, + 10, + ); res.json(recent); } catch (err) { @@ -2442,53 +2001,15 @@ router.post( } try { - const existing = await db - .select() - .from(transferRecent) - .where( - and( - eq(transferRecent.userId, userId), - eq(transferRecent.sourceHostId, sourceHostId), - eq(transferRecent.destHostId, destHostId), - eq(transferRecent.destPath, destPath), - ), - ); + const transferRecentRepository = createCurrentTransferRecentRepository(); + await transferRecentRepository.upsertForDestination(userId, { + sourceHostId, + destHostId, + destPath, + destPathLabel, + }); - if (existing.length > 0) { - await db - .update(transferRecent) - .set({ lastUsed: new Date().toISOString() }) - .where(eq(transferRecent.id, existing[0].id)); - } else { - await db.insert(transferRecent).values({ - userId, - sourceHostId, - destHostId, - destPath, - destPathLabel: destPathLabel || destPath, - lastUsed: new Date().toISOString(), - }); - } - - const allRecent = await db - .select() - .from(transferRecent) - .where( - and( - eq(transferRecent.userId, userId), - eq(transferRecent.sourceHostId, sourceHostId), - ), - ) - .orderBy(desc(transferRecent.lastUsed)); - - if (allRecent.length > 10) { - const toDelete = allRecent.slice(10); - for (const entry of toDelete) { - await db - .delete(transferRecent) - .where(eq(transferRecent.id, entry.id)); - } - } + await transferRecentRepository.pruneSourceHost(userId, sourceHostId, 10); res.json({ message: "Recent destination saved" }); } catch (err) { @@ -2554,22 +2075,13 @@ async function resolveHostCredentials( } } - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", - ownerId, - ); + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + credentialId, + ownerId, + ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { const resolvedHost: Record = { ...host, password: pickResolvedPassword(host.password, credential.password), @@ -2667,31 +2179,20 @@ router.get( } try { - const { opksshTokens } = await import("../db/schema.js"); - const token = await db - .select() - .from(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .limit(1); + const opksshTokenRepository = createCurrentOpksshTokenRepository(); + const tokenData = await opksshTokenRepository.findByUserAndHost( + userId, + hostId, + ); - if (!token || token.length === 0) { + if (!tokenData) { return res.status(404).json({ exists: false }); } - const tokenData = token[0]; const expiresAt = new Date(tokenData.expiresAt); if (expiresAt < new Date()) { - await db - .delete(opksshTokens) - .where( - and( - eq(opksshTokens.userId, userId), - eq(opksshTokens.hostId, hostId), - ), - ); + await opksshTokenRepository.deleteByUserAndHost(userId, hostId); return res.status(404).json({ exists: false }); } diff --git a/src/backend/database/routes/ldap-auth-routes.ts b/src/backend/database/routes/ldap-auth-routes.ts index cefdc6cb..799ddb94 100644 --- a/src/backend/database/routes/ldap-auth-routes.ts +++ b/src/backend/database/routes/ldap-auth-routes.ts @@ -1,14 +1,15 @@ import type { Router } from "express"; import type { LDAPProviderConfig } from "../../../types/index.js"; -import { db } from "../db/index.js"; -import { ssoProviders, users, roles, userRoles } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; import { nanoid } from "nanoid"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js"; import ldap from "ldapjs"; +import { createCurrentRoleRepository } from "../repositories/current-role-repository.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; +import { createCurrentSsoProviderRepository } from "../repositories/current-sso-provider-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; const authManager = AuthManager.getInstance(); @@ -120,12 +121,9 @@ export function registerLDAPAuthRoutes(router: Router): void { } try { - const rows = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (rows.length === 0 || rows[0].type !== "ldap" || !rows[0].enabled) { + const provider = + await createCurrentSsoProviderRepository().findById(providerId); + if (!provider || provider.type !== "ldap" || !provider.enabled) { return res.status(404).json({ error: "LDAP provider not found" }); } } catch (err) { @@ -268,22 +266,19 @@ export function registerLDAPAuthRoutes(router: Router): void { const deviceInfo = parseUserAgent(req); const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`; + const userRepository = createCurrentUserRepository(); - let existingUsers = await db - .select() - .from(users) - .where(eq(users.oidcIdentifier, oidcIdentifier)); + let existingUser = + await userRepository.findByOidcIdentifier(oidcIdentifier); let userId: string; - if (existingUsers.length === 0) { + if (!existingUser) { let autoProvision = false; try { - const r = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", - ) - .get() as { value: string } | undefined; - if (r) autoProvision = r.value === "true"; + autoProvision = await createCurrentSettingsRepository().getBoolean( + "oidc_auto_provision", + false, + ); } catch { /* */ } @@ -292,50 +287,31 @@ export function registerLDAPAuthRoutes(router: Router): void { (process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"; - const countRow = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const isFirst = (countRow?.count || 0) === 0; + const isFirst = (await userRepository.countAll()) === 0; if (!isFirst && !autoProvision) { return res.status(403).json({ error: "Registration is disabled" }); } userId = nanoid(); - const isFirstFinal = db.$client.transaction(() => { - const c = - ( - db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number } - )?.count || 0; - const first = c === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)", - ) - .run( - userId, - displayName, - "", - first || isAdmin ? 1 : 0, - oidcIdentifier, - providerId, - ); - return first; - })(); + const createdUser = await userRepository.createFirstSsoUser({ + id: userId, + username: displayName, + passwordHash: "", + isAdmin, + isOidc: true, + oidcIdentifier, + ssoProviderId: providerId, + }); + const isFirstFinal = createdUser.isFirstUser; try { const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - if (defaultRole.length > 0) - await db - .insert(userRoles) - .values({ userId, roleId: defaultRole[0].id, grantedBy: userId }); + await createCurrentRoleRepository().assignRoleNameToUser({ + userId, + roleName: defaultRoleName, + grantedBy: userId, + }); } catch { /* */ } @@ -347,7 +323,7 @@ export function registerLDAPAuthRoutes(router: Router): void { : 24 * 60 * 60 * 1000; await authManager.registerOIDCUser(userId, sessionDurationMs); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, userId)); + await userRepository.delete(userId); authLogger.error( "Failed to setup LDAP user encryption", encryptionError, @@ -357,47 +333,26 @@ export function registerLDAPAuthRoutes(router: Router): void { .json({ error: "Failed to setup user security" }); } - existingUsers = await db - .select() - .from(users) - .where(eq(users.id, userId)); + existingUser = await userRepository.findById(userId); + if (!existingUser) { + throw new Error("Created LDAP user could not be loaded"); + } } else { - userId = existingUsers[0].id; + userId = existingUser.id; // Sync admin status from group membership - if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) { - await db.update(users).set({ isAdmin }).where(eq(users.id, userId)); - existingUsers[0].isAdmin = isAdmin; + if (config.adminGroup && !!existingUser.isAdmin !== isAdmin) { + existingUser = + (await userRepository.update(userId, { isAdmin })) ?? existingUser; try { const newRoleName = isAdmin ? "admin" : "user"; const oldRoleName = isAdmin ? "user" : "admin"; - const newRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, newRoleName)) - .limit(1); - const oldRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, oldRoleName)) - .limit(1); - if (oldRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, userId), - eq(userRoles.roleId, oldRole[0].id), - ), - ); - } - if (newRole.length > 0) { - await db.insert(userRoles).values({ - userId, - roleId: newRole[0].id, - grantedBy: userId, - }); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId, + addRoleName: newRoleName, + removeRoleName: oldRoleName, + grantedBy: userId, + }); } catch { /* non-fatal */ } @@ -405,17 +360,15 @@ export function registerLDAPAuthRoutes(router: Router): void { // Update display name if not dual-auth const isDualAuth = - existingUsers[0].passwordHash && - existingUsers[0].passwordHash.trim() !== ""; - if (!isDualAuth && existingUsers[0].username !== displayName) { - await db - .update(users) - .set({ username: displayName }) - .where(eq(users.id, userId)); + existingUser.passwordHash && existingUser.passwordHash.trim() !== ""; + if (!isDualAuth && existingUser.username !== displayName) { + existingUser = + (await userRepository.update(userId, { username: displayName })) ?? + existingUser; } } - const userRecord = existingUsers[0]; + const userRecord = existingUser; try { await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch { diff --git a/src/backend/database/routes/network-topology.ts b/src/backend/database/routes/network-topology.ts index d6683124..77744b33 100644 --- a/src/backend/database/routes/network-topology.ts +++ b/src/backend/database/routes/network-topology.ts @@ -1,10 +1,8 @@ import express from "express"; -import { eq } from "drizzle-orm"; -import { getDb } from "../db/index.js"; -import { networkTopology } from "../db/schema.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { databaseLogger } from "../../utils/logger.js"; +import { createCurrentNetworkTopologyRepository } from "../repositories/current-network-topology-repository.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); @@ -70,14 +68,11 @@ router.get( return res.status(401).json({ error: "User not authenticated" }); } - const db = getDb(); - const result = await db - .select() - .from(networkTopology) - .where(eq(networkTopology.userId, userId)); + const record = + await createCurrentNetworkTopologyRepository().findByUserId(userId); - if (result.length > 0) { - const topologyStr = result[0].topology; + if (record) { + const topologyStr = record.topology; const topology = topologyStr ? JSON.parse(topologyStr) : null; return res.json(topology); } else { @@ -157,26 +152,13 @@ router.post( return res.status(400).json({ error: "Topology data is required" }); } - const db = getDb(); - const topologyStr = typeof topology === "string" ? topology : JSON.stringify(topology); - const existing = await db - .select() - .from(networkTopology) - .where(eq(networkTopology.userId, userId)); - - if (existing.length > 0) { - await db - .update(networkTopology) - .set({ topology: topologyStr }) - .where(eq(networkTopology.userId, userId)); - } else { - await db - .insert(networkTopology) - .values({ userId, topology: topologyStr }); - } + await createCurrentNetworkTopologyRepository().upsertForUser( + userId, + topologyStr, + ); return res.json({ success: true }); } catch (error) { diff --git a/src/backend/database/routes/open-tabs.ts b/src/backend/database/routes/open-tabs.ts index b5e04ad3..182bbc11 100644 --- a/src/backend/database/routes/open-tabs.ts +++ b/src/backend/database/routes/open-tabs.ts @@ -1,12 +1,11 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { userOpenTabs } from "../db/schema.js"; -import { eq, and, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { sessionManager } from "../../ssh/terminal-session-manager.js"; +import { getCurrentSettingValue } from "../repositories/current-settings-repository.js"; +import { createCurrentOpenTabRepository } from "../repositories/current-open-tab-repository.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); @@ -27,13 +26,9 @@ const DEFAULT_TAB_TTL_MINUTES = 30; function getTabTtlMs(): number { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", - ) - .get() as { value: string } | undefined; - if (row) { - const minutes = parseInt(row.value, 10); + const value = getCurrentSettingValue("terminal_session_timeout_minutes"); + if (value) { + const minutes = parseInt(value, 10); if (!isNaN(minutes) && minutes > 0) return minutes * 60_000; } } catch { @@ -56,17 +51,10 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { const cutoff = new Date(Date.now() - getTabTtlMs()).toISOString(); - const tabs = db - .select() - .from(userOpenTabs) - .where( - and( - eq(userOpenTabs.userId, userId), - sql`${userOpenTabs.updatedAt} > ${cutoff}`, - ), - ) - .orderBy(userOpenTabs.tabOrder) - .all(); + const tabs = await createCurrentOpenTabRepository().listRecentForUser( + userId, + cutoff, + ); return res.json( tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })), ); @@ -131,43 +119,14 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { } try { - const now = new Date().toISOString(); - const existing = db - .select() - .from(userOpenTabs) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .all(); - if (existing.length > 0) { - // Preserve existing backendSessionId when not explicitly provided - const sessionId = - backendSessionId !== undefined - ? backendSessionId - : existing[0].backendSessionId; - db.update(userOpenTabs) - .set({ - tabType, - hostId: hostId ?? null, - label, - tabOrder, - backendSessionId: sessionId ?? null, - updatedAt: now, - }) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .run(); - } else { - db.insert(userOpenTabs) - .values({ - id, - userId, - tabType, - hostId: hostId ?? null, - label, - tabOrder, - backendSessionId: backendSessionId ?? null, - updatedAt: now, - }) - .run(); - } + await createCurrentOpenTabRepository().upsertForUser(userId, { + id, + tabType, + hostId, + label, + tabOrder, + backendSessionId, + }); return res.json({ success: true }); } catch (e) { databaseLogger.error("Failed to upsert open tab", e, { @@ -217,24 +176,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { } try { - db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run(); - if (tabs.length > 0) { - const now = new Date().toISOString(); - db.insert(userOpenTabs) - .values( - tabs.map((t) => ({ - id: t.id, - userId, - tabType: t.tabType, - hostId: t.hostId ?? null, - label: t.label, - tabOrder: t.tabOrder, - backendSessionId: t.backendSessionId ?? null, - updatedAt: now, - })), - ) - .run(); - } + await createCurrentOpenTabRepository().replaceForUser(userId, tabs); return res.json({ success: true }); } catch (e) { databaseLogger.error("Failed to sync open tabs", e, { @@ -274,13 +216,13 @@ router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => { }>; try { - const result = db - .update(userOpenTabs) - .set({ ...updates, updatedAt: new Date().toISOString() }) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .run(); + const updated = await createCurrentOpenTabRepository().updateForUser( + userId, + id, + updates, + ); - if (result.changes === 0) { + if (!updated) { return res.status(404).json({ error: "Tab not found" }); } return res.json({ success: true }); @@ -316,9 +258,7 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { const id = String(req.params.id); try { - db.delete(userOpenTabs) - .where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))) - .run(); + await createCurrentOpenTabRepository().deleteForUser(userId, id); return res.json({ success: true }); } catch (e) { databaseLogger.error("Failed to delete open tab", e, { diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 00b878c1..364e12a4 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -1,22 +1,15 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - hostAccess, - hosts, - users, - roles, - userRoles, - sharedCredentials, - snippets, - snippetAccess, - sshCredentials, -} from "../db/schema.js"; -import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; import type { Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; +import { createCurrentCredentialRepository } from "../repositories/current-credential-repository.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; +import { createCurrentRbacAccessRepository } from "../repositories/current-rbac-access-repository.js"; +import { createCurrentRoleRepository } from "../repositories/current-role-repository.js"; +import { createCurrentSnippetRepository } from "../repositories/current-snippet-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; const router = express.Router(); @@ -112,13 +105,12 @@ router.post( .json({ error: "Target role ID is required when sharing with role" }); } - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); + const host = + await createCurrentHostResolutionRepository().findHostUpdateState( + hostId, + ); - if (host.length === 0) { + if (!host || host.userId !== userId) { databaseLogger.warn("Permission denied", { operation: "rbac_permission_denied", userId, @@ -130,10 +122,10 @@ router.post( } if ( - !host[0].credentialId && - !host[0].rdpCredentialId && - !host[0].vncCredentialId && - host[0].authType !== "opkssh" + !host.credentialId && + !host.rdpCredentialId && + !host.vncCredentialId && + host.authType !== "opkssh" ) { return res.status(400).json({ error: @@ -143,23 +135,17 @@ router.post( } if (targetType === "user") { - const targetUser = await db - .select({ id: users.id, username: users.username }) - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); + const targetUser = + await createCurrentUserRepository().findById(targetUserId); - if (targetUser.length === 0) { + if (!targetUser) { return res.status(404).json({ error: "Target user not found" }); } } else { - const targetRole = await db - .select({ id: roles.id, name: roles.name }) - .from(roles) - .where(eq(roles.id, targetRoleId)) - .limit(1); + const targetRole = + await createCurrentRoleRepository().findRoleById(targetRoleId); - if (targetRole.length === 0) { + if (!targetRole) { return res.status(404).json({ error: "Target role not found" }); } } @@ -183,50 +169,34 @@ router.post( }); } - const whereConditions = [eq(hostAccess.hostId, hostId)]; - if (targetType === "user") { - whereConditions.push(eq(hostAccess.userId, targetUserId)); - } else { - whereConditions.push(eq(hostAccess.roleId, targetRoleId)); - } - - const existing = await db - .select() - .from(hostAccess) - .where(and(...whereConditions)) - .limit(1); - - if (existing.length > 0) { - await db - .update(hostAccess) - .set({ - permissionLevel, - expiresAt, - }) - .where(eq(hostAccess.id, existing[0].id)); - - await db - .delete(sharedCredentials) - .where(eq(sharedCredentials.hostAccessId, existing[0].id)); + const accessGrant = + await createCurrentRbacAccessRepository().upsertHostAccess({ + hostId, + grantedBy: userId, + permissionLevel, + expiresAt, + ...(targetType === "user" + ? { targetType: "user" as const, targetUserId: targetUserId! } + : { targetType: "role" as const, targetRoleId: targetRoleId! }), + }); + if (!accessGrant.created) { const activeCredentialId = - host[0].credentialId ?? - host[0].rdpCredentialId ?? - host[0].vncCredentialId; + host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId; if (activeCredentialId) { const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); if (targetType === "user") { await sharedCredManager.createSharedCredentialForUser( - existing[0].id, + accessGrant.id, activeCredentialId, targetUserId!, userId, ); } else { await sharedCredManager.createSharedCredentialsForRole( - existing[0].id, + accessGrant.id, activeCredentialId, targetRoleId!, userId, @@ -248,34 +218,23 @@ router.post( }); } - const result = await db.insert(hostAccess).values({ - hostId, - userId: targetType === "user" ? targetUserId : null, - roleId: targetType === "role" ? targetRoleId : null, - grantedBy: userId, - permissionLevel, - expiresAt, - }); - const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); const activeCredentialId = - host[0].credentialId ?? - host[0].rdpCredentialId ?? - host[0].vncCredentialId; + host.credentialId ?? host.rdpCredentialId ?? host.vncCredentialId; if (activeCredentialId) { if (targetType === "user") { await sharedCredManager.createSharedCredentialForUser( - result.lastInsertRowid as number, + accessGrant.id, activeCredentialId, targetUserId!, userId, ); } else { await sharedCredManager.createSharedCredentialsForRole( - result.lastInsertRowid as number, + accessGrant.id, activeCredentialId, targetRoleId!, userId, @@ -352,19 +311,20 @@ router.delete( } try { - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); + const isHostOwner = + await createCurrentHostResolutionRepository().isHostOwnedByUser( + hostId, + userId, + ); - if (host.length === 0) { + if (!isHostOwner) { return res.status(403).json({ error: "Not host owner" }); } - await db - .delete(hostAccess) - .where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId))); + await createCurrentRbacAccessRepository().revokeHostAccess( + accessId, + hostId, + ); databaseLogger.info("Permission revoked", { operation: "rbac_permission_revoke", adminId: userId, @@ -422,50 +382,18 @@ router.get( } try { - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); + const isHostOwner = + await createCurrentHostResolutionRepository().isHostOwnedByUser( + hostId, + userId, + ); - if (host.length === 0) { + if (!isHostOwner) { return res.status(403).json({ error: "Not host owner" }); } - const rawAccessList = await db - .select({ - id: hostAccess.id, - userId: hostAccess.userId, - roleId: hostAccess.roleId, - username: users.username, - roleName: roles.name, - roleDisplayName: roles.displayName, - grantedBy: hostAccess.grantedBy, - grantedByUsername: sql`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`, - permissionLevel: hostAccess.permissionLevel, - expiresAt: hostAccess.expiresAt, - createdAt: hostAccess.createdAt, - }) - .from(hostAccess) - .leftJoin(users, eq(hostAccess.userId, users.id)) - .leftJoin(roles, eq(hostAccess.roleId, roles.id)) - .where(eq(hostAccess.hostId, hostId)) - .orderBy(desc(hostAccess.createdAt)); - - const accessList = rawAccessList.map((access) => ({ - id: access.id, - targetType: access.userId ? "user" : "role", - userId: access.userId, - roleId: access.roleId, - username: access.username, - roleName: access.roleName, - roleDisplayName: access.roleDisplayName, - grantedBy: access.grantedBy, - grantedByUsername: access.grantedByUsername, - permissionLevel: access.permissionLevel, - expiresAt: access.expiresAt, - createdAt: access.createdAt, - })); + const accessList = + await createCurrentRbacAccessRepository().listHostAccess(hostId); res.json({ accessList }); } catch (error) { @@ -500,46 +428,13 @@ router.get( const userId = req.userId!; try { - const now = new Date().toISOString(); - - const userRoleIds = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); - - const sharedHosts = await db - .select({ - id: hosts.id, - name: hosts.name, - ip: hosts.ip, - port: hosts.port, - username: hosts.username, - folder: hosts.folder, - tags: hosts.tags, - permissionLevel: hostAccess.permissionLevel, - expiresAt: hostAccess.expiresAt, - grantedBy: hostAccess.grantedBy, - ownerUsername: users.username, - }) - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .innerJoin(users, eq(hosts.userId, users.id)) - .where( - and( - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? sql`${hostAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - ) - .orderBy(desc(hostAccess.createdAt)); + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const sharedHosts = + await createCurrentRbacAccessRepository().listSharedHosts( + userId, + roleIds, + ); res.json({ sharedHosts }); } catch (error) { @@ -571,18 +466,25 @@ router.get( authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { try { - const rolesList = await db - .select({ - id: roles.id, - name: roles.name, - displayName: roles.displayName, - description: roles.description, - isSystem: roles.isSystem, - createdAt: roles.createdAt, - updatedAt: roles.updatedAt, - }) - .from(roles) - .orderBy(roles.isSystem, roles.name); + const rolesList = (await createCurrentRoleRepository().listRoles()).map( + ({ + id, + name, + displayName, + description, + isSystem, + createdAt, + updatedAt, + }) => ({ + id, + name, + displayName, + description, + isSystem, + createdAt, + updatedAt, + }), + ); res.json({ roles: rolesList }); } catch (error) { @@ -646,19 +548,15 @@ router.post( } try { - const existing = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, name)) - .limit(1); + const existing = await createCurrentRoleRepository().findRoleByName(name); - if (existing.length > 0) { + if (existing) { return res.status(409).json({ error: "A role with this name already exists", }); } - const result = await db.insert(roles).values({ + const newRoleId = await createCurrentRoleRepository().createRole({ name, displayName, description: description || null, @@ -666,8 +564,6 @@ router.post( permissions: null, }); - const newRoleId = result.lastInsertRowid; - res.status(201).json({ success: true, roleId: newRoleId, @@ -738,17 +634,10 @@ router.put( } try { - const existingRole = await db - .select({ - id: roles.id, - name: roles.name, - isSystem: roles.isSystem, - }) - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + const existingRole = + await createCurrentRoleRepository().findRoleById(roleId); - if (existingRole.length === 0) { + if (!existingRole) { return res.status(404).json({ error: "Role not found" }); } @@ -768,7 +657,7 @@ router.put( updates.description = description || null; } - await db.update(roles).set(updates).where(eq(roles.id, roleId)); + await createCurrentRoleRepository().updateRole(roleId, updates); res.json({ success: true, @@ -823,39 +712,25 @@ router.delete( } try { - const role = await db - .select({ - id: roles.id, - name: roles.name, - isSystem: roles.isSystem, - }) - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + const role = await createCurrentRoleRepository().findRoleById(roleId); - if (role.length === 0) { + if (!role) { return res.status(404).json({ error: "Role not found" }); } - if (role[0].isSystem) { + if (role.isSystem) { return res.status(403).json({ error: "Cannot delete system roles", }); } - const deletedUserRoles = await db - .delete(userRoles) - .where(eq(userRoles.roleId, roleId)) - .returning({ userId: userRoles.userId }); + const { deletedUserIds } = + await createCurrentRoleRepository().deleteRole(roleId); - for (const { userId } of deletedUserRoles) { + for (const userId of deletedUserIds) { permissionManager.invalidateUserPermissionCache(userId); } - await db.delete(hostAccess).where(eq(hostAccess.roleId, roleId)); - - await db.delete(roles).where(eq(roles.id, roleId)); - res.json({ success: true, message: "Role deleted successfully", @@ -924,69 +799,58 @@ router.post( return res.status(400).json({ error: "Role ID is required" }); } - const targetUser = await db - .select() - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); + const targetUser = + await createCurrentUserRepository().findById(targetUserId); - if (targetUser.length === 0) { + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - const role = await db - .select() - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + const role = await createCurrentRoleRepository().findRoleById(roleId); - if (role.length === 0) { + if (!role) { return res.status(404).json({ error: "Role not found" }); } - if (role[0].isSystem) { + if (role.isSystem) { return res.status(403).json({ error: "System roles (admin, user) are automatically assigned and cannot be manually assigned", }); } - const existing = await db - .select() - .from(userRoles) - .where( - and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), - ) - .limit(1); + const existing = await createCurrentRoleRepository().findUserRole( + targetUserId, + roleId, + ); - if (existing.length > 0) { + if (existing) { return res.status(409).json({ error: "Role already assigned" }); } - await db.insert(userRoles).values({ + await createCurrentRoleRepository().assignRoleToUser({ userId: targetUserId, roleId, grantedBy: currentUserId, }); - const hostsSharedWithRole = await db - .select() - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .where(eq(hostAccess.roleId, roleId)); + const hostsSharedWithRole = + await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( + roleId, + ); const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); - for (const { host_access, ssh_data } of hostsSharedWithRole) { - if (ssh_data.credentialId) { + for (const sharedHost of hostsSharedWithRole) { + if (sharedHost.credentialId) { try { await sharedCredManager.createSharedCredentialForUser( - host_access.id, - ssh_data.credentialId, + sharedHost.hostAccessId, + sharedHost.credentialId, targetUserId, - ssh_data.userId, + sharedHost.hostOwnerId, ); } catch (error) { databaseLogger.error( @@ -996,7 +860,7 @@ router.post( operation: "assign_role_create_credentials", targetUserId, roleId, - hostId: ssh_data.id, + hostId: sharedHost.hostId, }, ); } @@ -1009,7 +873,7 @@ router.post( adminId: currentUserId, targetUserId, roleId, - roleName: role[0].name, + roleName: role.name, }); res.json({ @@ -1075,32 +939,23 @@ router.delete( } try { - const role = await db - .select({ - id: roles.id, - name: roles.name, - isSystem: roles.isSystem, - }) - .from(roles) - .where(eq(roles.id, roleId)) - .limit(1); + const role = await createCurrentRoleRepository().findRoleById(roleId); - if (role.length === 0) { + if (!role) { return res.status(404).json({ error: "Role not found" }); } - if (role[0].isSystem) { + if (role.isSystem) { return res.status(403).json({ error: "System roles (admin, user) are automatically assigned and cannot be removed", }); } - await db - .delete(userRoles) - .where( - and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), - ); + await createCurrentRoleRepository().removeRoleFromUser( + targetUserId, + roleId, + ); permissionManager.invalidateUserPermissionCache(targetUserId); databaseLogger.info("Role removed from user", { @@ -1164,19 +1019,8 @@ router.get( } try { - const userRolesList = await db - .select({ - id: userRoles.id, - roleId: roles.id, - roleName: roles.name, - roleDisplayName: roles.displayName, - description: roles.description, - isSystem: roles.isSystem, - grantedAt: userRoles.grantedAt, - }) - .from(userRoles) - .innerJoin(roles, eq(userRoles.roleId, roles.id)) - .where(eq(userRoles.userId, targetUserId)); + const userRolesList = + await createCurrentRoleRepository().listUserRoles(targetUserId); res.json({ roles: userRolesList }); } catch (error) { @@ -1239,32 +1083,25 @@ router.post( .json({ error: "Target role ID is required when sharing with role" }); } - const snippet = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const snippet = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (snippet.length === 0) { + if (!snippet) { return res.status(403).json({ error: "Not snippet owner" }); } if (targetType === "user") { - const targetUser = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); - if (targetUser.length === 0) { + const targetUser = + await createCurrentUserRepository().findById(targetUserId); + if (!targetUser) { return res.status(404).json({ error: "Target user not found" }); } } else { - const targetRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.id, targetRoleId)) - .limit(1); - if (targetRole.length === 0) { + const targetRole = + await createCurrentRoleRepository().findRoleById(targetRoleId); + if (!targetRole) { return res.status(404).json({ error: "Target role not found" }); } } @@ -1280,25 +1117,17 @@ router.post( expiresAt = expiryDate.toISOString(); } - const whereConditions = [eq(snippetAccess.snippetId, snippetId)]; - if (targetType === "user") { - whereConditions.push(eq(snippetAccess.userId, targetUserId)); - } else { - whereConditions.push(eq(snippetAccess.roleId, targetRoleId)); - } - - const existing = await db - .select() - .from(snippetAccess) - .where(and(...whereConditions)) - .limit(1); - - if (existing.length > 0) { - await db - .update(snippetAccess) - .set({ expiresAt }) - .where(eq(snippetAccess.id, existing[0].id)); + const accessGrant = + await createCurrentRbacAccessRepository().upsertSnippetAccess({ + snippetId, + grantedBy: userId, + expiresAt, + ...(targetType === "user" + ? { targetType: "user" as const, targetUserId: targetUserId! } + : { targetType: "role" as const, targetRoleId: targetRoleId! }), + }); + if (!accessGrant.created) { return res.json({ success: true, message: "Snippet access updated", @@ -1306,15 +1135,6 @@ router.post( }); } - await db.insert(snippetAccess).values({ - snippetId, - userId: targetType === "user" ? targetUserId : null, - roleId: targetType === "role" ? targetRoleId : null, - grantedBy: userId, - permissionLevel: "view", - expiresAt, - }); - databaseLogger.success("Snippet shared successfully", { operation: "rbac_snippet_share", userId, @@ -1361,24 +1181,19 @@ router.delete( } try { - const snippet = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const snippet = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (snippet.length === 0) { + if (!snippet) { return res.status(403).json({ error: "Not snippet owner" }); } - await db - .delete(snippetAccess) - .where( - and( - eq(snippetAccess.id, accessId), - eq(snippetAccess.snippetId, snippetId), - ), - ); + await createCurrentRbacAccessRepository().revokeSnippetAccess( + accessId, + snippetId, + ); res.json({ success: true, message: "Snippet access revoked" }); } catch (error) { @@ -1413,50 +1228,17 @@ router.get( } try { - const snippet = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const snippet = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (snippet.length === 0) { + if (!snippet) { return res.status(403).json({ error: "Not snippet owner" }); } - const rawAccessList = await db - .select({ - id: snippetAccess.id, - userId: snippetAccess.userId, - roleId: snippetAccess.roleId, - username: users.username, - roleName: roles.name, - roleDisplayName: roles.displayName, - grantedBy: snippetAccess.grantedBy, - grantedByUsername: sql`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - createdAt: snippetAccess.createdAt, - }) - .from(snippetAccess) - .leftJoin(users, eq(snippetAccess.userId, users.id)) - .leftJoin(roles, eq(snippetAccess.roleId, roles.id)) - .where(eq(snippetAccess.snippetId, snippetId)) - .orderBy(desc(snippetAccess.createdAt)); - - const accessList = rawAccessList.map((access) => ({ - id: access.id, - targetType: access.userId ? "user" : "role", - userId: access.userId, - roleId: access.roleId, - username: access.username, - roleName: access.roleName, - roleDisplayName: access.roleDisplayName, - grantedBy: access.grantedBy, - grantedByUsername: access.grantedByUsername, - permissionLevel: access.permissionLevel, - expiresAt: access.expiresAt, - createdAt: access.createdAt, - })); + const accessList = + await createCurrentRbacAccessRepository().listSnippetAccess(snippetId); res.json({ accessList }); } catch (error) { @@ -1485,72 +1267,15 @@ router.get( const userId = req.userId!; try { - const now = new Date().toISOString(); - - const directShared = await db - .select({ - id: snippets.id, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - ownerUsername: users.username, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .innerJoin(users, eq(snippets.userId, users.id)) - .where( - and( - eq(snippetAccess.userId, userId), - or( - isNull(snippetAccess.expiresAt), - gte(snippetAccess.expiresAt, now), - ), - ), + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); + const sharedSnippets = + await createCurrentRbacAccessRepository().listSharedSnippets( + userId, + roleIds, ); - const userRoleRows = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleRows.map((r) => r.roleId); - - let roleShared: typeof directShared = []; - if (roleIds.length > 0) { - const directIds = directShared.map((s) => s.id); - const roleResults = await db - .select({ - id: snippets.id, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - ownerUsername: users.username, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .innerJoin(users, eq(snippets.userId, users.id)) - .where( - and( - or( - isNull(snippetAccess.expiresAt), - gte(snippetAccess.expiresAt, now), - ), - sql`${snippetAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ), - ); - - roleShared = roleResults.filter((s) => !directIds.includes(s.id)); - } - - res.json({ sharedSnippets: [...directShared, ...roleShared] }); + res.json({ sharedSnippets }); } catch (error) { databaseLogger.error("Failed to get shared snippets", error, { operation: "get_shared_snippets", @@ -1573,39 +1298,31 @@ router.put( return res.status(400).json({ error: "Invalid host ID" }); } - const access = await db - .select() - .from(hostAccess) - .where( - and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), - ) - .limit(1); + const access = + await createCurrentRbacAccessRepository().findDirectHostAccess( + hostId, + userId, + ); - if (access.length === 0) { + if (!access) { return res.status(403).json({ error: "No access to this host" }); } if (credentialId) { - const cred = await db - .select({ id: sshCredentials.id }) - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .limit(1); + const cred = await createCurrentCredentialRepository().findByIdForUser( + userId, + credentialId, + ); - if (cred.length === 0) { + if (!cred) { return res.status(404).json({ error: "Credential not found" }); } } - await db - .update(hostAccess) - .set({ overrideCredentialId: credentialId || null }) - .where(eq(hostAccess.id, access[0].id)); + await createCurrentRbacAccessRepository().updateHostAccessOverrideCredential( + access.id, + credentialId || null, + ); res.json({ success: true }); } catch (error) { diff --git a/src/backend/database/routes/session-log-routes.ts b/src/backend/database/routes/session-log-routes.ts index 1ae1c12d..e25620ca 100644 --- a/src/backend/database/routes/session-log-routes.ts +++ b/src/backend/database/routes/session-log-routes.ts @@ -2,13 +2,12 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import fs from "fs"; import path from "path"; -import { eq, desc, lt } from "drizzle-orm"; -import { db } from "../db/index.js"; -import { sessionRecordings, hosts, users } from "../db/schema.js"; import { apiLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { Request, Response } from "express"; import { PermissionManager } from "../../utils/permission-manager.js"; +import { createCurrentSessionRecordingRepository } from "../repositories/current-session-recording-repository.js"; +import { getDb } from "../db/index.js"; const router = express.Router(); @@ -30,7 +29,7 @@ function getRetentionDays(): number { 10, ); try { - const row = db.$client + const row = getDb().$client .prepare( "SELECT value FROM settings WHERE key = 'session_recording_retention_days'", ) @@ -56,13 +55,9 @@ async function pruneOldLogs(): Promise { Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000, ).toISOString(); - const old = await db - .select({ - id: sessionRecordings.id, - recordingPath: sessionRecordings.recordingPath, - }) - .from(sessionRecordings) - .where(lt(sessionRecordings.startedAt, cutoff)); + const sessionRecordingRepository = + createCurrentSessionRecordingRepository(); + const old = await sessionRecordingRepository.listPathsOlderThan(cutoff); for (const row of old) { if (row.recordingPath) { @@ -71,9 +66,7 @@ async function pruneOldLogs(): Promise { await fs.promises.unlink(resolved).catch(() => {}); } } - await db - .delete(sessionRecordings) - .where(eq(sessionRecordings.id, row.id)); + await sessionRecordingRepository.deleteById(row.id); } if (old.length > 0) { @@ -114,27 +107,10 @@ const authenticateJWT = authManager.createAuthMiddleware(); router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const isAdmin = await permissionManager.isAdmin(userId); - const rows = await db - .select({ - id: sessionRecordings.id, - hostId: sessionRecordings.hostId, - userId: sessionRecordings.userId, - startedAt: sessionRecordings.startedAt, - endedAt: sessionRecordings.endedAt, - duration: sessionRecordings.duration, - recordingPath: sessionRecordings.recordingPath, - protocol: sessionRecordings.protocol, - format: sessionRecordings.format, - username: users.username, - hostName: hosts.name, - hostIp: hosts.ip, - }) - .from(sessionRecordings) - .leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id)) - .leftJoin(users, eq(sessionRecordings.userId, users.id)) - .where(isAdmin ? undefined : eq(sessionRecordings.userId, userId)) - .orderBy(desc(sessionRecordings.startedAt)); + const rows = + await createCurrentSessionRecordingRepository().listByUserIdWithHost( + userId, + ); const records = rows.map((row) => { let sizeBytes: number | null = null; @@ -188,8 +164,8 @@ router.put( .status(400) .json({ error: "Retention must be between 1 and 3650 days" }); } - db.$client - .prepare( + getDb() + .$client.prepare( "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", ) .run("session_recording_retention_days", String(retentionDays)); @@ -228,17 +204,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => { if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const rows = await db - .select() - .from(sessionRecordings) - .where(eq(sessionRecordings.id, id)) - .limit(1); + const row = await createCurrentSessionRecordingRepository().findByIdForUser( + userId, + id, + ); - if (rows.length === 0) return res.status(404).json({ error: "Not found" }); - if (!(await canAccessRecording(userId, rows[0].userId))) { - return res.status(403).json({ error: "Forbidden" }); - } - res.json({ log: rows[0] }); + if (!row) return res.status(404).json({ error: "Not found" }); + res.json({ log: row }); } catch (error) { apiLogger.error("Failed to fetch session log", error, { operation: "session_log_get", @@ -284,23 +256,15 @@ router.get( if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const rows = await db - .select({ - recordingPath: sessionRecordings.recordingPath, - userId: sessionRecordings.userId, - format: sessionRecordings.format, - }) - .from(sessionRecordings) - .where(eq(sessionRecordings.id, id)) - .limit(1); + const row = + await createCurrentSessionRecordingRepository().findPathByIdForUser( + userId, + id, + ); - if (rows.length === 0) - return res.status(404).json({ error: "Not found" }); - if (!(await canAccessRecording(userId, rows[0].userId))) { - return res.status(403).json({ error: "Forbidden" }); - } + if (!row) return res.status(404).json({ error: "Not found" }); - const filePath = rows[0].recordingPath; + const filePath = row.recordingPath; if (!filePath) return res.status(404).json({ error: "No recording file" }); @@ -314,10 +278,13 @@ router.get( } const content = await fs.promises.readFile(resolvedPath); + const format = + (row as { format?: string | null }).format ?? + (row.recordingPath?.endsWith(".cast") ? "asciicast" : "text"); const contentType = - rows[0].format === "guacamole" + format === "guacamole" ? "application/vnd.apache.guacamole.recording" - : rows[0].format === "asciicast" + : format === "asciicast" ? "application/x-asciicast" : "text/plain"; res.type(contentType).send(content); @@ -362,23 +329,18 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); try { - const rows = await db - .select({ - recordingPath: sessionRecordings.recordingPath, - userId: sessionRecordings.userId, - }) - .from(sessionRecordings) - .where(eq(sessionRecordings.id, id)) - .limit(1); + const sessionRecordingRepository = + createCurrentSessionRecordingRepository(); + const row = await sessionRecordingRepository.findPathByIdForUser( + userId, + id, + ); - if (rows.length === 0) return res.status(404).json({ error: "Not found" }); - if (!(await canAccessRecording(userId, rows[0].userId))) { - return res.status(403).json({ error: "Forbidden" }); - } + if (!row) return res.status(404).json({ error: "Not found" }); - const filePath = rows[0].recordingPath; + const filePath = row.recordingPath; - await db.delete(sessionRecordings).where(eq(sessionRecordings.id, id)); + await sessionRecordingRepository.deleteForUser(userId, id); if (filePath) { const resolvedPath = path.resolve(filePath); diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 316dd6e9..6cfcaf73 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -1,21 +1,16 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { - snippets, - snippetFolders, - snippetAccess, - users, - userRoles, -} from "../db/schema.js"; -import { eq, and, desc, asc, sql, or, isNull, gte } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { extractSnippetReorderUpdates } from "./snippets-reorder.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; -import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; +import { createCurrentRbacAccessRepository } from "../repositories/current-rbac-access-repository.js"; +import { createCurrentRoleRepository } from "../repositories/current-role-repository.js"; +import { createCurrentSnippetRepository } from "../repositories/current-snippet-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; const router = express.Router(); @@ -24,38 +19,12 @@ function isNonEmptyString(val: unknown): val is string { } async function getUserRoleIds(userId: string): Promise { - const rows = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - - return rows.map((row) => row.roleId); + return createCurrentRoleRepository().listUserRoleIds(userId); } -function roleIdFilter(roleIds: number[]) { - if (roleIds.length === 0) { - return undefined; - } - - return sql`${snippetAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})`; -} - -function activeSnippetAccessFilter(userId: string, roleIds: number[]) { - const roleFilter = roleIdFilter(roleIds); - const targetFilter = roleFilter - ? or(eq(snippetAccess.userId, userId), roleFilter) - : eq(snippetAccess.userId, userId); - - return and( - targetFilter, - or( - isNull(snippetAccess.expiresAt), - gte(snippetAccess.expiresAt, new Date().toISOString()), - ), - ); +async function getActorUsername(userId: string): Promise { + const user = await createCurrentUserRepository().findById(userId); + return user?.username ?? userId; } function sortSnippets< @@ -73,41 +42,21 @@ function sortSnippets< } async function getAccessibleSnippet(snippetId: number, userId: string) { - const owned = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId))) - .limit(1); + const owned = await createCurrentSnippetRepository().findOwnedById( + userId, + snippetId, + ); - if (owned.length > 0) { - return owned[0]; + if (owned) { + return owned; } const roleIds = await getUserRoleIds(userId); - const shared = await db - .select({ - id: snippets.id, - userId: snippets.userId, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - order: snippets.order, - createdAt: snippets.createdAt, - updatedAt: snippets.updatedAt, - hostFilter: snippets.hostFilter, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .where( - and( - eq(snippetAccess.snippetId, snippetId), - activeSnippetAccessFilter(userId, roleIds), - ), - ) - .limit(1); - - return shared[0] ?? null; + return createCurrentRbacAccessRepository().findAccessibleSharedSnippet( + snippetId, + userId, + roleIds, + ); } const authManager = AuthManager.getInstance(); @@ -143,11 +92,7 @@ router.get( } try { - const result = await db - .select() - .from(snippetFolders) - .where(eq(snippetFolders.userId, userId)) - .orderBy(asc(snippetFolders.name)); + const result = await createCurrentSnippetRepository().listFolders(userId); res.json(result); } catch (err) { @@ -206,38 +151,26 @@ router.post( } try { - const existing = await db - .select() - .from(snippetFolders) - .where( - and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), - ); + const created = await createCurrentSnippetRepository().createFolder( + userId, + name, + color, + icon, + ); - if (existing.length > 0) { + if (!created) { return res .status(409) .json({ error: "Folder with this name already exists" }); } - const insertData = { - userId, - name: name.trim(), - color: color?.trim() || null, - icon: icon?.trim() || null, - }; - - const result = await db - .insert(snippetFolders) - .values(insertData) - .returning(); - authLogger.success(`Snippet folder created: ${name} by user ${userId}`, { operation: "snippet_folder_create_success", userId, name, }); - res.status(201).json(result[0]); + res.status(201).json(created); } catch (err) { authLogger.error("Failed to create snippet folder", err); res.status(500).json({ @@ -302,51 +235,19 @@ router.put( } try { - const existing = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, decodeURIComponent(name)), - ), + const decodedName = decodeURIComponent(name); + const updated = + await createCurrentSnippetRepository().updateFolderMetadata( + userId, + decodedName, + color, + icon, ); - if (existing.length === 0) { + if (!updated) { return res.status(404).json({ error: "Folder not found" }); } - const updateFields: Partial<{ - color: string | null; - icon: string | null; - updatedAt: ReturnType; - }> = { - updatedAt: sql`CURRENT_TIMESTAMP`, - }; - - if (color !== undefined) updateFields.color = color?.trim() || null; - if (icon !== undefined) updateFields.icon = icon?.trim() || null; - - await db - .update(snippetFolders) - .set(updateFields) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, decodeURIComponent(name)), - ), - ); - - const updated = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, decodeURIComponent(name)), - ), - ); - authLogger.success( `Snippet folder metadata updated: ${name} by user ${userId}`, { @@ -356,7 +257,7 @@ router.put( }, ); - res.json(updated[0]); + res.json(updated); } catch (err) { authLogger.error("Failed to update snippet folder metadata", err); res.status(500).json({ @@ -418,51 +319,22 @@ router.put( } try { - const existing = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, oldName), - ), - ); + const result = await createCurrentSnippetRepository().renameFolder( + userId, + oldName, + newName, + ); - if (existing.length === 0) { + if (result.status === "missing") { return res.status(404).json({ error: "Folder not found" }); } - const nameExists = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, newName), - ), - ); - - if (nameExists.length > 0) { + if (result.status === "conflict") { return res .status(409) .json({ error: "Folder with new name already exists" }); } - await db - .update(snippetFolders) - .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, oldName), - ), - ); - - await db - .update(snippets) - .set({ folder: newName }) - .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); - authLogger.success( `Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`, { @@ -526,21 +398,7 @@ router.delete( try { const folderName = decodeURIComponent(name); - await db - .update(snippets) - .set({ folder: null }) - .where( - and(eq(snippets.userId, userId), eq(snippets.folder, folderName)), - ); - - await db - .delete(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, folderName), - ), - ); + await createCurrentSnippetRepository().deleteFolder(userId, folderName); authLogger.success( `Snippet folder deleted: ${folderName} by user ${userId}`, @@ -623,29 +481,10 @@ router.put( } try { - for (const update of snippetUpdates) { - const { id, order, folder } = update; - - if (!id || order === undefined) { - continue; - } - - const updateFields: Partial<{ - order: number; - folder: string | null; - }> = { - order, - }; - - if (folder !== undefined) { - updateFields.folder = folder?.trim() || null; - } - - await db - .update(snippets) - .set(updateFields) - .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); - } + await createCurrentSnippetRepository().reorderSnippets( + userId, + snippetUpdates, + ); authLogger.success(`Snippets reordered by user ${userId}`, { operation: "snippet_reorder_success", @@ -720,47 +559,25 @@ router.post( } const { Client } = await import("ssh2"); - const { hosts, sshCredentials } = await import("../db/schema.js"); + const repository = createCurrentHostResolutionRepository(); + const host = await repository.findHostById(parseInt(hostId), userId); - const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); - - const hostResult = await SimpleDBOps.select( - db - .select() - .from(hosts) - .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); - - if (hostResult.length === 0) { + if (!host || host.userId !== userId) { return res.status(404).json({ error: "Host not found" }); } - const host = hostResult[0]; - let password = host.password; let privateKey = host.key; let passphrase = host.keyPassword; let authType = host.authType; if (host.credentialId) { - const credResult = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", + const cred = await repository.findCredentialByIdForUser( + host.credentialId as number, userId, ); - if (credResult.length > 0) { - const cred = credResult[0]; + if (cred) { authType = (cred.authType || authType) as string; password = (cred.password || undefined) as string | undefined; privateKey = (cred.privateKey || cred.key || undefined) as @@ -774,12 +591,11 @@ router.post( let output = ""; let errorOutput = ""; - /* eslint-disable no-async-promise-executor */ const executePromise = new Promise<{ success: boolean; output: string; error?: string; - }>(async (resolve, reject) => { + }>((resolve, reject) => { const timeout = setTimeout(() => { conn.end(); reject(new Error("Command execution timeout (30s)")); @@ -888,14 +704,6 @@ router.post( if (passphrase) { config.passphrase = passphrase; } - } else if (authType === "agent") { - const result = await applyAgentAuth( - config, - host.terminalConfig as Record | string | undefined, - ); - if ("error" in result) { - throw new Error(result.error); - } } else if (password) { config.password = password; } else if (privateKey) { @@ -911,7 +719,6 @@ router.post( conn.connect(config); }); - /* eslint-enable no-async-promise-executor */ const result = await executePromise; @@ -964,17 +771,9 @@ router.get( } try { - const allSnippets = await db - .select() - .from(snippets) - .where(eq(snippets.userId, userId)) - .orderBy(asc(snippets.folder), asc(snippets.order)); - - const allFolders = await db - .select() - .from(snippetFolders) - .where(eq(snippetFolders.userId, userId)) - .orderBy(asc(snippetFolders.name)); + const snippetRepository = createCurrentSnippetRepository(); + const allSnippets = await snippetRepository.listSnippetsForExport(userId); + const allFolders = await snippetRepository.listFoldersForExport(userId); const exportedSnippets = allSnippets.map((s) => ({ name: s.name, @@ -1057,131 +856,13 @@ router.post( .json({ error: "snippets or folders array is required" }); } - const results = { - snippetsImported: 0, - snippetsSkipped: 0, - snippetsUpdated: 0, - foldersImported: 0, - foldersSkipped: 0, - failed: 0, - errors: [] as string[], - }; - try { - if (Array.isArray(foldersToImport)) { - for (const folder of foldersToImport) { - if (!isNonEmptyString(folder.name)) { - results.failed++; - results.errors.push(`Folder missing name`); - continue; - } - - const existing = await db - .select() - .from(snippetFolders) - .where( - and( - eq(snippetFolders.userId, userId), - eq(snippetFolders.name, folder.name.trim()), - ), - ) - .limit(1); - - if (existing.length > 0) { - results.foldersSkipped++; - continue; - } - - await db.insert(snippetFolders).values({ - userId, - name: folder.name.trim(), - color: folder.color?.trim() || null, - icon: folder.icon?.trim() || null, - }); - results.foldersImported++; - } - } - - if (Array.isArray(snippetsToImport)) { - for (let i = 0; i < snippetsToImport.length; i++) { - const s = snippetsToImport[i]; - - if (!isNonEmptyString(s.name) || !isNonEmptyString(s.content)) { - results.failed++; - results.errors.push( - `Snippet ${i + 1}: name and content are required`, - ); - continue; - } - - const folderVal = s.folder?.trim() || null; - - const existing = await db - .select() - .from(snippets) - .where( - and( - eq(snippets.userId, userId), - eq(snippets.name, s.name.trim()), - folderVal - ? eq(snippets.folder, folderVal) - : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, - ), - ) - .limit(1); - - if (existing.length > 0) { - if (!overwrite) { - results.snippetsSkipped++; - continue; - } - - await db - .update(snippets) - .set({ - content: s.content.trim(), - description: s.description?.trim() || null, - folder: folderVal, - order: - typeof s.order === "number" ? s.order : existing[0].order, - hostFilter: s.hostFilter || null, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where( - and( - eq(snippets.id, existing[0].id), - eq(snippets.userId, userId), - ), - ); - results.snippetsUpdated++; - continue; - } - - const maxOrderResult = await db - .select({ maxOrder: sql`MAX(${snippets.order})` }) - .from(snippets) - .where( - and( - eq(snippets.userId, userId), - folderVal - ? eq(snippets.folder, folderVal) - : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, - ), - ); - const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; - - await db.insert(snippets).values({ - userId, - name: s.name.trim(), - content: s.content.trim(), - description: s.description?.trim() || null, - folder: folderVal, - order: typeof s.order === "number" ? s.order : maxOrder + 1, - hostFilter: s.hostFilter || null, - }); - results.snippetsImported++; - } - } + const results = await createCurrentSnippetRepository().bulkImport( + userId, + snippetsToImport, + foldersToImport, + !!overwrite, + ); authLogger.success(`Snippets bulk-imported by user ${userId}`, { operation: "snippet_bulk_import", @@ -1226,37 +907,15 @@ router.get( } try { - const ownedSnippets = await db - .select() - .from(snippets) - .where(eq(snippets.userId, userId)) - .orderBy( - sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, - asc(snippets.folder), - asc(snippets.order), - desc(snippets.updatedAt), - ); + const ownedSnippets = + await createCurrentSnippetRepository().listOwnedSnippets(userId); const roleIds = await getUserRoleIds(userId); - const sharedSnippets = await db - .select({ - id: snippets.id, - userId: snippets.userId, - name: snippets.name, - content: snippets.content, - description: snippets.description, - folder: snippets.folder, - order: snippets.order, - createdAt: snippets.createdAt, - updatedAt: snippets.updatedAt, - ownerUsername: users.username, - permissionLevel: snippetAccess.permissionLevel, - expiresAt: snippetAccess.expiresAt, - }) - .from(snippetAccess) - .innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id)) - .innerJoin(users, eq(snippets.userId, users.id)) - .where(activeSnippetAccessFilter(userId, roleIds)); + const sharedSnippets = + await createCurrentRbacAccessRepository().listVisibleSharedSnippets( + userId, + roleIds, + ); const visibleSnippets = new Map>(); for (const snippet of ownedSnippets) { @@ -1396,61 +1055,38 @@ router.post( } try { - let snippetOrder = order; - if (snippetOrder === undefined || snippetOrder === null) { - const folderValue = folder?.trim() || ""; - const maxOrderResult = await db - .select({ maxOrder: sql`MAX(${snippets.order})` }) - .from(snippets) - .where( - and( - eq(snippets.userId, userId), - folderValue - ? eq(snippets.folder, folderValue) - : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, - ), - ); - const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; - snippetOrder = maxOrder + 1; - } - - const insertData = { + const result = await createCurrentSnippetRepository().createSnippet( userId, - name: name.trim(), - content: content.trim(), - description: description?.trim() || null, - folder: folder?.trim() || null, - order: snippetOrder, - hostFilter: hostFilter ? JSON.stringify(hostFilter) : null, - }; - - const result = await db.insert(snippets).values(insertData).returning(); + { + name, + content, + description, + folder, + order, + hostFilter, + }, + ); databaseLogger.info("Command snippet created", { operation: "snippet_create", userId, - snippetId: result[0].id, + snippetId: result.id, name, }); const { ipAddress: scIp, userAgent: scUa } = getRequestMeta(req); - const scActor = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: scActor[0]?.username ?? userId, + username: await getActorUsername(userId), action: "create_snippet", resourceType: "snippet", - resourceId: String(result[0].id), + resourceId: String(result.id), resourceName: name, ipAddress: scIp, userAgent: scUa, success: true, }); - res.status(201).json(result[0]); + res.status(201).json(result); } catch (err) { authLogger.error("Failed to create snippet", err); res.status(500).json({ @@ -1516,75 +1152,36 @@ router.put( } try { - const existing = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + const snippetId = parseInt(id); + const result = await createCurrentSnippetRepository().updateSnippet( + userId, + snippetId, + updateData, + ); - if (existing.length === 0) { + if (!result) { return res.status(404).json({ error: "Snippet not found" }); } - - const updateFields: Partial<{ - updatedAt: ReturnType; - name: string; - content: string; - description: string | null; - folder: string | null; - order: number; - hostFilter: string | null; - }> = { - updatedAt: sql`CURRENT_TIMESTAMP`, - }; - - if (updateData.name !== undefined) - updateFields.name = updateData.name.trim(); - if (updateData.content !== undefined) - updateFields.content = updateData.content.trim(); - if (updateData.description !== undefined) - updateFields.description = updateData.description?.trim() || null; - if (updateData.folder !== undefined) - updateFields.folder = updateData.folder?.trim() || null; - if (updateData.order !== undefined) updateFields.order = updateData.order; - if (updateData.hostFilter !== undefined) - updateFields.hostFilter = updateData.hostFilter - ? JSON.stringify(updateData.hostFilter) - : null; - - await db - .update(snippets) - .set(updateFields) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); - - const updated = await db - .select() - .from(snippets) - .where(eq(snippets.id, parseInt(id))); databaseLogger.info("Command snippet updated", { operation: "snippet_update", userId, - snippetId: parseInt(id), + snippetId, }); const { ipAddress: suIp, userAgent: suUa } = getRequestMeta(req); - const suActor = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: suActor[0]?.username ?? userId, + username: await getActorUsername(userId), action: "update_snippet", resourceType: "snippet", resourceId: id, - resourceName: existing[0].name, + resourceName: result.existing.name, ipAddress: suIp, userAgent: suUa, success: true, }); - res.json(updated[0]); + res.json(result.updated); } catch (err) { authLogger.error("Failed to update snippet", err); res.status(500).json({ @@ -1632,37 +1229,30 @@ router.delete( } try { - const existing = await db - .select() - .from(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); + const snippetId = parseInt(id); + const existing = await createCurrentSnippetRepository().deleteSnippet( + userId, + snippetId, + ); - if (existing.length === 0) { + if (!existing) { return res.status(404).json({ error: "Snippet not found" }); } - await db - .delete(snippets) - .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); databaseLogger.info("Command snippet deleted", { operation: "snippet_delete", userId, - snippetId: parseInt(id), + snippetId, }); const { ipAddress: sdIp, userAgent: sdUa } = getRequestMeta(req); - const sdActor = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: sdActor[0]?.username ?? userId, + username: await getActorUsername(userId), action: "delete_snippet", resourceType: "snippet", resourceId: id, - resourceName: existing[0].name, + resourceName: existing.name, ipAddress: sdIp, userAgent: sdUa, success: true, diff --git a/src/backend/database/routes/sso-provider-routes.ts b/src/backend/database/routes/sso-provider-routes.ts index 7a2d068f..f06f77ad 100644 --- a/src/backend/database/routes/sso-provider-routes.ts +++ b/src/backend/database/routes/sso-provider-routes.ts @@ -3,12 +3,10 @@ import type { OIDCProviderConfig, } from "../../../types/index.js"; import type { Router } from "express"; -import { db } from "../db/index.js"; -import { ssoProviders } from "../db/schema.js"; -import { eq, asc } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { SSOProviderType } from "../../../types/index.js"; +import { createCurrentSsoProviderRepository } from "../repositories/current-sso-provider-repository.js"; import { getOIDCConfigFromEnv } from "./user-oidc-utils.js"; const authManager = AuthManager.getInstance(); @@ -91,7 +89,6 @@ function applyProviderDefaults( } export function registerSSOProviderRoutes(router: Router): void { - const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); /** @@ -108,16 +105,8 @@ export function registerSSOProviderRoutes(router: Router): void { */ router.get("/sso-providers", async (_req, res) => { try { - const providers = await db - .select({ - id: ssoProviders.id, - name: ssoProviders.name, - type: ssoProviders.type, - displayOrder: ssoProviders.displayOrder, - }) - .from(ssoProviders) - .where(eq(ssoProviders.enabled, true)) - .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + const providers = + await createCurrentSsoProviderRepository().listEnabledPublic(); // If no DB providers exist, synthesize one from env vars so SSO login // remains available when configured purely via environment variables. @@ -150,10 +139,7 @@ export function registerSSOProviderRoutes(router: Router): void { router.get("/sso-providers/admin", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = await db - .select() - .from(ssoProviders) - .orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id)); + const rows = await createCurrentSsoProviderRepository().listAll(); const result = rows.map((row) => ({ ...row, @@ -273,16 +259,13 @@ export function registerSSOProviderRoutes(router: Router): void { tempId, ); - const [inserted] = await db - .insert(ssoProviders) - .values({ - name: name.trim(), - type, - enabled, - displayOrder, - config: encryptedConfig, - }) - .returning(); + const inserted = await createCurrentSsoProviderRepository().create({ + name: name.trim(), + type, + enabled, + displayOrder, + config: encryptedConfig, + }); authLogger.info("SSO provider created", { operation: "sso_provider_create", @@ -327,12 +310,9 @@ export function registerSSOProviderRoutes(router: Router): void { return res.status(400).json({ error: "Invalid provider ID" }); } try { - const existing = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (existing.length === 0) { + const providerRepository = createCurrentSsoProviderRepository(); + const existing = await providerRepository.findById(providerId); + if (!existing) { return res.status(404).json({ error: "SSO provider not found" }); } @@ -350,10 +330,10 @@ export function registerSSOProviderRoutes(router: Router): void { config?: Record; }; - let encryptedConfig = existing[0].config; + let encryptedConfig = existing.config; if (rawConfig !== undefined) { const existingDecrypted = decryptProviderConfig( - existing[0].config, + existing.config, userId, ); const mergedConfig = { @@ -369,18 +349,18 @@ export function registerSSOProviderRoutes(router: Router): void { ); } - const [updated] = await db - .update(ssoProviders) - .set({ - ...(name !== undefined ? { name: name.trim() } : {}), - ...(type !== undefined ? { type } : {}), - ...(enabled !== undefined ? { enabled } : {}), - ...(displayOrder !== undefined ? { displayOrder } : {}), - config: encryptedConfig, - updatedAt: new Date().toISOString(), - }) - .where(eq(ssoProviders.id, providerId)) - .returning(); + const updated = await providerRepository.update(providerId, { + ...(name !== undefined ? { name: name.trim() } : {}), + ...(type !== undefined ? { type } : {}), + ...(enabled !== undefined ? { enabled } : {}), + ...(displayOrder !== undefined ? { displayOrder } : {}), + config: encryptedConfig, + updatedAt: new Date().toISOString(), + }); + + if (!updated) { + return res.status(404).json({ error: "SSO provider not found" }); + } authLogger.info("SSO provider updated", { operation: "sso_provider_update", @@ -426,27 +406,21 @@ export function registerSSOProviderRoutes(router: Router): void { return res.status(400).json({ error: "Invalid provider ID" }); } try { - const existing = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (existing.length === 0) { + const providerRepository = createCurrentSsoProviderRepository(); + const existing = await providerRepository.findById(providerId); + if (!existing) { return res.status(404).json({ error: "SSO provider not found" }); } - const associatedUsers = db.$client - .prepare( - "SELECT COUNT(*) as count FROM users WHERE sso_provider_id = ?", - ) - .get(providerId) as { count: number }; - if (associatedUsers.count > 0) { + const associatedUserCount = + await providerRepository.countUsersByProviderId(providerId); + if (associatedUserCount > 0) { return res.status(409).json({ - error: `Cannot delete provider: ${associatedUsers.count} user(s) are associated with it`, + error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`, }); } - await db.delete(ssoProviders).where(eq(ssoProviders.id, providerId)); + await providerRepository.delete(providerId); authLogger.info("SSO provider deleted", { operation: "sso_provider_delete", userId, diff --git a/src/backend/database/routes/tailscale-routes.ts b/src/backend/database/routes/tailscale-routes.ts index 423c460f..2052c988 100644 --- a/src/backend/database/routes/tailscale-routes.ts +++ b/src/backend/database/routes/tailscale-routes.ts @@ -1,8 +1,8 @@ import { Router } from "express"; import type { RequestHandler, Router as ExpressRouter } from "express"; -import { db } from "../db/index.js"; import { apiLogger } from "../../utils/logger.js"; import { getProxyAgent } from "../../utils/proxy-agent.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; interface TailscaleDevice { id: string; @@ -56,11 +56,9 @@ export function registerTailscaleRoutes( */ router.get("/devices", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'") - .get() as { value: string } | undefined; - - const apiKey = row?.value ?? ""; + const apiKey = + (await createCurrentSettingsRepository().get("tailscale_api_key")) ?? + ""; if (!apiKey) { return res.json({ devices: [], hasApiKey: false }); } diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts index 0bb8154e..6bc3d16c 100644 --- a/src/backend/database/routes/terminal.ts +++ b/src/backend/database/routes/terminal.ts @@ -1,11 +1,11 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { commandHistory, hosts } from "../db/schema.js"; -import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { createCurrentCommandHistoryRepository } from "../repositories/current-command-history-repository.js"; +import { createCurrentHostResolutionRepository } from "../repositories/current-host-resolution-repository.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; const router = express.Router(); @@ -88,12 +88,11 @@ router.post( }); } - const globalEnabledRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'command_history_enabled'", - ) - .get() as { value: string } | undefined; - if (globalEnabledRow && globalEnabledRow.value === "false") { + const globalEnabled = await createCurrentSettingsRepository().getBoolean( + "command_history_enabled", + true, + ); + if (!globalEnabled) { return res.status(201).json({ id: 0, userId, @@ -103,12 +102,12 @@ router.post( }); } - const hostRecord = await db - .select({ enableCommandHistory: hosts.enableCommandHistory }) - .from(hosts) - .where(eq(hosts.id, parseInt(hostId, 10))) - .limit(1); - if (hostRecord.length > 0 && hostRecord[0].enableCommandHistory === false) { + const hostRecord = + await createCurrentHostResolutionRepository().findHostById( + parseInt(hostId, 10), + userId, + ); + if (hostRecord?.enableCommandHistory === false) { return res.status(201).json({ id: 0, userId, @@ -119,18 +118,13 @@ router.post( } try { - const insertData = { + const result = await createCurrentCommandHistoryRepository().create( userId, - hostId: parseInt(hostId, 10), - command: trimmedCommand, - }; + parseInt(hostId, 10), + trimmedCommand, + ); - const result = await db - .insert(commandHistory) - .values(insertData) - .returning(); - - res.status(201).json(result[0]); + res.status(201).json(result); } catch (err) { authLogger.error("Failed to save command to history", err); res.status(500).json({ @@ -182,23 +176,11 @@ router.get( } try { - const result = await db - .select({ - command: commandHistory.command, - maxExecutedAt: sql`MAX(${commandHistory.executedAt})`, - }) - .from(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostIdNum), - ), - ) - .groupBy(commandHistory.command) - .orderBy(desc(sql`MAX(${commandHistory.executedAt})`)) - .limit(500); - - const uniqueCommands = result.map((r) => r.command); + const uniqueCommands = + await createCurrentCommandHistoryRepository().listUniqueCommandsForHost( + userId, + hostIdNum, + ); res.json(uniqueCommands); } catch (err) { @@ -258,15 +240,11 @@ router.post( try { const hostIdNum = parseInt(hostId, 10); - await db - .delete(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostIdNum), - eq(commandHistory.command, command.trim()), - ), - ); + await createCurrentCommandHistoryRepository().deleteCommandForHost( + userId, + hostIdNum, + command.trim(), + ); res.json({ success: true }); } catch (err) { @@ -317,14 +295,10 @@ router.delete( } try { - await db - .delete(commandHistory) - .where( - and( - eq(commandHistory.userId, userId), - eq(commandHistory.hostId, hostIdNum), - ), - ); + await createCurrentCommandHistoryRepository().deleteByUserAndHost( + userId, + hostIdNum, + ); databaseLogger.info("Terminal history cleared", { operation: "terminal_history_clear", userId, @@ -360,20 +334,18 @@ router.get( authenticateJWT, async (_req: Request, res: Response) => { try { - const timeoutRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", - ) - .get() as { value: string } | undefined; - const enabledRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_persistence_enabled'", - ) - .get() as { value: string } | undefined; + const settings = createCurrentSettingsRepository(); + const timeoutValue = await settings.get( + "terminal_session_timeout_minutes", + ); + const enabled = await settings.getBoolean( + "terminal_session_persistence_enabled", + true, + ); res.json({ - timeoutMinutes: timeoutRow ? parseInt(timeoutRow.value, 10) : 30, - enabled: enabledRow ? enabledRow.value === "true" : true, + timeoutMinutes: timeoutValue ? parseInt(timeoutValue, 10) : 30, + enabled, }); } catch (err) { authLogger.error("Failed to fetch session settings", err); @@ -429,20 +401,19 @@ router.post( } try { + const settings = createCurrentSettingsRepository(); if (timeoutMinutes !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_timeout_minutes', ?)", - ) - .run(String(timeoutMinutes)); + await settings.set( + "terminal_session_timeout_minutes", + String(timeoutMinutes), + ); } if (enabled !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_persistence_enabled', ?)", - ) - .run(String(enabled)); + await settings.set( + "terminal_session_persistence_enabled", + String(enabled), + ); } res.json({ success: true }); diff --git a/src/backend/database/routes/termix-id.test.ts b/src/backend/database/routes/termix-id.test.ts index 32d1e983..c1e0b3b1 100644 --- a/src/backend/database/routes/termix-id.test.ts +++ b/src/backend/database/routes/termix-id.test.ts @@ -53,21 +53,39 @@ vi.mock("../../utils/user-crypto.js", () => ({ UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) }, })); +vi.mock("../repositories/current-termix-identity-ca-repository.js", () => ({ + createCurrentTermixIdentityCaRepository: vi.fn(() => ({ + findPublicByIdentityId: vi.fn(), + findDecryptedByIdentityId: vi.fn(), + createEncryptedForUser: vi.fn(), + updateEncryptedForIdentity: vi.fn(), + deleteByIdentityId: vi.fn(), + })), +})); + +vi.mock("../repositories/current-termix-identity-repository.js", () => ({ + createCurrentTermixIdentityRepository: vi.fn(() => ({ + findIdentityForUser: vi.fn(), + findIdentityByHandle: vi.fn(), + isHandleTaken: vi.fn(), + createIdentity: vi.fn(), + updateIdentityForUser: vi.fn(), + deleteIdentityForUser: vi.fn(), + listKeysByIdentityId: vi.fn(), + listEnabledKeysByIdentityId: vi.fn(), + listLinkedCredentialIds: vi.fn(), + createKey: vi.fn(), + updateKeyForUser: vi.fn(), + deleteKeyForUser: vi.fn(), + findKeyForUser: vi.fn(), + })), +})); + vi.mock("./termix-id-keys.js", () => ({ termixIdKeysRouter: { use: vi.fn() }, matchesAlgoFilter: vi.fn(() => true), })); -vi.mock("../../utils/simple-db-ops.js", () => ({ - SimpleDBOps: vi.fn().mockImplementation(() => ({ - findOne: vi.fn(), - findAll: vi.fn(), - insert: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - })), -})); - // Chainable Drizzle stub — supports arbitrary method chains and resolves via .then() function makeChain(resolveValue: unknown) { const chain: Record = {}; @@ -110,7 +128,7 @@ describe("GET /termix-id/linked-credentials", () => { expect(router).toBeDefined(); expect(router).toBeDefined(); - }); + }, 15_000); it("returns empty list when identity has no keys", async () => { mockSelect diff --git a/src/backend/database/routes/termix-id.ts b/src/backend/database/routes/termix-id.ts index 3a069c0f..1e4fd5cd 100644 --- a/src/backend/database/routes/termix-id.ts +++ b/src/backend/database/routes/termix-id.ts @@ -1,21 +1,15 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import type { Request, Response } from "express"; -import { db } from "../db/index.js"; -import { - termixIdentities, - termixIdentityKeys, - sshCredentials, - termixIdentityCa, - users, -} from "../db/schema.js"; -import { and, eq, asc } from "drizzle-orm"; +import { createCurrentCredentialRepository } from "../repositories/current-credential-repository.js"; +import { createCurrentTermixIdentityRepository } from "../repositories/current-termix-identity-repository.js"; +import { createCurrentTermixIdentityCaRepository } from "../repositories/current-termix-identity-ca-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; // ssh2 is CommonJS; Node's cjs-module-lexer does not surface its `utils` named // export, so we use a default import (esModuleInterop) and read `.utils` off it. import ssh2 from "ssh2"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; -import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { parsePublicKey, matchesAlgoFilter } from "./termix-id-keys.js"; import { @@ -38,17 +32,6 @@ const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]); // Max stored length for a free-text description. const MAX_DESCRIPTION_LENGTH = 500; -// Decrypted ssh_credentials fields this route reads. A type alias (not an -// interface) so it satisfies the generic bound on SimpleDBOps.select. -type CredentialRow = { - name?: string | null; - authType?: string | null; - publicKey?: string | null; - privateKey?: string | null; - key?: string | null; - keyPassword?: string | null; -}; - function cleanDescription(value: string | null | undefined): string | null { if (typeof value !== "string") return null; return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null; @@ -93,23 +76,14 @@ async function derivePublicFromPrivate( } async function getIdentityForUser(userId: string) { - const rows = await db - .select() - .from(termixIdentities) - .where(eq(termixIdentities.userId, userId)) - .limit(1); - return rows[0] ?? null; + return createCurrentTermixIdentityRepository().findIdentityForUser(userId); } // Resolve the real username for audit_logs (the column expects a username, not // the user id), matching how the credentials/snippets routes log. async function getActorUsername(userId: string): Promise { - const rows = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - return rows[0]?.username ?? userId; + const user = await createCurrentUserRepository().findById(userId); + return user?.username ?? userId; } // --------------------------------------------------------------------------- @@ -137,26 +111,19 @@ async function resolveHandle(req: Request, res: Response) { } try { - const identity = await db - .select() - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); + const identity = + await createCurrentTermixIdentityRepository().findIdentityByHandle( + handle, + ); - if (identity.length === 0) { + if (!identity) { return res.status(404).type("text/plain").send("Not found\n"); } - let keys = await db - .select() - .from(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.identityId, identity[0].id), - eq(termixIdentityKeys.enabled, true), - ), - ) - .orderBy(asc(termixIdentityKeys.id)); + let keys = + await createCurrentTermixIdentityRepository().listEnabledKeysByIdentityId( + identity.id, + ); if (algoFilter) { keys = keys.filter((k) => matchesAlgoFilter(k.algorithm, algoFilter)); @@ -280,25 +247,23 @@ async function caResolver(req: Request, res: Response) { return res.status(404).type("text/plain").send("Not found\n"); } try { - const identity = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - if (identity.length === 0) { + const identity = + await createCurrentTermixIdentityRepository().findIdentityByHandle( + handle, + ); + if (!identity) { return res.status(404).type("text/plain").send("Not found\n"); } - const ca = await db - .select({ publicKey: termixIdentityCa.publicKey }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity[0].id)) - .limit(1); + const ca = + await createCurrentTermixIdentityCaRepository().findPublicByIdentityId( + identity.id, + ); res.setHeader("Content-Type", "text/plain; charset=utf-8"); - if (ca.length === 0) { + if (!ca) { return res.status(404).send("No CA configured\n"); } - return res.send(`${ca[0].publicKey} termix-id-ca@${handle}\n`); + return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`); } catch (err) { authLogger.error("Termix ID CA resolve failed", err); return res.status(500).type("text/plain").send("Internal Server Error\n"); @@ -332,11 +297,10 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { if (!identity) { return res.json({ identity: null, keys: [] }); } - const keys = await db - .select() - .from(termixIdentityKeys) - .where(eq(termixIdentityKeys.identityId, identity.id)) - .orderBy(asc(termixIdentityKeys.id)); + const keys = + await createCurrentTermixIdentityRepository().listKeysByIdentityId( + identity.id, + ); res.json({ identity: { ...identity, @@ -377,12 +341,9 @@ router.get( return res.json({ available: false, valid: false }); } try { - const existing = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - res.json({ available: existing.length === 0, valid: true }); + const taken = + await createCurrentTermixIdentityRepository().isHandleTaken(handle); + res.json({ available: !taken, valid: true }); } catch (err) { authLogger.error("Failed to check Termix ID handle", err); res.status(500).json({ error: "Failed to check handle" }); @@ -433,19 +394,17 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { }); } - const taken = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - if (taken.length > 0) { + const termixIdentityRepository = createCurrentTermixIdentityRepository(); + const taken = await termixIdentityRepository.isHandleTaken(handle); + if (taken) { return res.status(409).json({ error: "Handle already taken" }); } - const inserted = await db - .insert(termixIdentities) - .values({ userId, handle, description }) - .returning(); + const inserted = await termixIdentityRepository.createIdentity({ + userId, + handle, + description, + }); databaseLogger.info("Termix ID created", { operation: "termix_id_create", @@ -459,14 +418,14 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => { username: await getActorUsername(userId), action: "create_termix_id", resourceType: "termix_id", - resourceId: String(inserted[0].id), + resourceId: String(inserted.id), resourceName: handle, ipAddress, userAgent, success: true, }); - res.status(201).json(inserted[0]); + res.status(201).json(inserted); } catch (err) { // The check-then-insert above is not atomic; the UNIQUE constraints on // handle and user_id are the real guard, so map a violation to 409. @@ -516,12 +475,9 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid handle" }); } if (handle !== identity.handle) { - const taken = await db - .select({ id: termixIdentities.id }) - .from(termixIdentities) - .where(eq(termixIdentities.handle, handle)) - .limit(1); - if (taken.length > 0) { + const taken = + await createCurrentTermixIdentityRepository().isHandleTaken(handle); + if (taken) { return res.status(409).json({ error: "Handle already taken" }); } } @@ -532,11 +488,11 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { updates.description = cleanDescription(req.body.description); } - const updated = await db - .update(termixIdentities) - .set({ ...updates, updatedAt: new Date().toISOString() }) - .where(eq(termixIdentities.userId, userId)) - .returning(); + const updated = + await createCurrentTermixIdentityRepository().updateIdentityForUser( + userId, + { ...updates, updatedAt: new Date().toISOString() }, + ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -545,7 +501,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { action: "update_termix_id", resourceType: "termix_id", resourceId: String(identity.id), - resourceName: updated[0]?.handle ?? identity.handle, + resourceName: updated?.handle ?? identity.handle, details: updates.handle && updates.handle !== identity.handle ? `renamed ${identity.handle} -> ${updates.handle}` @@ -555,7 +511,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { success: true, }); - res.json(updated[0]); + res.json(updated); } catch (err) { if (isUniqueConstraintError(err)) { return res.status(409).json({ error: "Handle already taken" }); @@ -584,9 +540,7 @@ router.delete("/", authenticateJWT, async (req: Request, res: Response) => { if (!identity) { return res.status(404).json({ error: "No Termix ID found" }); } - await db - .delete(termixIdentities) - .where(eq(termixIdentities.userId, userId)); + await createCurrentTermixIdentityRepository().deleteIdentityForUser(userId); databaseLogger.info("Termix ID deleted", { operation: "termix_id_delete", @@ -665,23 +619,14 @@ router.post( let resolvedLabel = label; if (credentialId) { - const credResult = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", - userId, - ); - if (credResult.length === 0) { + const cred = + await createCurrentCredentialRepository().findDecryptedByIdForUser( + userId, + credentialId, + ); + if (!cred) { return res.status(404).json({ error: "Credential not found" }); } - const cred = credResult[0]; // The UI only offers key credentials, but the UI is not authoritative — // reject non-key credentials server-side before treating cred.key as a // private key. @@ -721,31 +666,25 @@ router.post( } // Dedupe within this identity by normalized " ". - const existing = await db - .select({ - id: termixIdentityKeys.id, - publicKey: termixIdentityKeys.publicKey, - }) - .from(termixIdentityKeys) - .where(eq(termixIdentityKeys.identityId, identity.id)); + const termixIdentityRepository = createCurrentTermixIdentityRepository(); + const existing = await termixIdentityRepository.listKeysByIdentityId( + identity.id, + ); if (existing.some((k) => k.publicKey === parsed.normalized)) { return res.status(409).json({ error: "This key is already published" }); } - const inserted = await db - .insert(termixIdentityKeys) - .values({ - identityId: identity.id, - userId, - publicKey: parsed.normalized, - keyType: parsed.type, - algorithm: parsed.algorithm, - label: resolvedLabel, - comment: parsed.comment || null, - source, - credentialId: credentialId || null, - }) - .returning(); + const inserted = await termixIdentityRepository.createKey({ + identityId: identity.id, + userId, + publicKey: parsed.normalized, + keyType: parsed.type, + algorithm: parsed.algorithm, + label: resolvedLabel, + comment: parsed.comment || null, + source, + credentialId: credentialId || null, + }); databaseLogger.info("Termix ID key added", { operation: "termix_id_key_add", @@ -760,7 +699,7 @@ router.post( username: await getActorUsername(userId), action: "add_termix_id_key", resourceType: "termix_id_key", - resourceId: String(inserted[0].id), + resourceId: String(inserted.id), resourceName: identity.handle, details: `${parsed.algorithm} (${source})`, ipAddress, @@ -768,7 +707,7 @@ router.post( success: true, }); - res.status(201).json(inserted[0]); + res.status(201).json(inserted); } catch (err) { authLogger.error("Failed to add Termix ID key", err); res.status(500).json({ error: "Failed to add key" }); @@ -863,34 +802,31 @@ router.post( usageCount: 0, lastUsed: null, }; - const created = (await SimpleDBOps.insert( - sshCredentials, - "ssh_credentials", - credData, - userId, - )) as typeof credData & { id: number }; + const created = + await createCurrentCredentialRepository().createEncryptedForUser( + userId, + credData, + ); credentialId = created.id; } - // There is no single transaction here (SimpleDBOps.insert is async and - // better-sqlite3 transactions are sync), so if publishing the key fails + // There is no single transaction here because credential encryption is async, + // so if publishing the key fails // after the vault credential was created, compensate by deleting it to // avoid leaving an orphaned credential behind. + const termixIdentityRepository = createCurrentTermixIdentityRepository(); const runInsert = () => - db - .insert(termixIdentityKeys) - .values({ - identityId: identity.id, - userId, - publicKey: parsed.normalized, - keyType: parsed.type, - algorithm: parsed.algorithm, - label: label || `Generated ${parsed.algorithm}`, - comment: parsed.comment || null, - source: "generated", - credentialId, - }) - .returning(); + termixIdentityRepository.createKey({ + identityId: identity.id, + userId, + publicKey: parsed.normalized, + keyType: parsed.type, + algorithm: parsed.algorithm, + label: label || `Generated ${parsed.algorithm}`, + comment: parsed.comment || null, + source: "generated", + credentialId, + }); let inserted: Awaited>; try { @@ -898,14 +834,10 @@ router.post( } catch (insertErr) { if (credentialId !== null) { try { - await db - .delete(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ); + await createCurrentCredentialRepository().deleteForUser( + userId, + credentialId, + ); } catch { // best-effort cleanup } @@ -926,7 +858,7 @@ router.post( username: await getActorUsername(userId), action: "generate_termix_id_key", resourceType: "termix_id_key", - resourceId: String(inserted[0].id), + resourceId: String(inserted.id), resourceName: identity.handle, details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`, ipAddress, @@ -937,7 +869,7 @@ router.post( // The private key is also returned once so the user can download it; when // saveCredential is true it additionally lives (encrypted) in the vault. res.status(201).json({ - key: inserted[0], + key: inserted, privateKey: pair.private, publicKey: parsed.normalized, credentialId, @@ -997,17 +929,13 @@ router.patch( ? req.body.label.trim() || null : null; } - const updated = await db - .update(termixIdentityKeys) - .set(updates) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .returning(); - if (updated.length === 0) { + const updated = + await createCurrentTermixIdentityRepository().updateKeyForUser( + userId, + id, + updates, + ); + if (!updated) { return res.status(404).json({ error: "Key not found" }); } @@ -1018,7 +946,7 @@ router.patch( action: "update_termix_id_key", resourceType: "termix_id_key", resourceId: String(id), - resourceName: String(updated[0].label ?? updated[0].algorithm), + resourceName: String(updated.label ?? updated.algorithm), details: updates.enabled !== undefined ? `enabled: ${updates.enabled}` @@ -1028,7 +956,7 @@ router.patch( success: true, }); - res.json(updated[0]); + res.json(updated); } catch (err) { authLogger.error("Failed to update Termix ID key", err); res.status(500).json({ error: "Failed to update key" }); @@ -1064,16 +992,12 @@ router.delete( return res.status(400).json({ error: "Invalid key id" }); } try { - const deleted = await db - .delete(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .returning(); - if (deleted.length === 0) { + const deleted = + await createCurrentTermixIdentityRepository().deleteKeyForUser( + userId, + id, + ); + if (!deleted) { return res.status(404).json({ error: "Key not found" }); } @@ -1122,15 +1046,12 @@ async function getCaForUser( userId: string, identityId: number, ): Promise { - const rows = await SimpleDBOps.select( - db - .select() - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identityId)), - "termix_identity_ca", - userId, + return ( + (await createCurrentTermixIdentityCaRepository().findDecryptedByIdentityId( + userId, + identityId, + )) ?? undefined ); - return rows[0]; } /** @@ -1150,19 +1071,15 @@ router.get("/ca", authenticateJWT, async (req: Request, res: Response) => { try { const identity = await getIdentityForUser(userId); if (!identity) return res.json({ ca: null }); - const ca = await db - .select({ - publicKey: termixIdentityCa.publicKey, - validityDays: termixIdentityCa.validityDays, - }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .limit(1); - if (ca.length === 0) return res.json({ ca: null }); + const ca = + await createCurrentTermixIdentityCaRepository().findPublicByIdentityId( + identity.id, + ); + if (!ca) return res.json({ ca: null }); res.json({ ca: { - publicKey: ca[0].publicKey, - validityDays: ca[0].validityDays, + publicKey: ca.publicKey, + validityDays: ca.validityDays, resolverPath: `/termix-id/u/${identity.handle}/ca`, }, }); @@ -1205,29 +1122,21 @@ router.post( .status(400) .json({ error: "Create a Termix ID handle first" }); } - const existing = await db - .select({ id: termixIdentityCa.id }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .limit(1); - if (existing.length > 0) { + const caRepository = createCurrentTermixIdentityCaRepository(); + const existing = await caRepository.findPublicByIdentityId(identity.id); + if (existing) { return res.status(409).json({ error: "CA already exists" }); } const validityDays = clampValidityDays(req.body?.validityDays, 90); const generated = generateCa(); - await SimpleDBOps.insert( - termixIdentityCa, - "termix_identity_ca", - { - identityId: identity.id, - userId, - publicKey: generated.publicKeyLine, - privateKey: generated.privateKeyPem, - validityDays, - }, + await caRepository.createEncryptedForUser(userId, { + identityId: identity.id, userId, - ); + publicKey: generated.publicKeyLine, + privateKey: generated.privateKeyPem, + validityDays, + }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -1283,37 +1192,25 @@ router.post( const identity = await getIdentityForUser(userId); if (!identity) return res.status(404).json({ error: "No Termix ID found" }); - const existing = await db - .select({ - id: termixIdentityCa.id, - validityDays: termixIdentityCa.validityDays, - }) - .from(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .limit(1); - if (existing.length === 0) { + const caRepository = createCurrentTermixIdentityCaRepository(); + const existing = await caRepository.findPublicByIdentityId(identity.id); + if (!existing) { return res.status(404).json({ error: "No CA to rotate" }); } const validityDays = clampValidityDays( req.body?.validityDays, - existing[0].validityDays, + existing.validityDays, ); const generated = generateCa(); // Rotating invalidates every previously issued certificate — this IS the // central revocation mechanism. - await SimpleDBOps.update( - termixIdentityCa, - "termix_identity_ca", - eq(termixIdentityCa.identityId, identity.id), - { - publicKey: generated.publicKeyLine, - privateKey: generated.privateKeyPem, - validityDays, - updatedAt: new Date().toISOString(), - }, - userId, - ); + await caRepository.updateEncryptedForIdentity(userId, identity.id, { + publicKey: generated.publicKeyLine, + privateKey: generated.privateKeyPem, + validityDays, + updatedAt: new Date().toISOString(), + }); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -1356,11 +1253,11 @@ router.delete("/ca", authenticateJWT, async (req: Request, res: Response) => { try { const identity = await getIdentityForUser(userId); if (!identity) return res.status(404).json({ error: "No Termix ID found" }); - const deleted = await db - .delete(termixIdentityCa) - .where(eq(termixIdentityCa.identityId, identity.id)) - .returning(); - if (deleted.length === 0) { + const deleted = + await createCurrentTermixIdentityCaRepository().deleteByIdentityId( + identity.id, + ); + if (!deleted) { return res.status(404).json({ error: "No CA to delete" }); } @@ -1424,20 +1321,13 @@ router.post( return res.status(400).json({ error: "Invalid key id" }); } try { - const keyRows = await db - .select() - .from(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.id, id), - eq(termixIdentityKeys.userId, userId), - ), - ) - .limit(1); - if (keyRows.length === 0) { + const key = await createCurrentTermixIdentityRepository().findKeyForUser( + userId, + id, + ); + if (!key) { return res.status(404).json({ error: "Key not found" }); } - const key = keyRows[0]; if (!ed25519RawFromLine(key.publicKey)) { return res.status(400).json({ error: "Certificates are only supported for Ed25519 keys", @@ -1523,22 +1413,10 @@ router.get( try { const identity = await getIdentityForUser(userId); if (!identity) return res.json({ credentialIds: [] }); - const keys = await db - .select({ credentialId: termixIdentityKeys.credentialId }) - .from(termixIdentityKeys) - .where( - and( - eq(termixIdentityKeys.identityId, identity.id), - eq(termixIdentityKeys.enabled, true), - ), + const credentialIds = + await createCurrentTermixIdentityRepository().listLinkedCredentialIds( + identity.id, ); - const credentialIds = [ - ...new Set( - keys - .map((k) => k.credentialId) - .filter((cid): cid is number => cid !== null), - ), - ]; res.json({ credentialIds }); } catch (err) { authLogger.error("Failed to fetch linked credential IDs", err); diff --git a/src/backend/database/routes/user-admin-routes.ts b/src/backend/database/routes/user-admin-routes.ts index d6630e1b..8148ec21 100644 --- a/src/backend/database/routes/user-admin-routes.ts +++ b/src/backend/database/routes/user-admin-routes.ts @@ -1,18 +1,32 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq, and } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users, roles, userRoles } from "../db/schema.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import { AuthManager } from "../../utils/auth-manager.js"; +import { createCurrentRoleRepository } from "../repositories/current-role-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; +import type { + UserRecord, + UserRepository, +} from "../repositories/user-repository.js"; function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } +async function getUserByPreferredIdentifier( + userRepository: UserRepository, + userId: string | null, + username: string | null, +): Promise { + return userId + ? userRepository.findById(userId) + : userRepository.findByUsername(username!); +} + export function registerUserAdminRoutes( router: Router, authenticateJWT: RequestHandler, @@ -35,15 +49,7 @@ export function registerUserAdminRoutes( */ router.get("/list", authenticateJWT, async (req, res) => { try { - const allUsers = await db - .select({ - id: users.id, - username: users.username, - isAdmin: users.isAdmin, - isOidc: users.isOidc, - passwordHash: users.passwordHash, - }) - .from(users); + const allUsers = await createCurrentUserRepository().listAll(); res.json({ users: allUsers.map((u) => ({ @@ -108,95 +114,51 @@ export function registerUserAdminRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(userId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } - const targetUser = await db - .select() - .from(users) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ) - .limit(1); - if (!targetUser || targetUser.length === 0) { + const targetUser = await getUserByPreferredIdentifier( + userRepository, + resolvedUserId, + resolvedUsername, + ); + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - if (targetUser[0].isAdmin) { + if (targetUser.isAdmin) { return res.status(400).json({ error: "User is already an admin" }); } - await db - .update(users) - .set({ isAdmin: true }) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ); + await userRepository.update(targetUser.id, { isAdmin: true }); try { - const targetId = targetUser[0].id; - const adminRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "admin")) - .limit(1); - const userRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "user")) - .limit(1); - if (adminRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, adminRole[0].id), - ), - ); - await db.insert(userRoles).values({ - userId: targetId, - roleId: adminRole[0].id, - grantedBy: userId, - }); - } - if (userRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, userRole[0].id), - ), - ); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId: targetUser.id, + addRoleName: "admin", + removeRoleName: "user", + grantedBy: userId, + }); } catch (roleError) { authLogger.error("Failed to sync admin role on make-admin", roleError, { operation: "make_admin_role_sync", - userId: targetUser[0].id, + userId: targetUser.id, }); } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("make_admin_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist admin promotion to disk", saveError, { operation: "make_admin_save_failed", - userId: targetUser[0].id, - username: targetUser[0].username, + userId: targetUser.id, + username: targetUser.username, }, ); } @@ -204,29 +166,24 @@ export function registerUserAdminRoutes( authLogger.info("Admin privileges granted", { operation: "admin_grant", adminId: userId, - targetUserId: targetUser[0].id, - targetUsername: targetUser[0].username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, }); const { ipAddress, userAgent } = getRequestMeta(req); - const adminUserRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: adminUserRecord[0]?.username ?? userId, + username: adminUser.username ?? userId, action: "make_admin", resourceType: "user", - resourceId: targetUser[0].id, - resourceName: targetUser[0].username, + resourceId: targetUser.id, + resourceName: targetUser.username, ipAddress, userAgent, success: true, }); - res.json({ message: `User ${targetUser[0].username} is now an admin` }); + res.json({ message: `User ${targetUser.username} is now an admin` }); } catch (err) { authLogger.error("Failed to make user admin", err); res.status(500).json({ error: "Failed to make user admin" }); @@ -281,135 +238,86 @@ export function registerUserAdminRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(userId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } if ( - (resolvedUserId && adminUser[0].id === resolvedUserId) || - (resolvedUsername && adminUser[0].username === resolvedUsername) + (resolvedUserId && adminUser.id === resolvedUserId) || + (resolvedUsername && adminUser.username === resolvedUsername) ) { return res .status(400) .json({ error: "Cannot remove your own admin status" }); } - const targetUser = await db - .select() - .from(users) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ) - .limit(1); - if (!targetUser || targetUser.length === 0) { + const targetUser = await getUserByPreferredIdentifier( + userRepository, + resolvedUserId, + resolvedUsername, + ); + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - if (!targetUser[0].isAdmin) { + if (!targetUser.isAdmin) { return res.status(400).json({ error: "User is not an admin" }); } - await db - .update(users) - .set({ isAdmin: false }) - .where( - resolvedUserId - ? eq(users.id, resolvedUserId) - : eq(users.username, resolvedUsername!), - ); + await userRepository.update(targetUser.id, { isAdmin: false }); try { - const targetId = targetUser[0].id; - const adminRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "admin")) - .limit(1); - const userRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "user")) - .limit(1); - if (adminRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, adminRole[0].id), - ), - ); - } - if (userRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, targetId), - eq(userRoles.roleId, userRole[0].id), - ), - ); - await db.insert(userRoles).values({ - userId: targetId, - roleId: userRole[0].id, - grantedBy: userId, - }); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId: targetUser.id, + addRoleName: "user", + removeRoleName: "admin", + grantedBy: userId, + }); } catch (roleError) { authLogger.error( "Failed to sync user role on remove-admin", roleError, { operation: "remove_admin_role_sync", - userId: targetUser[0].id, + userId: targetUser.id, }, ); } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("remove_admin_explicit_save"); } catch (saveError) { authLogger.error("Failed to persist admin removal to disk", saveError, { operation: "remove_admin_save_failed", - userId: targetUser[0].id, - username: targetUser[0].username, + userId: targetUser.id, + username: targetUser.username, }); } authLogger.info("Admin privileges revoked", { operation: "admin_revoke", adminId: userId, - targetUserId: targetUser[0].id, - targetUsername: targetUser[0].username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, }); const { ipAddress, userAgent } = getRequestMeta(req); - const adminUserRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: adminUserRecord[0]?.username ?? userId, + username: adminUser.username ?? userId, action: "remove_admin", resourceType: "user", - resourceId: targetUser[0].id, - resourceName: targetUser[0].username, + resourceId: targetUser.id, + resourceName: targetUser.username, ipAddress, userAgent, success: true, }); res.json({ - message: `Admin status removed from ${targetUser[0].username}`, + message: `Admin status removed from ${targetUser.username}`, }); } catch (err) { authLogger.error("Failed to remove admin status", err); @@ -450,13 +358,12 @@ export function registerUserAdminRoutes( */ router.post("/admin-create", authenticateJWT, async (req, res) => { const adminId = (req as AuthenticatedRequest).userId; + const userRepository = createCurrentUserRepository(); + let adminUser: UserRecord | null = null; try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + adminUser = await userRepository.findById(adminId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } } catch (err) { @@ -473,55 +380,39 @@ export function registerUserAdminRoutes( } try { - const existing = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (existing && existing.length > 0) { + const existing = await userRepository.findByUsername(username); + if (existing) { return res.status(409).json({ error: "Username already exists" }); } const password_hash = await bcrypt.hash(password, 10); const id = nanoid(); - db.$client.transaction(() => { - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run( - id, - username, - password_hash, - 0, - 0, - "", - "", - "", - "", - "", - "", - "", - "openid email profile", - null, - 0, - null, - ); - })(); + await userRepository.create({ + id, + username, + passwordHash: password_hash, + isAdmin: false, + isOidc: false, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); try { - const userRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, "user")) - .limit(1); - if (userRole.length > 0) { - await db.insert(userRoles).values({ - userId: id, - roleId: userRole[0].id, - grantedBy: adminId, - }); - } + await createCurrentRoleRepository().assignRoleNameToUser({ + userId: id, + roleName: "user", + grantedBy: adminId, + }); } catch (roleError) { authLogger.error( "Failed to assign default role during admin create", @@ -537,7 +428,7 @@ export function registerUserAdminRoutes( try { await authManager.registerUser(id, password); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, id)); + await userRepository.delete(id); authLogger.error( "Failed to setup user encryption during admin create, rolled back", encryptionError, @@ -549,8 +440,7 @@ export function registerUserAdminRoutes( } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("admin_create_user_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist admin-created user to disk", @@ -570,14 +460,9 @@ export function registerUserAdminRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const adminRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, adminId)) - .limit(1); await logAudit({ userId: adminId, - username: adminRecord[0]?.username ?? adminId, + username: adminUser.username ?? adminId, action: "create_user", resourceType: "user", resourceId: id, diff --git a/src/backend/database/routes/user-api-key-routes.ts b/src/backend/database/routes/user-api-key-routes.ts index bb3ada5d..c7a582cf 100644 --- a/src/backend/database/routes/user-api-key-routes.ts +++ b/src/backend/database/routes/user-api-key-routes.ts @@ -3,11 +3,10 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; -import { eq } from "drizzle-orm"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { apiKeys, users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { createCurrentApiKeyRepository } from "../repositories/current-api-key-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; export function registerUserApiKeyRoutes( router: Router, @@ -56,6 +55,8 @@ export function registerUserApiKeyRoutes( router.post("/api-keys", requireAdmin, async (req, res) => { try { const { name, userId: targetUserId, expiresAt } = req.body; + const apiKeyRepository = createCurrentApiKeyRepository(); + const userRepository = createCurrentUserRepository(); if (typeof name !== "string" || !name.trim()) { return res.status(400).json({ error: "name is required" }); @@ -64,12 +65,8 @@ export function registerUserApiKeyRoutes( return res.status(400).json({ error: "userId is required" }); } - const targetUser = await db - .select() - .from(users) - .where(eq(users.id, targetUserId)) - .limit(1); - if (targetUser.length === 0) { + const targetUser = await userRepository.findById(targetUserId); + if (!targetUser) { return res.status(404).json({ error: "Target user not found" }); } @@ -93,7 +90,7 @@ export function registerUserApiKeyRoutes( const keyId = nanoid(); const now = new Date().toISOString(); - await db.insert(apiKeys).values({ + await apiKeyRepository.create({ id: keyId, userId: targetUserId, name: name.trim(), @@ -105,26 +102,21 @@ export function registerUserApiKeyRoutes( isActive: true, }); - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); - const actorId = (req as AuthenticatedRequest).userId; const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, actorId)) - .limit(1); + const actorRecord = actorId + ? await userRepository.findById(actorId) + : null; await logAudit({ userId: actorId, - username: actorRecord[0]?.username ?? actorId, + username: actorRecord?.username ?? actorId, action: "create_api_key", resourceType: "api_key", resourceId: keyId, resourceName: name.trim(), details: JSON.stringify({ targetUserId, - targetUsername: targetUser[0].username, + targetUsername: targetUser.username, }), ipAddress, userAgent, @@ -135,7 +127,7 @@ export function registerUserApiKeyRoutes( id: keyId, name: name.trim(), userId: targetUserId, - username: targetUser[0].username, + username: targetUser.username, tokenPrefix, createdAt: now, expiresAt: expiresAtValue, @@ -165,21 +157,7 @@ export function registerUserApiKeyRoutes( */ router.get("/api-keys", requireAdmin, async (_req, res) => { try { - const keys = await db - .select({ - id: apiKeys.id, - name: apiKeys.name, - userId: apiKeys.userId, - username: users.username, - tokenPrefix: apiKeys.tokenPrefix, - createdAt: apiKeys.createdAt, - expiresAt: apiKeys.expiresAt, - lastUsedAt: apiKeys.lastUsedAt, - isActive: apiKeys.isActive, - }) - .from(apiKeys) - .leftJoin(users, eq(apiKeys.userId, users.id)) - .orderBy(apiKeys.createdAt); + const keys = await createCurrentApiKeyRepository().listAllWithUsers(); return res.json({ apiKeys: keys }); } catch (err) { @@ -216,36 +194,26 @@ export function registerUserApiKeyRoutes( router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => { try { const keyId = String(req.params.keyId); + const apiKeyRepository = createCurrentApiKeyRepository(); + const userRepository = createCurrentUserRepository(); - const existing = await db - .select() - .from(apiKeys) - .where(eq(apiKeys.id, keyId)) - .limit(1); - - if (existing.length === 0) { + const deleted = await apiKeyRepository.delete(keyId); + if (!deleted) { return res.status(404).json({ error: "API key not found" }); } - await db.delete(apiKeys).where(eq(apiKeys.id, keyId)); - - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); - const actorId = (req as AuthenticatedRequest).userId; const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, actorId)) - .limit(1); + const actorRecord = actorId + ? await userRepository.findById(actorId) + : null; await logAudit({ userId: actorId, - username: actorRecord[0]?.username ?? actorId, + username: actorRecord?.username ?? actorId, action: "delete_api_key", resourceType: "api_key", resourceId: keyId, - resourceName: existing[0].name, + resourceName: deleted.name, ipAddress, userAgent, success: true, diff --git a/src/backend/database/routes/user-oidc-account-routes.ts b/src/backend/database/routes/user-oidc-account-routes.ts index 5151a1e7..5a1c42c6 100644 --- a/src/backend/database/routes/user-oidc-account-routes.ts +++ b/src/backend/database/routes/user-oidc-account-routes.ts @@ -1,10 +1,9 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; type UserOidcAccountRoutesDeps = { @@ -62,42 +61,30 @@ export function registerUserOidcAccountRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminUserId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminUserId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Admin access required" }); } - const oidcUserRecords = await db - .select() - .from(users) - .where(eq(users.id, oidcUserId)); - if (!oidcUserRecords || oidcUserRecords.length === 0) { + const oidcUser = await userRepository.findById(oidcUserId); + if (!oidcUser) { return res.status(404).json({ error: "OIDC user not found" }); } - const oidcUser = oidcUserRecords[0]; - if (!oidcUser.isOidc) { return res.status(400).json({ error: "Source user is not an OIDC user", }); } - const targetUserRecords = await db - .select() - .from(users) - .where(eq(users.username, targetUsername)); - if (!targetUserRecords || targetUserRecords.length === 0) { + const targetUser = await userRepository.findByUsername(targetUsername); + if (!targetUser) { return res .status(404) .json({ error: "Target password user not found" }); } - const targetUser = targetUserRecords[0]; - if (targetUser.isOidc || !targetUser.passwordHash) { return res.status(400).json({ error: "Target user must be a password-based account", @@ -119,21 +106,18 @@ export function registerUserOidcAccountRoutes( adminUserId, }); - await db - .update(users) - .set({ - isOidc: true, - oidcIdentifier: oidcUser.oidcIdentifier, - clientId: oidcUser.clientId, - clientSecret: oidcUser.clientSecret, - issuerUrl: oidcUser.issuerUrl, - authorizationUrl: oidcUser.authorizationUrl, - tokenUrl: oidcUser.tokenUrl, - identifierPath: oidcUser.identifierPath, - namePath: oidcUser.namePath, - scopes: oidcUser.scopes || "openid email profile", - }) - .where(eq(users.id, targetUser.id)); + await userRepository.update(targetUser.id, { + isOidc: true, + oidcIdentifier: oidcUser.oidcIdentifier, + clientId: oidcUser.clientId, + clientSecret: oidcUser.clientSecret, + issuerUrl: oidcUser.issuerUrl, + authorizationUrl: oidcUser.authorizationUrl, + tokenUrl: oidcUser.tokenUrl, + identifierPath: oidcUser.identifierPath, + namePath: oidcUser.namePath, + scopes: oidcUser.scopes || "openid email profile", + }); try { await authManager.convertToOIDCEncryption(targetUser.id); @@ -146,21 +130,18 @@ export function registerUserOidcAccountRoutes( userId: targetUser.id, }, ); - await db - .update(users) - .set({ - isOidc: false, - oidcIdentifier: null, - clientId: "", - clientSecret: "", - issuerUrl: "", - authorizationUrl: "", - tokenUrl: "", - identifierPath: "", - namePath: "", - scopes: "openid email profile", - }) - .where(eq(users.id, targetUser.id)); + await userRepository.update(targetUser.id, { + isOidc: false, + oidcIdentifier: null, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + }); return res.status(500).json({ error: @@ -178,8 +159,7 @@ export function registerUserOidcAccountRoutes( await deleteUserAndRelatedData(oidcUserId); try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist account linking to disk", @@ -265,12 +245,10 @@ export function registerUserOidcAccountRoutes( } try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminUserId)); + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(adminUserId); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + if (!adminUser?.isAdmin) { authLogger.warn("Non-admin attempted to unlink OIDC from password", { operation: "unlink_oidc_unauthorized", adminUserId, @@ -281,19 +259,13 @@ export function registerUserOidcAccountRoutes( }); } - const targetUserRecords = await db - .select() - .from(users) - .where(eq(users.id, userId)); - - if (!targetUserRecords || targetUserRecords.length === 0) { + const targetUser = await userRepository.findById(userId); + if (!targetUser) { return res.status(404).json({ error: "User not found", }); } - const targetUser = targetUserRecords[0]; - if (!targetUser.isOidc) { return res.status(400).json({ error: "User does not have OIDC authentication enabled", @@ -314,25 +286,21 @@ export function registerUserOidcAccountRoutes( adminUserId, }); - await db - .update(users) - .set({ - isOidc: false, - oidcIdentifier: null, - clientId: "", - clientSecret: "", - issuerUrl: "", - authorizationUrl: "", - tokenUrl: "", - identifierPath: "", - namePath: "", - scopes: "openid email profile", - }) - .where(eq(users.id, targetUser.id)); + await userRepository.update(targetUser.id, { + isOidc: false, + oidcIdentifier: null, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + }); try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save"); } catch (saveError) { authLogger.error( "Failed to save database after unlinking OIDC", diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index 6bb5c520..038b53ae 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -1,10 +1,12 @@ import { authLogger } from "../../utils/logger.js"; import type { SSOProviderType } from "../../../types/index.js"; -import { db } from "../db/index.js"; -import { ssoProviders } from "../db/schema.js"; -import { eq } from "drizzle-orm"; import { DataCrypto } from "../../utils/data-crypto.js"; import { Agent } from "undici"; +import { eq } from "drizzle-orm"; +import { getDb } from "../db/index.js"; +import { ssoProviders } from "../db/schema.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; +import { createCurrentSsoProviderRepository } from "../repositories/current-sso-provider-repository.js"; const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; @@ -318,13 +320,9 @@ export async function loadProviderConfig( } | null> { if (providerId != null) { try { - const rows = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.id, providerId)) - .limit(1); - if (rows.length > 0) { - const row = rows[0]; + const row = + await createCurrentSsoProviderRepository().findById(providerId); + if (row) { let parsed: Record; try { parsed = JSON.parse(row.config); @@ -374,14 +372,8 @@ export async function loadProviderConfig( // Fallback: first enabled OIDC-type provider in ssoProviders table try { - const rows = await db - .select() - .from(ssoProviders) - .where(eq(ssoProviders.enabled, true)) - .orderBy(); - const oidcRow = rows.find( - (r) => r.type === "oidc" || r.type === "github" || r.type === "google", - ); + const oidcRow = + await createCurrentSsoProviderRepository().findFirstEnabledOidcLike(); if (oidcRow) { let parsed: Record; try { @@ -406,11 +398,10 @@ export async function loadProviderConfig( // Fallback: legacy settings blob try { - const legacyRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") - .get() as { value: string } | undefined; - if (legacyRow) { - let config = JSON.parse(legacyRow.value) as Record; + const legacyValue = + await createCurrentSettingsRepository().get("oidc_config"); + if (legacyValue) { + let config = JSON.parse(legacyValue) as Record; config = decryptConfigSecret(config); return { config: config as unknown as OIDCConfig, @@ -433,7 +424,7 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{ const target = normalizeIssuer(issuer); try { - const rows = await db + const rows = await getDb() .select() .from(ssoProviders) .where(eq(ssoProviders.enabled, true)); diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts index c2dce5a2..fdbd7664 100644 --- a/src/backend/database/routes/user-password-reset-routes.ts +++ b/src/backend/database/routes/user-password-reset-routes.ts @@ -2,24 +2,18 @@ import type { Router } from "express"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; -import { eq } from "drizzle-orm"; import { AuthManager } from "../../utils/auth-manager.js"; import { authLogger } from "../../utils/logger.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; -import { db } from "../db/index.js"; -import { - users, - hosts, - sshCredentials, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - dismissedAlerts, - sshCredentialUsage, - recentActivity, - snippets, - webauthnCredentials, -} from "../db/schema.js"; +import { createCurrentCredentialRepository } from "../repositories/current-credential-repository.js"; +import { createCurrentDismissedAlertRepository } from "../repositories/current-dismissed-alert-repository.js"; +import { createCurrentFileManagerBookmarkRepository } from "../repositories/current-file-manager-bookmark-repository.js"; +import { createCurrentHostRepository } from "../repositories/current-host-repository.js"; +import { createCurrentRecentActivityRepository } from "../repositories/current-recent-activity-repository.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; +import { createCurrentSnippetRepository } from "../repositories/current-snippet-repository.js"; +import { createCurrentSshCredentialUsageRepository } from "../repositories/current-ssh-credential-usage-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; interface UserPasswordResetRoutesDeps { authManager: AuthManager; @@ -68,14 +62,10 @@ export function registerUserPasswordResetRoutes( const allowed = envVal !== undefined ? envVal.trim().toLowerCase() === "true" - : (() => { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_password_reset'", - ) - .get(); - return row ? (row as { value: string }).value === "true" : true; - })(); + : await createCurrentSettingsRepository().getBoolean( + "allow_password_reset", + true, + ); if (!allowed) { return res .status(403) @@ -95,12 +85,9 @@ export function registerUserPasswordResetRoutes( } try { - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); + const user = await createCurrentUserRepository().findByUsername(username); - if (!user || user.length === 0) { + if (!user) { authLogger.warn( `Password reset attempted for non-existent user: ${username}`, ); @@ -110,7 +97,7 @@ export function registerUserPasswordResetRoutes( }); } - if (user[0].isOidc) { + if (user.isOidc) { return res.json({ message: "If the user exists, a password reset code has been generated. Check docker logs for the code.", @@ -120,15 +107,13 @@ export function registerUserPasswordResetRoutes( const resetCode = crypto.randomInt(100000, 1000000).toString(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run( - `reset_code_${username}`, - JSON.stringify({ - code: resetCode, - expiresAt: expiresAt.toISOString(), - }), - ); + await createCurrentSettingsRepository().set( + `reset_code_${username}`, + JSON.stringify({ + code: resetCode, + expiresAt: expiresAt.toISOString(), + }), + ); authLogger.info( `Password reset code generated for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`, @@ -200,10 +185,10 @@ export function registerUserPasswordResetRoutes( loginRateLimiter.recordResetCodeAttempt(username); - const resetDataRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`reset_code_${username}`); - if (!resetDataRow) { + const resetDataValue = await createCurrentSettingsRepository().get( + `reset_code_${username}`, + ); + if (!resetDataValue) { authLogger.warn("Reset code verification failed - no code found", { operation: "reset_code_verify_failed", username, @@ -217,16 +202,14 @@ export function registerUserPasswordResetRoutes( }); } - const resetData = JSON.parse( - (resetDataRow as Record).value as string, - ); + const resetData = JSON.parse(resetDataValue); const now = new Date(); const expiresAt = new Date(resetData.expiresAt); if (now > expiresAt) { - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`reset_code_${username}`); + await createCurrentSettingsRepository().delete( + `reset_code_${username}`, + ); authLogger.warn("Reset code verification failed - code expired", { operation: "reset_code_verify_failed", username, @@ -259,15 +242,13 @@ export function registerUserPasswordResetRoutes( const tempToken = nanoid(); const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run( - `temp_reset_token_${username}`, - JSON.stringify({ - token: tempToken, - expiresAt: tempTokenExpiry.toISOString(), - }), - ); + await createCurrentSettingsRepository().set( + `temp_reset_token_${username}`, + JSON.stringify({ + token: tempToken, + expiresAt: tempTokenExpiry.toISOString(), + }), + ); res.json({ message: "Reset code verified", tempToken }); } catch (err) { @@ -321,23 +302,21 @@ export function registerUserPasswordResetRoutes( } try { - const tempTokenRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`temp_reset_token_${username}`); - if (!tempTokenRow) { + const tempTokenValue = await createCurrentSettingsRepository().get( + `temp_reset_token_${username}`, + ); + if (!tempTokenValue) { return res.status(400).json({ error: "No temporary token found" }); } - const tempTokenData = JSON.parse( - (tempTokenRow as Record).value as string, - ); + const tempTokenData = JSON.parse(tempTokenValue); const now = new Date(); const expiresAt = new Date(tempTokenData.expiresAt); if (now > expiresAt) { - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`temp_reset_token_${username}`); + await createCurrentSettingsRepository().delete( + `temp_reset_token_${username}`, + ); return res.status(400).json({ error: "Temporary token has expired" }); } @@ -345,14 +324,11 @@ export function registerUserPasswordResetRoutes( return res.status(400).json({ error: "Invalid temporary token" }); } - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (!user || user.length === 0) { + const user = await createCurrentUserRepository().findByUsername(username); + if (!user) { return res.status(404).json({ error: "User not found" }); } - const userId = user[0].id; + const userId = user.id; const password_hash = await bcrypt.hash(newPassword, 10); @@ -384,10 +360,9 @@ export function registerUserPasswordResetRoutes( ); } - await db - .update(users) - .set({ passwordHash: password_hash }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + passwordHash: password_hash, + }); authManager.logoutUser(userId); authLogger.success( `Password reset (data preserved) for user: ${username}`, @@ -412,50 +387,31 @@ export function registerUserPasswordResetRoutes( }); } } else { - await db - .update(users) - .set({ passwordHash: password_hash }) - .where(eq(users.username, username)); + await createCurrentUserRepository().update(userId, { + passwordHash: password_hash, + }); try { - await db - .delete(sshCredentialUsage) - .where(eq(sshCredentialUsage.userId, userId)); - await db - .delete(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)); - await db - .delete(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)); - await db - .delete(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)); - await db - .delete(recentActivity) - .where(eq(recentActivity.userId, userId)); - await db - .delete(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); - await db.delete(snippets).where(eq(snippets.userId, userId)); - await db.delete(hosts).where(eq(hosts.userId, userId)); - await db - .delete(sshCredentials) - .where(eq(sshCredentials.userId, userId)); - await db - .delete(webauthnCredentials) - .where(eq(webauthnCredentials.userId, userId)); + await createCurrentSshCredentialUsageRepository().deleteByUserId( + userId, + ); + await createCurrentFileManagerBookmarkRepository().deleteByUserId( + userId, + ); + await createCurrentRecentActivityRepository().deleteByUserId(userId); + await createCurrentDismissedAlertRepository().deleteByUserId(userId); + await createCurrentSnippetRepository().deleteByUserId(userId); + await createCurrentHostRepository().deleteByUserId(userId); + await createCurrentCredentialRepository().deleteByUserId(userId); await authManager.registerUser(userId, newPassword); authManager.logoutUser(userId); - await db - .update(users) - .set({ - totpEnabled: false, - totpSecret: null, - totpBackupCodes: null, - }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); authLogger.warn( `Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`, @@ -483,12 +439,9 @@ export function registerUserPasswordResetRoutes( authLogger.success(`Password successfully reset for user: ${username}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`reset_code_${username}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`temp_reset_token_${username}`); + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.delete(`reset_code_${username}`); + await settingsRepository.delete(`temp_reset_token_${username}`); res.json({ message: "Password has been successfully reset" }); } catch (err) { diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index 6dbc8d5a..36a2a14a 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -1,17 +1,19 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { userPreferences } from "../db/schema.js"; -import { eq } from "drizzle-orm"; import type { Request, Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { createCurrentUserPreferenceRepository } from "../repositories/current-user-preference-repository.js"; +import type { + UserPreferenceRecord, + UserPreferenceUpdate, +} from "../repositories/user-preference-repository.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); -const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ +const pickPreferences = (row?: UserPreferenceRecord | null) => ({ reopenTabsOnLogin: row?.reopenTabsOnLogin ?? false, theme: row?.theme ?? null, fontSize: row?.fontSize ?? null, @@ -105,16 +107,13 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ * type: string * nullable: true */ -router.get("/", authenticateJWT, (req: Request, res: Response) => { +router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = db - .select() - .from(userPreferences) - .where(eq(userPreferences.userId, userId)) - .all(); + const preferences = + await createCurrentUserPreferenceRepository().findByUserId(userId); - return res.json(pickPreferences(rows[0])); + return res.json(pickPreferences(preferences)); } catch (e) { databaseLogger.error("Failed to get user preferences", e, { operation: "get_user_preferences", @@ -180,7 +179,7 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => { * 200: * description: Preferences updated successfully. */ -router.put("/", authenticateJWT, (req: Request, res: Response) => { +router.put("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { reopenTabsOnLogin, @@ -224,7 +223,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { statusColorScheme?: string | null; }; - const updates: Partial = { + const updates: UserPreferenceUpdate = { updatedAt: new Date().toISOString(), }; @@ -301,25 +300,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { } try { - const existing = db - .select() - .from(userPreferences) - .where(eq(userPreferences.userId, userId)) - .all(); - - if (existing.length === 0) { - db.insert(userPreferences) - .values({ - userId, - ...updates, - }) - .run(); - } else { - db.update(userPreferences) - .set(updates) - .where(eq(userPreferences.userId, userId)) - .run(); - } + await createCurrentUserPreferenceRepository().upsert(userId, updates); return res.json({ success: true, ...updates }); } catch (e) { diff --git a/src/backend/database/routes/user-session-routes.ts b/src/backend/database/routes/user-session-routes.ts index 7e9ee6c0..2b9f9ab0 100644 --- a/src/backend/database/routes/user-session-routes.ts +++ b/src/backend/database/routes/user-session-routes.ts @@ -1,11 +1,10 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; import { AuthManager } from "../../utils/auth-manager.js"; import { authLogger } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { sessions, users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { createCurrentSessionRepository } from "../repositories/current-session-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; type UserSessionRoutesDeps = { authenticateJWT: RequestHandler; @@ -38,39 +37,38 @@ export function registerUserSessionRoutes( const currentSessionId = authReq.sessionId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRepository = createCurrentUserRepository(); + const userRecord = await userRepository.findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; let sessionList; if (userRecord.isAdmin) { sessionList = await authManager.getAllSessions(); - - const enrichedSessions = await Promise.all( - sessionList.map(async (session) => { - const sessionUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, session.userId)) - .limit(1); - - return { - id: session.id, - userId: session.userId, - username: sessionUser[0]?.username || "Unknown", - deviceType: session.deviceType, - deviceInfo: session.deviceInfo, - createdAt: session.createdAt, - expiresAt: session.expiresAt, - lastActiveAt: session.lastActiveAt, - isRevoked: session.isRevoked, - isCurrentSession: session.id === currentSessionId, - }; - }), + const sessionUsers = await userRepository.listByIds( + sessionList.map((session) => session.userId), ); + const usernamesById = new Map( + sessionUsers.map((sessionUser) => [ + sessionUser.id, + sessionUser.username, + ]), + ); + + const enrichedSessions = sessionList.map((session) => ({ + id: session.id, + userId: session.userId, + username: usernamesById.get(session.userId) || "Unknown", + deviceType: session.deviceType, + deviceInfo: session.deviceInfo, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + lastActiveAt: session.lastActiveAt, + isRevoked: session.isRevoked, + isCurrentSession: session.id === currentSessionId, + })); return res.json({ sessions: enrichedSessions }); } else { @@ -133,25 +131,19 @@ export function registerUserSessionRoutes( } try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRepository = createCurrentUserRepository(); + const userRecord = await userRepository.findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; + const session = + await createCurrentSessionRepository().findById(sessionId); - const sessionRecords = await db - .select() - .from(sessions) - .where(eq(sessions.id, sessionId)) - .limit(1); - - if (sessionRecords.length === 0) { + if (!session) { return res.status(404).json({ error: "Session not found" }); } - const session = sessionRecords[0]; - if (!userRecord.isAdmin && session.userId !== userId) { return res .status(403) @@ -169,14 +161,9 @@ export function registerUserSessionRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const actorUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorUser[0]?.username ?? userId, + username: userRecord.username ?? userId, action: "revoke_session", resourceType: "session", resourceId: sessionId, @@ -230,13 +217,12 @@ export function registerUserSessionRoutes( const { targetUserId, exceptCurrent } = req.body; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRepository = createCurrentUserRepository(); + const userRecord = await userRepository.findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - let revokeUserId = userId; if (targetUserId && userRecord.isAdmin) { revokeUserId = targetUserId; @@ -265,23 +251,17 @@ export function registerUserSessionRoutes( }); const { ipAddress, userAgent } = getRequestMeta(req); - const actorUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - const targetUserRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, revokeUserId)) - .limit(1); + const targetUserRecord = + revokeUserId === userId + ? userRecord + : await userRepository.findById(revokeUserId); await logAudit({ userId, - username: actorUser[0]?.username ?? userId, + username: userRecord.username ?? userId, action: "revoke_all_sessions", resourceType: "session", resourceId: revokeUserId, - resourceName: targetUserRecord[0]?.username, + resourceName: targetUserRecord?.username, details: JSON.stringify({ revokedCount, exceptCurrent }), ipAddress, userAgent, diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 047eda3b..8c7f4d86 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -1,19 +1,19 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; -import { eq } from "drizzle-orm"; import { restartGuacServer } from "../../guacamole/guacamole-server.js"; import { authLogger, getGlobalLogLevel, setGlobalLogLevel, } from "../../utils/logger.js"; -import { db } from "../db/index.js"; -import { users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; -import { - formatGuacdOptions, - resolveGuacdOptions, -} from "../../utils/guacd-config.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; +import type { UserRecord } from "../repositories/user-repository.js"; + +function getDefaultGuacUrl(): string { + return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`; +} export type HostDefaults = { useSocks5?: boolean; @@ -33,6 +33,14 @@ export type HostDefaults = { enableCommandHistory?: boolean; }; +async function getAdminActor( + userId: string | undefined, +): Promise { + if (!userId) return null; + const user = await createCurrentUserRepository().findById(userId); + return user?.isAdmin ? user : null; +} + export function registerUserSettingsRoutes( router: Router, authenticateJWT: RequestHandler, @@ -62,15 +70,12 @@ export function registerUserSettingsRoutes( */ router.get("/guacamole-settings", authenticateJWT, async (_req, res) => { try { - const enabledRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") - .get() as { value: string } | undefined; - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; + const settings = createCurrentSettingsRepository(); + const enabled = await settings.getBoolean("guac_enabled", true); + const url = await settings.get("guac_url"); res.json({ - enabled: enabledRow ? enabledRow.value !== "false" : true, - url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)), + enabled, + url: url ?? getDefaultGuacUrl(), }); } catch (err) { authLogger.error("Failed to get guacamole settings", err); @@ -108,24 +113,17 @@ export function registerUserSettingsRoutes( router.patch("/guacamole-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled, url } = req.body; + const settings = createCurrentSettingsRepository(); if (typeof enabled === "boolean") { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)", - ) - .run(enabled ? "true" : "false"); + await settings.set("guac_enabled", enabled ? "true" : "false"); } if (typeof url === "string") { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)", - ) - .run(url); + await settings.set("guac_url", url); try { await restartGuacServer(); } catch (err) { @@ -135,22 +133,13 @@ export function registerUserSettingsRoutes( ); } } - const enabledRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") - .get() as { value: string } | undefined; - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; + const currentEnabled = await settings.getBoolean("guac_enabled", true); + const currentUrl = await settings.get("guac_url"); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_guacamole_settings", resourceType: "setting", details: JSON.stringify({ enabled, url }), @@ -160,8 +149,8 @@ export function registerUserSettingsRoutes( }); res.json({ - enabled: enabledRow ? enabledRow.value !== "false" : true, - url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)), + enabled: currentEnabled, + url: currentUrl ?? getDefaultGuacUrl(), }); } catch (err) { authLogger.error("Failed to update guacamole settings", err); @@ -183,11 +172,9 @@ export function registerUserSettingsRoutes( */ router.get("/log-level", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'log_level'") - .get() as { value: string } | undefined; + const level = await createCurrentSettingsRepository().get("log_level"); res.json({ - level: row ? row.value : getGlobalLogLevel(), + level: level ?? getGlobalLogLevel(), }); } catch (err) { authLogger.error("Failed to get log level", err); @@ -214,8 +201,8 @@ export function registerUserSettingsRoutes( router.patch("/log-level", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { level } = req.body; @@ -225,22 +212,13 @@ export function registerUserSettingsRoutes( .status(400) .json({ error: "level must be one of: debug, info, warn, error" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('log_level', ?)", - ) - .run(level); + await createCurrentSettingsRepository().set("log_level", level); setGlobalLogLevel(level); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_log_level", resourceType: "setting", details: JSON.stringify({ level }), @@ -270,13 +248,11 @@ export function registerUserSettingsRoutes( */ router.get("/session-timeout", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'session_timeout_hours'", - ) - .get() as { value: string } | undefined; + const value = await createCurrentSettingsRepository().get( + "session_timeout_hours", + ); res.json({ - timeoutHours: row ? parseInt(row.value, 10) : 24, + timeoutHours: value ? parseInt(value, 10) : 24, }); } catch (err) { authLogger.error("Failed to get session timeout", err); @@ -303,8 +279,8 @@ export function registerUserSettingsRoutes( router.patch("/session-timeout", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { timeoutHours } = req.body; @@ -317,21 +293,15 @@ export function registerUserSettingsRoutes( .status(400) .json({ error: "timeoutHours must be between 1 and 720" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)", - ) - .run(String(timeoutHours)); + await createCurrentSettingsRepository().set( + "session_timeout_hours", + String(timeoutHours), + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_session_timeout", resourceType: "setting", details: JSON.stringify({ timeoutHours }), @@ -371,10 +341,9 @@ export function registerUserSettingsRoutes( */ router.get("/tailscale-settings", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'") - .get() as { value: string } | undefined; - const apiKey = row?.value ?? ""; + const apiKey = + (await createCurrentSettingsRepository().get("tailscale_api_key")) ?? + ""; res.json({ apiKey: apiKey ? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}` @@ -415,29 +384,20 @@ export function registerUserSettingsRoutes( router.patch("/tailscale-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { apiKey } = req.body; if (typeof apiKey !== "string") { return res.status(400).json({ error: "apiKey must be a string" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('tailscale_api_key', ?)", - ) - .run(apiKey); + await createCurrentSettingsRepository().set("tailscale_api_key", apiKey); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_tailscale_settings", resourceType: "setting", details: JSON.stringify({ hasApiKey: !!apiKey }), @@ -474,12 +434,12 @@ export function registerUserSettingsRoutes( */ router.get("/command-history-enabled", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'command_history_enabled'", - ) - .get() as { value: string } | undefined; - res.json({ enabled: row ? row.value !== "false" : true }); + res.json({ + enabled: await createCurrentSettingsRepository().getBoolean( + "command_history_enabled", + true, + ), + }); } catch (err) { authLogger.error("Failed to get command history enabled setting", err); res @@ -519,29 +479,23 @@ export function registerUserSettingsRoutes( async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "enabled must be a boolean" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('command_history_enabled', ?)", - ) - .run(enabled ? "true" : "false"); + await createCurrentSettingsRepository().set( + "command_history_enabled", + enabled ? "true" : "false", + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_command_history_enabled", resourceType: "setting", details: JSON.stringify({ enabled }), @@ -579,10 +533,9 @@ export function registerUserSettingsRoutes( */ router.get("/host-defaults", authenticateJWT, async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'host_defaults'") - .get() as { value: string } | undefined; - const defaults: HostDefaults = row ? JSON.parse(row.value) : {}; + const value = + await createCurrentSettingsRepository().get("host_defaults"); + const defaults: HostDefaults = value ? JSON.parse(value) : {}; res.json(defaults); } catch (err) { authLogger.error("Failed to get host defaults", err); @@ -615,26 +568,20 @@ export function registerUserSettingsRoutes( router.patch("/host-defaults", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const actor = await getAdminActor(userId); + if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const defaults: HostDefaults = req.body; - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('host_defaults', ?)", - ) - .run(JSON.stringify(defaults)); + await createCurrentSettingsRepository().set( + "host_defaults", + JSON.stringify(defaults), + ); const { ipAddress, userAgent } = getRequestMeta(req); - const actorRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: actorRecord[0]?.username ?? userId, + username: actor.username ?? userId, action: "update_host_defaults", resourceType: "setting", details: JSON.stringify(defaults), diff --git a/src/backend/database/routes/user-totp-routes.test.ts b/src/backend/database/routes/user-totp-routes.test.ts index 39954db7..617a2272 100644 --- a/src/backend/database/routes/user-totp-routes.test.ts +++ b/src/backend/database/routes/user-totp-routes.test.ts @@ -2,16 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import bcrypt from "bcryptjs"; import speakeasy from "speakeasy"; -// The route module imports the db barrel (which has filesystem/crypto side -// effects on import) plus the logger; stub both so importing stays inert. -const updateWhere = vi.fn().mockResolvedValue(undefined); -const updateSet = vi.fn(() => ({ where: updateWhere })); -const dbUpdate = vi.fn(() => ({ set: updateSet })); +// The route module imports repository factories and the logger; stub both so +// importing stays inert. +const userRepositoryUpdate = vi.fn().mockResolvedValue(null); -vi.mock("../db/index.js", () => ({ - db: { - update: dbUpdate, - }, +vi.mock("../repositories/current-user-repository.js", () => ({ + createCurrentUserRepository: () => ({ + update: userRepositoryUpdate, + }), })); vi.mock("../../utils/logger.js", () => ({ @@ -59,14 +57,14 @@ describe("verifyTotpReauth", () => { it("accepts a valid backup code and consumes it", async () => { const result = await verifyTotpReauth(makeUser(), "BACKUP01"); expect(result).toBe(true); - expect(updateSet).toHaveBeenCalledWith({ + expect(userRepositoryUpdate).toHaveBeenCalledWith("user-1", { totpBackupCodes: JSON.stringify(["BACKUP02"]), }); }); it("rejects a wrong password / invalid code", async () => { expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false); - expect(updateSet).not.toHaveBeenCalled(); + expect(userRepositoryUpdate).not.toHaveBeenCalled(); }); it("ignores the password path for OIDC users but still accepts TOTP", async () => { diff --git a/src/backend/database/routes/user-totp-routes.ts b/src/backend/database/routes/user-totp-routes.ts index 51d24ee8..bae0dd2e 100644 --- a/src/backend/database/routes/user-totp-routes.ts +++ b/src/backend/database/routes/user-totp-routes.ts @@ -1,10 +1,10 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Router } from "express"; -import { and, eq, ne } from "drizzle-orm"; import bcrypt from "bcryptjs"; import QRCode from "qrcode"; import speakeasy from "speakeasy"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { FieldCrypto } from "../../utils/field-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { authLogger } from "../../utils/logger.js"; @@ -13,8 +13,11 @@ import { generateDeviceFingerprint, parseUserAgent, } from "../../utils/user-agent-parser.js"; -import { db } from "../db/index.js"; -import { sessions, trustedDevices, users } from "../db/schema.js"; +import { createCurrentSessionRepository } from "../repositories/current-session-repository.js"; +import { createCurrentSettingsRepository } from "../repositories/current-settings-repository.js"; +import { createCurrentTrustedDeviceRepository } from "../repositories/current-trusted-device-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; +import type { UserRecord } from "../repositories/user-repository.js"; type NativeAppRequestChecker = (req: Request) => boolean; @@ -24,10 +27,8 @@ interface UserTotpRoutesDeps { isNativeAppRequest: NativeAppRequestChecker; } -type TotpUserRecord = typeof users.$inferSelect; - export async function verifyTotpReauth( - userRecord: TotpUserRecord, + userRecord: UserRecord, credential: string, userDataKey?: Buffer | null, ): Promise { @@ -83,10 +84,9 @@ export async function verifyTotpReauth( "totpBackupCodes", ) : updatedJson; - await db - .update(users) - .set({ totpBackupCodes: storedValue }) - .where(eq(users.id, userRecord.id)); + await createCurrentUserRepository().update(userRecord.id, { + totpBackupCodes: storedValue, + }); return true; } } @@ -120,13 +120,11 @@ export function registerUserTotpRoutes( const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } @@ -136,10 +134,9 @@ export function registerUserTotpRoutes( length: 32, }); - await db - .update(users) - .set({ totpSecret: secret.base32 }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpSecret: secret.base32, + }); const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ""); @@ -192,14 +189,11 @@ export function registerUserTotpRoutes( } try { - const passwordLoginRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'allow_password_login'", - ) - .get() as { value: string } | undefined; - const passwordLoginAllowed = passwordLoginRow - ? passwordLoginRow.value === "true" - : true; + const passwordLoginAllowed = + await createCurrentSettingsRepository().getBoolean( + "allow_password_login", + true, + ); if (!passwordLoginAllowed) { return res.status(409).json({ error: @@ -207,13 +201,11 @@ export function registerUserTotpRoutes( }); } - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } @@ -257,26 +249,19 @@ export function registerUserTotpRoutes( ) : backupCodesJson; - await db - .update(users) - .set({ - totpEnabled: true, - totpBackupCodes: storedBackupCodes, - }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpEnabled: true, + totpBackupCodes: storedBackupCodes, + }); - await db - .delete(sessions) - .where( - sessionId - ? and(eq(sessions.userId, userId), ne(sessions.id, sessionId)) - : eq(sessions.userId, userId), - ); - await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId)); + await createCurrentSessionRepository().revokeAllForUser( + userId, + sessionId, + ); + await createCurrentTrustedDeviceRepository().deleteByUserId(userId); try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("totp_enable_explicit_save"); } catch (saveError) { authLogger.error( "Failed to persist TOTP enablement to disk", @@ -333,13 +318,11 @@ export function registerUserTotpRoutes( const userId = (req as AuthenticatedRequest).userId; const { password, totp_code } = req.body; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (!totp_code || (!userRecord.isOidc && !password)) { return res.status(400).json({ error: userRecord.isOidc @@ -372,14 +355,11 @@ export function registerUserTotpRoutes( .json({ error: "Incorrect password or invalid TOTP code" }); } - await db - .update(users) - .set({ - totpEnabled: false, - totpSecret: null, - totpBackupCodes: null, - }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); authLogger.info("Two-factor authentication disabled", { operation: "totp_disable", userId, @@ -427,13 +407,11 @@ export function registerUserTotpRoutes( const userId = (req as AuthenticatedRequest).userId; const { password, totp_code } = req.body; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (!totp_code || (!userRecord.isOidc && !password)) { return res.status(400).json({ error: userRecord.isOidc @@ -480,10 +458,9 @@ export function registerUserTotpRoutes( ) : backupCodesJson; - await db - .update(users) - .set({ totpBackupCodes: storedBackupCodes }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + totpBackupCodes: storedBackupCodes, + }); res.json({ backup_codes: backupCodes }); } catch (err) { @@ -538,16 +515,13 @@ export function registerUserTotpRoutes( return res.status(401).json({ error: "Invalid temporary token" }); } - const user = await db - .select() - .from(users) - .where(eq(users.id, decoded.userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById( + decoded.userId, + ); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id); if (lockStatus.locked) { authLogger.warn("TOTP verification blocked due to rate limiting", { @@ -586,14 +560,11 @@ export function registerUserTotpRoutes( ); if (!totpSecret) { - await db - .update(users) - .set({ - totpEnabled: false, - totpSecret: null, - totpBackupCodes: null, - }) - .where(eq(users.id, userRecord.id)); + await createCurrentUserRepository().update(userRecord.id, { + totpEnabled: false, + totpSecret: null, + totpBackupCodes: null, + }); return res.status(400).json({ error: @@ -641,10 +612,9 @@ export function registerUserTotpRoutes( } backupCodes.splice(backupIndex, 1); - await db - .update(users) - .set({ totpBackupCodes: JSON.stringify(backupCodes) }) - .where(eq(users.id, userRecord.id)); + await createCurrentUserRepository().update(userRecord.id, { + totpBackupCodes: JSON.stringify(backupCodes), + }); } loginRateLimiter.resetTOTPAttempts(userRecord.id); @@ -689,14 +659,10 @@ export function registerUserTotpRoutes( ...(isNativeAppRequest(req) ? { token } : {}), }; - const timeoutRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'session_timeout_hours'", - ) - .get() as { value: string } | undefined; - const timeoutHours = timeoutRow - ? parseInt(timeoutRow.value, 10) || 24 - : 24; + const timeoutValue = await createCurrentSettingsRepository().get( + "session_timeout_hours", + ); + const timeoutHours = timeoutValue ? parseInt(timeoutValue, 10) || 24 : 24; const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : timeoutHours * 60 * 60 * 1000; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c9d5eba8..ae290897 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1,23 +1,18 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; -import { db } from "../db/index.js"; -import { users, settings, roles, userRoles } from "../db/schema.js"; -import { eq, and } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { parseUserAgent, generateDeviceFingerprint, } from "../../utils/user-agent-parser.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; -import { - getRequestBasePath, - getRequestBaseUrlWithForceHTTPS, -} from "../../utils/request-origin.js"; +import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js"; import { getDesktopOidcCallbackUrl, isOidcTokenCallback, @@ -42,10 +37,16 @@ import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js"; import { registerUserDataAccessRoutes } from "./user-data-access-routes.js"; -import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js"; import { registerSSOProviderRoutes } from "./sso-provider-routes.js"; import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentSettingsRepository, + getCurrentSettingValue, +} from "../repositories/current-settings-repository.js"; +import { createCurrentRoleRepository } from "../repositories/current-role-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; +import type { UserRecord } from "../repositories/user-repository.js"; const authManager = AuthManager.getInstance(); @@ -78,10 +79,8 @@ function isRegistrationAllowed(): boolean { const envVal = process.env.ALLOW_REGISTRATION; if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") - .get() as { value: string } | undefined; - return row ? row.value === "true" : true; + const value = getCurrentSettingValue("allow_registration"); + return value ? value === "true" : true; } catch { return true; } @@ -91,10 +90,8 @@ function isPasswordLoginAllowed(): boolean { const envVal = process.env.ALLOW_PASSWORD_LOGIN; if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") - .get() as { value: string } | undefined; - return row ? row.value === "true" : true; + const value = getCurrentSettingValue("allow_password_login"); + return value ? value === "true" : true; } catch { return true; } @@ -104,10 +101,8 @@ function isPasswordResetAllowed(): boolean { const envVal = process.env.ALLOW_PASSWORD_RESET; if (envVal !== undefined) return envVal.trim().toLowerCase() === "true"; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") - .get() as { value: string } | undefined; - return row ? row.value === "true" : true; + const value = getCurrentSettingValue("allow_password_reset"); + return value ? value === "true" : true; } catch { return true; } @@ -120,6 +115,24 @@ function isNativeAppRequest(req: Request): boolean { ); } +async function findCurrentUser(userId: string): Promise { + return createCurrentUserRepository().findById(userId); +} + +async function requireCurrentAdmin(userId: string): Promise { + const user = await findCurrentUser(userId); + return user?.isAdmin ? user : null; +} + +async function deleteOIDCStateSettings(state: string): Promise { + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.delete(`oidc_state_${state}`); + await settingsRepository.delete(`oidc_backend_callback_${state}`); + await settingsRepository.delete(`oidc_frontend_origin_${state}`); + await settingsRepository.delete(`oidc_remember_me_${state}`); + await settingsRepository.delete(`oidc_provider_${state}`); +} + const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); @@ -182,11 +195,9 @@ router.post("/create", async (req, res) => { } try { - const existing = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (existing && existing.length > 0) { + const userRepository = createCurrentUserRepository(); + const existing = await userRepository.findByUsername(username); + if (existing) { authLogger.warn("Registration failed - username exists", { operation: "user_register_failed", username, @@ -198,51 +209,35 @@ router.post("/create", async (req, res) => { const password_hash = await bcrypt.hash(password, 10); const id = nanoid(); - const isFirstUser = db.$client.transaction(() => { - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const first = (countResult?.count || 0) === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .run( - id, - username, - password_hash, - first ? 1 : 0, - 0, - "", - "", - "", - "", - "", - "", - "", - "openid email profile", - null, - 0, - null, - ); - return first; - })(); + const { isFirstUser } = await userRepository.createFirstLocalUser({ + id, + username, + passwordHash: password_hash, + isOidc: false, + clientId: "", + clientSecret: "", + issuerUrl: "", + authorizationUrl: "", + tokenUrl: "", + identifierPath: "", + namePath: "", + scopes: "openid email profile", + totpSecret: null, + totpEnabled: false, + totpBackupCodes: null, + }); try { const defaultRoleName = isFirstUser ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - - if (defaultRole.length > 0) { - await db.insert(userRoles).values({ + const assigned = await createCurrentRoleRepository().assignRoleNameToUser( + { userId: id, - roleId: defaultRole[0].id, + roleName: defaultRoleName, grantedBy: id, - }); - } else { + }, + ); + + if (!assigned) { authLogger.warn("Default role not found during user registration", { operation: "assign_default_role", userId: id, @@ -259,7 +254,7 @@ router.post("/create", async (req, res) => { try { await authManager.registerUser(id, password); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, id)); + await userRepository.delete(id); authLogger.error( "Failed to setup user encryption, user creation rolled back", encryptionError, @@ -274,8 +269,7 @@ router.post("/create", async (req, res) => { } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("user_create_explicit_save"); } catch (saveError) { authLogger.error("Failed to persist user to disk", saveError, { operation: "user_create_save_failed", @@ -333,8 +327,8 @@ router.post("/create", async (req, res) => { router.post("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } @@ -388,10 +382,10 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { .json({ error: "All OIDC configuration fields are required" }); } + const settingsRepository = createCurrentSettingsRepository(); + if (isDisableRequest) { - db.$client - .prepare("DELETE FROM settings WHERE key = 'oidc_config'") - .run(); + await settingsRepository.delete("oidc_config"); authLogger.info("OIDC configuration disabled", { operation: "oidc_disable", userId, @@ -452,11 +446,10 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { }; } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)", - ) - .run(JSON.stringify(encryptedConfig)); + await settingsRepository.set( + "oidc_config", + JSON.stringify(encryptedConfig), + ); authLogger.info("OIDC configuration updated", { operation: "oidc_update", userId, @@ -489,12 +482,12 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { router.delete("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } - db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); + await createCurrentSettingsRepository().delete("oidc_config"); authLogger.success("OIDC configuration disabled", { operation: "oidc_disable", userId, @@ -556,15 +549,13 @@ router.get("/oidc-config", async (_req, res) => { router.get("/oidc-config/admin", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") - .get(); - if (!row) { + const value = await createCurrentSettingsRepository().get("oidc_config"); + if (!value) { const envConfig = getOIDCConfigFromEnv(); return res.json(envConfig); } - let config = JSON.parse((row as Record).value as string); + let config = JSON.parse(value); if (config.client_secret?.startsWith("encrypted:")) { try { @@ -639,8 +630,9 @@ router.get("/oidc/authorize", async (req, res) => { appCallbackUrl, providerId: providerIdStr, } = req.query; - const publicBaseUrl = getRequestBaseUrlWithForceHTTPS(req); - const backendCallbackUri = `${publicBaseUrl}/users/oidc/callback`; + const origin = getRequestOriginWithForceHTTPS(req); + const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, ""); + const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`; const resolvedProviderId = providerIdStr ? parseInt(providerIdStr as string, 10) @@ -675,34 +667,31 @@ router.get("/oidc/authorize", async (req, res) => { frontendOrigin = callbackUrl.toString(); } else if (referer) { const refererUrl = new URL(referer); - frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}${getRequestBasePath(req)}`; + frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; } else { - frontendOrigin = publicBaseUrl; + frontendOrigin = origin; } - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_state_${state}`, nonce); - - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_backend_callback_${state}`, backendCallbackUri); - - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_frontend_origin_${state}`, frontendOrigin); - - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run( - `oidc_remember_me_${state}`, - rememberMe === "true" ? "true" : "false", - ); + const settingsRepository = createCurrentSettingsRepository(); + await settingsRepository.set(`oidc_state_${state}`, nonce); + await settingsRepository.set( + `oidc_backend_callback_${state}`, + backendCallbackUri, + ); + await settingsRepository.set( + `oidc_frontend_origin_${state}`, + frontendOrigin, + ); + await settingsRepository.set( + `oidc_remember_me_${state}`, + rememberMe === "true" ? "true" : "false", + ); if (providerDbId != null) { - db.$client - .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") - .run(`oidc_provider_${state}`, String(providerDbId)); + await settingsRepository.set( + `oidc_provider_${state}`, + String(providerDbId), + ); } const authUrl = new URL(config.authorization_url); @@ -741,43 +730,38 @@ router.get("/oidc/callback", async (req, res) => { return res.status(400).json({ error: "Code and state are required" }); } - const storedBackendCallbackRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_backend_callback_${state}`); - const storedFrontendOriginRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_frontend_origin_${state}`); - const storedRememberMeRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_remember_me_${state}`); + const settingsRepository = createCurrentSettingsRepository(); + const storedBackendCallback = await settingsRepository.get( + `oidc_backend_callback_${state}`, + ); + const storedFrontendOrigin = await settingsRepository.get( + `oidc_frontend_origin_${state}`, + ); + const storedRememberMeValue = await settingsRepository.get( + `oidc_remember_me_${state}`, + ); - if (!storedBackendCallbackRow || !storedFrontendOriginRow) { + if (!storedBackendCallback || !storedFrontendOrigin) { return res .status(400) .json({ error: "Invalid state parameter - redirect URIs not found" }); } - const backendCallbackUri = ( - storedBackendCallbackRow as Record - ).value as string; - const frontendOrigin = (storedFrontendOriginRow as Record) - .value as string; - const storedRememberMe = - (storedRememberMeRow as Record | null)?.value === "true"; + const backendCallbackUri = storedBackendCallback; + const frontendOrigin = storedFrontendOrigin; + const storedRememberMe = storedRememberMeValue === "true"; try { - const storedNonce = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_state_${state}`); + const storedNonce = await settingsRepository.get(`oidc_state_${state}`); if (!storedNonce) { return res.status(400).json({ error: "Invalid state parameter" }); } - const storedProviderIdRow = db.$client - .prepare("SELECT value FROM settings WHERE key = ?") - .get(`oidc_provider_${state}`) as { value: string } | null; - const callbackProviderId = storedProviderIdRow - ? parseInt(storedProviderIdRow.value, 10) + const storedProviderId = await settingsRepository.get( + `oidc_provider_${state}`, + ); + const callbackProviderId = storedProviderId + ? parseInt(storedProviderId, 10) : null; const providerResult = await loadProviderConfig( @@ -792,12 +776,7 @@ router.get("/oidc/callback", async (req, res) => { providerDbId: callbackProviderDbId, } = providerResult; - // Clean up provider state key - if (storedProviderIdRow) { - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_provider_${state}`); - } + await settingsRepository.delete(`oidc_provider_${state}`); const caCert = config.ca_cert; const fetchOptions = buildFetchOptions(caCert); @@ -836,18 +815,7 @@ router.get("/oidc/callback", async (req, res) => { string, unknown >; - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_state_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_backend_callback_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_frontend_origin_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_remember_me_${state}`); + await deleteOIDCStateSettings(state); const ghUserInfoResponse = await fetch("https://api.github.com/user", { headers: { @@ -889,16 +857,12 @@ router.get("/oidc/callback", async (req, res) => { ghUserInfo.login || ghIdentifier) as string; const deviceInfo = parseUserAgent(req); + const userRepository = createCurrentUserRepository(); - let ghUser = await db - .select() - .from(users) - .where(eq(users.oidcIdentifier, ghIdentifier)); - if (!ghUser || ghUser.length === 0) { - const preCheckCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const isFirstUser = (preCheckCount?.count || 0) === 0; + let ghUserRecord = + await userRepository.findByOidcIdentifier(ghIdentifier); + if (!ghUserRecord) { + const isFirstUser = (await userRepository.countAll()) === 0; if (!isFirstUser && config.allowed_users) { const email = ghUserInfo.email as string | undefined; @@ -911,12 +875,10 @@ router.get("/oidc/callback", async (req, res) => { let ghAutoProvision = false; try { - const r = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", - ) - .get() as { value: string } | undefined; - if (r) ghAutoProvision = r.value === "true"; + ghAutoProvision = await settingsRepository.getBoolean( + "oidc_auto_provision", + false, + ); } catch { /* */ } @@ -932,42 +894,23 @@ router.get("/oidc/callback", async (req, res) => { } const ghId = nanoid(); - const ghIsFirst = db.$client.transaction(() => { - const c = - ( - db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number } - )?.count || 0; - const first = c === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)", - ) - .run( - ghId, - ghName, - "", - first ? 1 : 0, - ghIdentifier, - callbackProviderDbId, - ); - return first; - })(); + const createdUser = await userRepository.createFirstLocalUser({ + id: ghId, + username: ghName, + passwordHash: "", + isOidc: true, + oidcIdentifier: ghIdentifier, + ssoProviderId: callbackProviderDbId, + }); + ghUserRecord = createdUser.user; try { - const defaultRoleName = ghIsFirst ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - if (defaultRole.length > 0) - await db.insert(userRoles).values({ - userId: ghId, - roleId: defaultRole[0].id, - grantedBy: ghId, - }); + const defaultRoleName = createdUser.isFirstUser ? "admin" : "user"; + await createCurrentRoleRepository().assignRoleNameToUser({ + userId: ghId, + roleName: defaultRoleName, + grantedBy: ghId, + }); } catch { /* */ } @@ -979,16 +922,13 @@ router.get("/oidc/callback", async (req, res) => { : 24 * 60 * 60 * 1000; await authManager.registerOIDCUser(ghId, sessionDurationMs); } catch { - await db.delete(users).where(eq(users.id, ghId)); + await userRepository.delete(ghId); return res.status(500).json({ error: "Failed to setup user security - user creation cancelled", }); } - - ghUser = await db.select().from(users).where(eq(users.id, ghId)); } - const ghUserRecord = ghUser[0]; try { await authManager.authenticateOIDCUser( ghUserRecord.id, @@ -1062,18 +1002,7 @@ router.get("/oidc/callback", async (req, res) => { const tokenData = (await tokenResponse.json()) as Record; - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_state_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_backend_callback_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_frontend_origin_${state}`); - db.$client - .prepare("DELETE FROM settings WHERE key = ?") - .run(`oidc_remember_me_${state}`); + await deleteOIDCStateSettings(state); let userInfo: Record = null; const userInfoUrls: string[] = []; @@ -1122,7 +1051,7 @@ router.get("/oidc/callback", async (req, res) => { caCert, ); - const expectedNonce = (storedNonce as { value: string }).value; + const expectedNonce = storedNonce; if (userInfo.nonce !== expectedNonce) { authLogger.warn("OIDC ID token nonce mismatch", { operation: "oidc_nonce_mismatch", @@ -1201,17 +1130,12 @@ router.get("/oidc/callback", async (req, res) => { } const deviceInfo = parseUserAgent(req); - let user = await db - .select() - .from(users) - .where(eq(users.oidcIdentifier, identifier)); + const userRepository = createCurrentUserRepository(); + let userRecord = await userRepository.findByOidcIdentifier(identifier); let isFirstUser = false; - if (!user || user.length === 0) { - const preCheckCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get(); - isFirstUser = ((preCheckCount as { count?: number })?.count || 0) === 0; + if (!userRecord) { + isFirstUser = (await userRepository.countAll()) === 0; if (!isFirstUser && config.allowed_users) { const email = userInfo.email as string | undefined; @@ -1229,15 +1153,10 @@ router.get("/oidc/callback", async (req, res) => { let oidcAutoProvision = false; try { - const oidcProvRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_auto_provision'", - ) - .get(); - if (oidcProvRow) { - oidcAutoProvision = - (oidcProvRow as Record).value === "true"; - } + oidcAutoProvision = await settingsRepository.getBoolean( + "oidc_auto_provision", + false, + ); } catch { // fall through to env var check } @@ -1263,42 +1182,27 @@ router.get("/oidc/callback", async (req, res) => { } const id = nanoid(); - isFirstUser = db.$client.transaction(() => { - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get() as { count?: number }; - const first = (countResult?.count || 0) === 0; - db.$client - .prepare( - "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .run( - id, - name, - "", - first ? 1 : 0, - 1, - identifier, - callbackProviderDbId, - ); - return first; - })(); + const createdUser = await userRepository.createFirstLocalUser({ + id, + username: name, + passwordHash: "", + isOidc: true, + oidcIdentifier: identifier, + ssoProviderId: callbackProviderDbId, + }); + isFirstUser = createdUser.isFirstUser; + userRecord = createdUser.user; try { const defaultRoleName = isFirstUser ? "admin" : "user"; - const defaultRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, defaultRoleName)) - .limit(1); - - if (defaultRole.length > 0) { - await db.insert(userRoles).values({ + const assigned = + await createCurrentRoleRepository().assignRoleNameToUser({ userId: id, - roleId: defaultRole[0].id, + roleName: defaultRoleName, grantedBy: id, }); - } else { + + if (!assigned) { authLogger.warn( "Default role not found during OIDC user registration", { @@ -1326,7 +1230,7 @@ router.get("/oidc/callback", async (req, res) => { : 24 * 60 * 60 * 1000; await authManager.registerOIDCUser(id, sessionDurationMs); } catch (encryptionError) { - await db.delete(users).where(eq(users.id, id)); + await userRepository.delete(id); authLogger.error( "Failed to setup OIDC user encryption, user creation rolled back", encryptionError, @@ -1341,16 +1245,13 @@ router.get("/oidc/callback", async (req, res) => { } try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("oidc_user_create_explicit_save"); } catch (saveError) { authLogger.error("Failed to persist OIDC user to disk", saveError, { operation: "oidc_user_create_save_failed", userId: id, }); } - - user = await db.select().from(users).where(eq(users.id, id)); } else { if (config.allowed_users) { const email = userInfo.email as string | undefined; @@ -1359,7 +1260,7 @@ router.get("/oidc/callback", async (req, res) => { operation: "oidc_user_not_allowed_existing", identifier, email, - userId: user[0].id, + userId: userRecord.id, }); const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "user_not_allowed"); @@ -1368,20 +1269,15 @@ router.get("/oidc/callback", async (req, res) => { } const isDualAuth = - user[0].passwordHash && user[0].passwordHash.trim() !== ""; + userRecord.passwordHash && userRecord.passwordHash.trim() !== ""; if (!isDualAuth) { - await db - .update(users) - .set({ username: name }) - .where(eq(users.id, user[0].id)); + userRecord = + (await userRepository.update(userRecord.id, { username: name })) ?? + userRecord; } - - user = await db.select().from(users).where(eq(users.id, user[0].id)); } - const userRecord = user[0]; - // Sync admin status based on OIDC group membership if (config.admin_group) { const groups = extractOidcGroups( @@ -1405,41 +1301,19 @@ router.get("/oidc/callback", async (req, res) => { group: config.admin_group, isAdmin: shouldBeAdmin, }); - await db - .update(users) - .set({ isAdmin: shouldBeAdmin }) - .where(eq(users.id, userRecord.id)); - userRecord.isAdmin = shouldBeAdmin; + userRecord = + (await userRepository.update(userRecord.id, { + isAdmin: shouldBeAdmin, + })) ?? userRecord; try { const newRoleName = shouldBeAdmin ? "admin" : "user"; const oldRoleName = shouldBeAdmin ? "user" : "admin"; - const newRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, newRoleName)) - .limit(1); - const oldRole = await db - .select({ id: roles.id }) - .from(roles) - .where(eq(roles.name, oldRoleName)) - .limit(1); - if (oldRole.length > 0) { - await db - .delete(userRoles) - .where( - and( - eq(userRoles.userId, userRecord.id), - eq(userRoles.roleId, oldRole[0].id), - ), - ); - } - if (newRole.length > 0) { - await db.insert(userRoles).values({ - userId: userRecord.id, - roleId: newRole[0].id, - grantedBy: userRecord.id, - }); - } + await createCurrentRoleRepository().switchUserRoleName({ + userId: userRecord.id, + addRoleName: newRoleName, + removeRoleName: oldRoleName, + grantedBy: userRecord.id, + }); } catch { /* non-fatal */ } @@ -1587,12 +1461,10 @@ router.post("/login", async (req, res) => { } try { - const user = await db - .select() - .from(users) - .where(eq(users.username, username)); + const userRecord = + await createCurrentUserRepository().findByUsername(username); - if (!user || user.length === 0) { + if (!userRecord) { loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: user not found`, { operation: "user_login", @@ -1606,8 +1478,6 @@ router.post("/login", async (req, res) => { return res.status(401).json({ error: "Invalid username or password" }); } - const userRecord = user[0]; - if ( userRecord.isOidc && (!userRecord.passwordHash || userRecord.passwordHash.trim() === "") @@ -1639,12 +1509,11 @@ router.post("/login", async (req, res) => { } try { - const kekSalt = await db - .select() - .from(settings) - .where(eq(settings.key, `user_kek_salt_${userRecord.id}`)); + const kekSalt = await createCurrentSettingsRepository().get( + `user_kek_salt_${userRecord.id}`, + ); - if (kekSalt.length === 0) { + if (!kekSalt) { await authManager.registerUser(userRecord.id, password); } } catch { @@ -1738,10 +1607,11 @@ router.post("/login", async (req, res) => { ...(isNativeAppRequest(req) ? { token } : {}), }; - const timeoutRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'") - .get() as { value: string } | undefined; - const timeoutHours = timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24; + const sessionTimeoutHoursValue = + await createCurrentSettingsRepository().get("session_timeout_hours"); + const timeoutHours = sessionTimeoutHoursValue + ? parseInt(sessionTimeoutHoursValue, 10) || 24 + : 24; const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : timeoutHours * 60 * 60 * 1000; @@ -1896,24 +1766,23 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({ error: "Invalid userId" }); } try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const user = await findCurrentUser(userId); + if (!user) { authLogger.warn(`User not found for /users/me: ${userId}`); return res.status(401).json({ error: "User not found" }); } - const hasPassword = - user[0].passwordHash && user[0].passwordHash.trim() !== ""; - const hasOidc = user[0].isOidc && user[0].oidcIdentifier; + const hasPassword = user.passwordHash && user.passwordHash.trim() !== ""; + const hasOidc = user.isOidc && user.oidcIdentifier; const isDualAuth = hasPassword && hasOidc; res.json({ - userId: user[0].id, - username: user[0].username, - is_admin: !!user[0].isAdmin, - is_oidc: !!user[0].isOidc, + userId: user.id, + username: user.username, + is_admin: !!user.isAdmin, + is_oidc: !!user.isOidc, is_dual_auth: isDualAuth, - totp_enabled: !!user[0].totpEnabled, + totp_enabled: !!user.totpEnabled, data_unlocked: authManager.isUserUnlocked(userId), }); } catch (err) { @@ -1965,10 +1834,7 @@ router.get("/me/token", authenticateJWT, (req: Request, res: Response) => { */ router.get("/setup-required", async (req, res) => { try { - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get(); - const count = (countResult as { count?: number })?.count || 0; + const count = await createCurrentUserRepository().countAll(); res.json({ setup_required: count === 0, @@ -1998,15 +1864,12 @@ router.get("/setup-required", async (req, res) => { router.get("/count", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user[0] || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Admin access required" }); } - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM users") - .get(); - const count = (countResult as { count?: number })?.count || 0; + const count = await createCurrentUserRepository().countAll(); res.json({ count }); } catch (err) { authLogger.error("Failed to count users", err); @@ -2030,7 +1893,7 @@ router.get("/count", authenticateJWT, async (req, res) => { */ router.get("/db-health", requireAdmin, async (req, res) => { try { - db.$client.prepare("SELECT 1").get(); + await createCurrentUserRepository().countAll(); res.json({ status: "ok" }); } catch (err) { authLogger.error("DB health check failed", err); @@ -2091,17 +1954,18 @@ router.get("/registration-allowed", async (req, res) => { router.patch("/registration-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } - db.$client - .prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'") - .run(allowed ? "true" : "false"); + await createCurrentSettingsRepository().set( + "allow_registration", + allowed ? "true" : "false", + ); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set registration allowed", err); @@ -2111,11 +1975,11 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { router.get("/oidc-auto-provision", async (_req, res) => { try { - const row = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") - .get(); res.json({ - enabled: row ? (row as Record).value === "true" : false, + enabled: await createCurrentSettingsRepository().getBoolean( + "oidc_auto_provision", + false, + ), }); } catch (err) { authLogger.error("Failed to get OIDC auto-provision setting", err); @@ -2128,30 +1992,18 @@ router.get("/oidc-auto-provision", async (_req, res) => { router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "Invalid value for enabled" }); } - const existing = db.$client - .prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'") - .get(); - if (existing) { - db.$client - .prepare( - "UPDATE settings SET value = ? WHERE key = 'oidc_auto_provision'", - ) - .run(enabled ? "true" : "false"); - } else { - db.$client - .prepare( - "INSERT INTO settings (key, value) VALUES ('oidc_auto_provision', ?)", - ) - .run(enabled ? "true" : "false"); - } + await createCurrentSettingsRepository().set( + "oidc_auto_provision", + enabled ? "true" : "false", + ); res.json({ enabled }); } catch (err) { authLogger.error("Failed to set OIDC auto-provision", err); @@ -2175,13 +2027,11 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => { */ router.get("/oidc-silent-login-default", async (_req, res) => { try { - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_silent_login_default'", - ) - .get(); res.json({ - enabled: row ? (row as Record).value === "true" : false, + enabled: await createCurrentSettingsRepository().getBoolean( + "oidc_silent_login_default", + false, + ), }); } catch (err) { authLogger.error("Failed to get OIDC silent login default", err); @@ -2222,34 +2072,18 @@ router.patch( async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "Invalid value for enabled" }); } - const existing = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'oidc_silent_login_default'", - ) - .get(); - if (existing) { - db.$client - .prepare( - "UPDATE settings SET value = ? WHERE key = 'oidc_silent_login_default'", - ) - .run(enabled ? "true" : "false"); - } else { - db.$client - .prepare( - "INSERT INTO settings (key, value) VALUES ('oidc_silent_login_default', ?)", - ) - .run(enabled ? "true" : "false"); - } - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await createCurrentSettingsRepository().set( + "oidc_silent_login_default", + enabled ? "true" : "false", + ); res.json({ enabled }); } catch (err) { authLogger.error("Failed to set OIDC silent login default", err); @@ -2313,8 +2147,8 @@ router.get("/password-login-allowed", async (req, res) => { router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; @@ -2322,23 +2156,19 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { return res.status(400).json({ error: "Invalid value for allowed" }); } if (!allowed) { - const totpRow = db.$client - .prepare("SELECT COUNT(*) as count FROM users WHERE totp_enabled = 1") - .get() as { count?: number }; - if ((totpRow?.count || 0) > 0) { + const totpEnabledCount = + await createCurrentUserRepository().countTotpEnabled(); + if (totpEnabledCount > 0) { return res.status(409).json({ error: "Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.", }); } } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)", - ) - .run(allowed ? "true" : "false"); - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); + await createCurrentSettingsRepository().set( + "allow_password_login", + allowed ? "true" : "false", + ); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set password login allowed", err); @@ -2399,19 +2229,18 @@ router.get("/password-reset-allowed", async (req, res) => { router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0 || !user[0].isAdmin) { + const user = await requireCurrentAdmin(userId); + if (!user) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_reset', ?)", - ) - .run(allowed ? "true" : "false"); + await createCurrentSettingsRepository().set( + "allow_password_reset", + allowed ? "true" : "false", + ); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set password reset allowed", err); @@ -2461,13 +2290,11 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { } try { - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await findCurrentUser(userId); + if (!userRecord) { return res.status(404).json({ error: "User not found" }); } - const userRecord = user[0]; - if (userRecord.isOidc) { return res.status(403).json({ error: @@ -2484,17 +2311,15 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { } if (userRecord.isAdmin) { - const adminCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") - .get(); - if (((adminCount as { count?: number })?.count || 0) <= 1) { + const adminCount = await createCurrentUserRepository().countAdmins(); + if (adminCount <= 1) { return res .status(403) .json({ error: "Cannot delete the last admin user" }); } } - await db.delete(users).where(eq(users.id, userId)); + await createCurrentUserRepository().delete(userId); authLogger.success(`User account deleted: ${userRecord.username}`); res.json({ message: "Account deleted successfully" }); @@ -2553,12 +2378,12 @@ router.post("/change-password", authenticateJWT, async (req, res) => { .json({ error: "Old and new passwords are required." }); } - const user = await db.select().from(users).where(eq(users.id, userId)); - if (!user || user.length === 0) { + const user = await findCurrentUser(userId); + if (!user) { return res.status(404).json({ error: "User not found" }); } - const isMatch = await bcrypt.compare(oldPassword, user[0].passwordHash); + const isMatch = await bcrypt.compare(oldPassword, user.passwordHash); if (!isMatch) { authLogger.warn("Password change failed - old password incorrect", { operation: "password_change_failed", @@ -2580,10 +2405,9 @@ router.post("/change-password", authenticateJWT, async (req, res) => { } const password_hash = await bcrypt.hash(newPassword, 10); - await db - .update(users) - .set({ passwordHash: password_hash }) - .where(eq(users.id, userId)); + await createCurrentUserRepository().update(userId, { + passwordHash: password_hash, + }); authManager.logoutUser(userId); authLogger.success("Password changed successfully", { @@ -2592,14 +2416,9 @@ router.post("/change-password", authenticateJWT, async (req, res) => { }); const { ipAddress: pwIp, userAgent: pwUa } = getRequestMeta(req); - const pwUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: pwUser[0]?.username ?? userId, + username: user.username ?? userId, action: "change_password", resourceType: "user", resourceId: userId, @@ -2619,12 +2438,6 @@ registerUserTotpRoutes(router, { isNativeAppRequest, }); -registerUserWebAuthnRoutes(router, { - authenticateJWT, - authManager, - isNativeAppRequest, -}); - /** * @openapi * /users/delete-user: @@ -2663,35 +2476,30 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { } try { - const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { + const userRepository = createCurrentUserRepository(); + const adminUser = await userRepository.findById(userId); + if (!adminUser?.isAdmin) { return res.status(403).json({ error: "Not authorized" }); } - if (adminUser[0].username === username) { + if (adminUser.username === username) { return res.status(400).json({ error: "Cannot delete your own account" }); } - const targetUser = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (!targetUser || targetUser.length === 0) { + const targetUser = await userRepository.findByUsername(username); + if (!targetUser) { return res.status(404).json({ error: "User not found" }); } - if (targetUser[0].isAdmin) { - const adminCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") - .get(); - if (((adminCount as { count?: number })?.count || 0) <= 1) { + if (targetUser.isAdmin) { + if ((await userRepository.countAdmins()) <= 1) { return res .status(403) .json({ error: "Cannot delete the last admin user" }); } } - const targetUserId = targetUser[0].id; + const targetUserId = targetUser.id; await deleteUserAndRelatedData(targetUserId); @@ -2703,14 +2511,9 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { }); const { ipAddress: deleteIp, userAgent: deleteUa } = getRequestMeta(req); - const delAdminRecord = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); await logAudit({ userId, - username: delAdminRecord[0]?.username ?? userId, + username: adminUser.username ?? userId, action: "delete_user", resourceType: "user", resourceId: targetUserId, diff --git a/src/backend/database/routes/vault.ts b/src/backend/database/routes/vault.ts index 502b34eb..9a43ad69 100644 --- a/src/backend/database/routes/vault.ts +++ b/src/backend/database/routes/vault.ts @@ -1,8 +1,8 @@ import express from "express"; import type { Request, Response } from "express"; -import { desc, eq, or } from "drizzle-orm"; -import { db } from "../db/index.js"; -import { users, vaultProfiles } from "../db/schema.js"; +import { createCurrentVaultProfileRepository } from "../repositories/current-vault-profile-repository.js"; +import { createCurrentUserRepository } from "../repositories/current-user-repository.js"; +import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -19,12 +19,8 @@ function isNonEmptyString(val: unknown): val is string { async function userIsAdmin(userId: string): Promise { try { - const rows = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - return !!rows[0]?.isAdmin; + const user = await createCurrentUserRepository().findById(userId); + return !!user?.isAdmin; } catch { return false; } @@ -148,13 +144,8 @@ router.get( async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const rows = await db - .select() - .from(vaultProfiles) - .where( - or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)), - ) - .orderBy(desc(vaultProfiles.updatedAt)); + const rows = + await createCurrentVaultProfileRepository().listVisibleToUser(userId); res.json( rows.map((r) => formatProfile(r as Record, userId)), ); @@ -253,28 +244,25 @@ router.post( } try { - const inserted = await db - .insert(vaultProfiles) - .values({ - userId, - name: name.trim(), - description: description?.trim() || null, - folder: folder?.trim() || null, - tags: Array.isArray(tags) ? tags.join(",") : tags || "", - vaultAddr: vaultAddr.trim(), - vaultNamespace: vaultNamespace?.trim() || null, - oidcMount: oidcMount?.trim() || null, - oidcRole: oidcRole?.trim() || null, - sshMount: sshMount?.trim() || null, - sshRole: sshRole.trim(), - validPrincipals: validPrincipals?.trim() || null, - keyType: keyType?.trim() || null, - shared: wantShared, - }) - .returning(); + const inserted = await createCurrentVaultProfileRepository().create({ + userId, + name: name.trim(), + description: description?.trim() || null, + folder: folder?.trim() || null, + tags: Array.isArray(tags) ? tags.join(",") : tags || "", + vaultAddr: vaultAddr.trim(), + vaultNamespace: vaultNamespace?.trim() || null, + oidcMount: oidcMount?.trim() || null, + oidcRole: oidcRole?.trim() || null, + sshMount: sshMount?.trim() || null, + sshRole: sshRole.trim(), + validPrincipals: validPrincipals?.trim() || null, + keyType: keyType?.trim() || null, + shared: wantShared, + }); res .status(201) - .json(formatProfile(inserted[0] as Record, userId)); + .json(formatProfile(inserted as Record, userId)); } catch (err) { authLogger.error("Failed to create vault profile", err); res.status(500).json({ error: "Failed to create vault profile" }); @@ -324,22 +312,19 @@ router.put( } try { - const existing = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, id)) - .limit(1); - if (!existing.length) { + const repository = createCurrentVaultProfileRepository(); + const existing = await repository.findById(id); + if (!existing) { return res.status(404).json({ error: "Profile not found" }); } - if (existing[0].userId !== userId) { + if (existing.userId !== userId) { return res .status(403) .json({ error: "Only the owner can edit this profile" }); } const body = req.body; - const fields: Record = { + const fields: VaultProfileUpdateInput = { updatedAt: new Date().toISOString(), }; if (body.name !== undefined) fields.name = body.name?.trim(); @@ -376,12 +361,11 @@ router.put( fields.shared = !!body.shared; } - const updated = await db - .update(vaultProfiles) - .set(fields) - .where(eq(vaultProfiles.id, id)) - .returning(); - res.json(formatProfile(updated[0] as Record, userId)); + const updated = await repository.updateById(id, fields); + if (!updated) { + return res.status(404).json({ error: "Profile not found" }); + } + res.json(formatProfile(updated as Record, userId)); } catch (err) { authLogger.error("Failed to update vault profile", err); res.status(500).json({ error: "Failed to update vault profile" }); @@ -425,20 +409,17 @@ router.delete( return res.status(400).json({ error: "Invalid profile id" }); } try { - const existing = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, id)) - .limit(1); - if (!existing.length) { + const repository = createCurrentVaultProfileRepository(); + const existing = await repository.findById(id); + if (!existing) { return res.status(404).json({ error: "Profile not found" }); } - if (existing[0].userId !== userId) { + if (existing.userId !== userId) { return res .status(403) .json({ error: "Only the owner can delete this profile" }); } - await db.delete(vaultProfiles).where(eq(vaultProfiles.id, id)); + await repository.deleteById(id); res.json({ success: true }); } catch (err) { authLogger.error("Failed to delete vault profile", err); diff --git a/src/backend/database/runtime/adapter.ts b/src/backend/database/runtime/adapter.ts new file mode 100644 index 00000000..76198359 --- /dev/null +++ b/src/backend/database/runtime/adapter.ts @@ -0,0 +1,31 @@ +import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; +import type { Database as BetterSqliteDatabase } from "better-sqlite3"; +import type * as schema from "../db/schema.js"; +import type { DatabaseDialect, DatabaseRuntimeConfig } from "./config.js"; +import { SqliteDatabaseAdapter } from "./sqlite-adapter.js"; + +export interface DatabaseContext { + dialect: DatabaseDialect; + drizzle: BetterSQLite3Database; + sqlite?: BetterSqliteDatabase; +} + +export interface DatabaseAdapter { + readonly dialect: DatabaseDialect; + connect(): Promise; + migrate(): Promise; + transaction(fn: (tx: DatabaseContext) => T | Promise): Promise; + close(): Promise; +} + +export function createDatabaseAdapter( + config: DatabaseRuntimeConfig, +): DatabaseAdapter { + if (config.dialect === "sqlite") { + return new SqliteDatabaseAdapter(config); + } + + throw new Error( + `${config.dialect} adapter is not implemented yet. Use sqlite for the first runtime slice.`, + ); +} diff --git a/src/backend/database/runtime/config.test.ts b/src/backend/database/runtime/config.test.ts new file mode 100644 index 00000000..e3718cf6 --- /dev/null +++ b/src/backend/database/runtime/config.test.ts @@ -0,0 +1,57 @@ +import path from "path"; +import { describe, expect, it } from "vitest"; +import { isExternalDatabase, parseDatabaseRuntimeConfig } from "./config.js"; + +describe("parseDatabaseRuntimeConfig", () => { + it("defaults to sqlite under DATA_DIR", () => { + const config = parseDatabaseRuntimeConfig({ DATA_DIR: "/app/data" }); + + const sqlitePath = path.join("/app/data", "termix.sqlite"); + expect(config).toEqual({ + dialect: "sqlite", + url: `file:${sqlitePath}`, + sqlitePath, + }); + }); + + it("accepts explicit sqlite file URLs", () => { + const config = parseDatabaseRuntimeConfig({ + DB_TYPE: "sqlite", + DATABASE_URL: "file:/tmp/termix.sqlite", + }); + + expect(config.sqlitePath).toBe("/tmp/termix.sqlite"); + }); + + it("normalizes postgres aliases", () => { + const config = parseDatabaseRuntimeConfig({ + DB_TYPE: "postgresql", + DATABASE_URL: "postgres://termix:pass@db:5432/termix", + }); + + expect(config.dialect).toBe("postgres"); + expect(isExternalDatabase(config.dialect)).toBe(true); + }); + + it("normalizes mariadb as mysql", () => { + const config = parseDatabaseRuntimeConfig({ + DB_TYPE: "mariadb", + DATABASE_URL: "mysql://termix:pass@db:3306/termix", + }); + + expect(config.dialect).toBe("mysql"); + expect(isExternalDatabase(config.dialect)).toBe(true); + }); + + it("requires DATABASE_URL for external databases", () => { + expect(() => parseDatabaseRuntimeConfig({ DB_TYPE: "postgres" })).toThrow( + "DATABASE_URL is required", + ); + }); + + it("rejects unsupported database types", () => { + expect(() => parseDatabaseRuntimeConfig({ DB_TYPE: "oracle" })).toThrow( + "Unsupported DB_TYPE", + ); + }); +}); diff --git a/src/backend/database/runtime/config.ts b/src/backend/database/runtime/config.ts new file mode 100644 index 00000000..25cc0465 --- /dev/null +++ b/src/backend/database/runtime/config.ts @@ -0,0 +1,58 @@ +import path from "path"; + +export type DatabaseDialect = "sqlite" | "postgres" | "mysql"; + +export interface DatabaseRuntimeConfig { + dialect: DatabaseDialect; + url: string; + sqlitePath?: string; +} + +type EnvLike = Record; + +function normalizeDialect(value: string | undefined): DatabaseDialect { + const raw = (value || "sqlite").trim().toLowerCase(); + if (raw === "sqlite") return "sqlite"; + if (raw === "postgres" || raw === "postgresql") return "postgres"; + if (raw === "mysql" || raw === "mariadb") return "mysql"; + throw new Error( + `Unsupported DB_TYPE '${value}'. Expected sqlite, postgres, or mysql.`, + ); +} + +function defaultSqliteUrl(env: EnvLike): string { + const dataDir = env.DATA_DIR || "./db/data"; + return `file:${path.join(dataDir, "termix.sqlite")}`; +} + +function sqlitePathFromUrl(url: string): string { + if (url === ":memory:") return url; + if (url.startsWith("file:")) return url.slice("file:".length); + return url; +} + +export function parseDatabaseRuntimeConfig( + env: EnvLike = process.env, +): DatabaseRuntimeConfig { + const dialect = normalizeDialect(env.DB_TYPE); + const url = + env.DATABASE_URL || (dialect === "sqlite" ? defaultSqliteUrl(env) : ""); + + if (!url) { + throw new Error(`DATABASE_URL is required when DB_TYPE is '${dialect}'.`); + } + + if (dialect === "sqlite") { + return { + dialect, + url, + sqlitePath: sqlitePathFromUrl(url), + }; + } + + return { dialect, url }; +} + +export function isExternalDatabase(dialect: DatabaseDialect): boolean { + return dialect === "postgres" || dialect === "mysql"; +} diff --git a/src/backend/database/runtime/sqlite-adapter.test.ts b/src/backend/database/runtime/sqlite-adapter.test.ts new file mode 100644 index 00000000..a473e532 --- /dev/null +++ b/src/backend/database/runtime/sqlite-adapter.test.ts @@ -0,0 +1,55 @@ +import { mkdtempSync, rmSync } from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { SqliteDatabaseAdapter } from "./sqlite-adapter.js"; + +describe("SqliteDatabaseAdapter", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it("opens a persistent sqlite database and creates migration metadata tables", async () => { + tempDir = mkdtempSync(path.join(os.tmpdir(), "termix-sqlite-adapter-")); + const dbPath = path.join(tempDir, "termix.sqlite"); + const adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: `file:${dbPath}`, + sqlitePath: dbPath, + }); + + const context = await adapter.connect(); + await adapter.migrate(); + + const migrationTable = context.sqlite + ?.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .get("schema_migrations") as { name: string } | undefined; + + expect(context.dialect).toBe("sqlite"); + expect(migrationTable?.name).toBe("schema_migrations"); + + await adapter.close(); + }); + + it("rejects async sqlite transaction callbacks for now", async () => { + const adapter = new SqliteDatabaseAdapter({ + dialect: "sqlite", + url: ":memory:", + sqlitePath: ":memory:", + }); + await adapter.connect(); + + await expect(adapter.transaction(async () => "done")).rejects.toThrow( + "must not return a Promise", + ); + + await adapter.close(); + }); +}); diff --git a/src/backend/database/runtime/sqlite-adapter.ts b/src/backend/database/runtime/sqlite-adapter.ts new file mode 100644 index 00000000..be7e90d2 --- /dev/null +++ b/src/backend/database/runtime/sqlite-adapter.ts @@ -0,0 +1,92 @@ +import fs from "fs"; +import path from "path"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import * as schema from "../db/schema.js"; +import type { DatabaseRuntimeConfig } from "./config.js"; +import type { DatabaseAdapter, DatabaseContext } from "./adapter.js"; + +export class SqliteDatabaseAdapter implements DatabaseAdapter { + readonly dialect = "sqlite" as const; + private sqlite: Database.Database | null = null; + private context: DatabaseContext | null = null; + + constructor(private readonly config: DatabaseRuntimeConfig) { + if (config.dialect !== "sqlite") { + throw new Error("SqliteDatabaseAdapter requires sqlite config."); + } + } + + async connect(): Promise { + if (this.context) return this.context; + + const sqlitePath = this.config.sqlitePath || ":memory:"; + if (sqlitePath !== ":memory:") { + fs.mkdirSync(path.dirname(sqlitePath), { recursive: true }); + } + + this.sqlite = new Database(sqlitePath); + this.sqlite.exec("PRAGMA foreign_keys = ON"); + if (sqlitePath !== ":memory:") { + this.sqlite.exec("PRAGMA journal_mode = WAL"); + } + + this.context = { + dialect: this.dialect, + drizzle: drizzle(this.sqlite, { schema }), + sqlite: this.sqlite, + }; + + return this.context; + } + + async migrate(): Promise { + if (!this.sqlite) { + throw new Error("SQLite adapter must be connected before migration."); + } + + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + checksum TEXT, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS system_migrations ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + } + + async transaction( + fn: (tx: DatabaseContext) => T | Promise, + ): Promise { + const context = await this.connect(); + if (!this.sqlite) { + throw new Error("SQLite adapter is not connected."); + } + + const run = this.sqlite.transaction(() => { + const result = fn(context); + if (result instanceof Promise) { + throw new Error( + "SQLite adapter transactions must not return a Promise until async transaction support is introduced.", + ); + } + return result; + }); + return run(); + } + + async close(): Promise { + if (this.sqlite) { + this.sqlite.close(); + this.sqlite = null; + this.context = null; + } + } +} diff --git a/src/backend/database/runtime/sqlite-foreign-key-boundary.test.ts b/src/backend/database/runtime/sqlite-foreign-key-boundary.test.ts new file mode 100644 index 00000000..95063cf4 --- /dev/null +++ b/src/backend/database/runtime/sqlite-foreign-key-boundary.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { withSqliteForeignKeysDisabled } from "./sqlite-foreign-key-boundary.js"; + +describe("withSqliteForeignKeysDisabled", () => { + it("disables foreign keys for an operation and restores them afterwards", async () => { + const exec = vi.fn(); + + await expect( + withSqliteForeignKeysDisabled({ exec }, async () => "done"), + ).resolves.toBe("done"); + + expect(exec).toHaveBeenNthCalledWith(1, "PRAGMA foreign_keys = OFF"); + expect(exec).toHaveBeenNthCalledWith(2, "PRAGMA foreign_keys = ON"); + }); + + it("restores foreign keys when the operation fails", async () => { + const exec = vi.fn(); + + await expect( + withSqliteForeignKeysDisabled({ exec }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(exec).toHaveBeenNthCalledWith(1, "PRAGMA foreign_keys = OFF"); + expect(exec).toHaveBeenNthCalledWith(2, "PRAGMA foreign_keys = ON"); + }); +}); diff --git a/src/backend/database/runtime/sqlite-foreign-key-boundary.ts b/src/backend/database/runtime/sqlite-foreign-key-boundary.ts new file mode 100644 index 00000000..52f24f0f --- /dev/null +++ b/src/backend/database/runtime/sqlite-foreign-key-boundary.ts @@ -0,0 +1,23 @@ +import { getCurrentRepositorySqlite } from "../repositories/current-repository-runtime.js"; + +export interface SqliteForeignKeyClient { + exec(sql: string): unknown; +} + +export async function withSqliteForeignKeysDisabled( + sqlite: SqliteForeignKeyClient, + operation: () => Promise, +): Promise { + sqlite.exec("PRAGMA foreign_keys = OFF"); + try { + return await operation(); + } finally { + sqlite.exec("PRAGMA foreign_keys = ON"); + } +} + +export async function withCurrentSqliteForeignKeysDisabled( + operation: () => Promise, +): Promise { + return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation); +} diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts index 3d7b80bf..7aa4536a 100644 --- a/src/backend/guacamole/guacamole-server.ts +++ b/src/backend/guacamole/guacamole-server.ts @@ -1,10 +1,11 @@ import GuacamoleLite from "guacamole-lite"; import { guacLogger } from "../utils/logger.js"; import { GuacamoleTokenService } from "./token-service.js"; -import { getDb } from "../database/db/index.js"; +import { getCurrentSettingValue } from "../database/repositories/current-settings-repository.js"; import { resolveGuacdOptions } from "../utils/guacd-config.js"; import fs from "fs"; import path from "path"; +import { getDb } from "../database/db/index.js"; import { sessionRecordings } from "../database/db/schema.js"; import type { GuacamoleRecordingMetadata } from "./token-service.js"; @@ -13,11 +14,7 @@ const tokenService = GuacamoleTokenService.getInstance(); function readGuacdOptions(): { host: string; port: number } { let dbUrl: string | undefined; try { - const db = getDb(); - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; - dbUrl = urlRow?.value; + dbUrl = getCurrentSettingValue("guac_url") ?? undefined; } catch { // DB not available yet, use env var defaults } diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts index 03c6249c..4d5a0570 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/guacamole/routes.ts @@ -3,15 +3,13 @@ import { GuacamoleTokenService } from "./token-service.js"; import { guacLogger } from "../utils/logger.js"; import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; import { Client } from "ssh2"; import net from "net"; import crypto from "crypto"; import path from "path"; import type { AuthenticatedRequest } from "../../types/index.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; +import { createCurrentSettingsRepository } from "../database/repositories/current-settings-repository.js"; import { resolveGuacdOptions } from "../utils/guacd-config.js"; const router = express.Router(); @@ -196,18 +194,15 @@ router.post( return res.status(400).json({ error: "Invalid host ID" }); } - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, userId, ); - if (hostResults.length === 0) { + if (!host) { return res.status(404).json({ error: "Host not found" }); } - const host = hostResults[0]; - if (host.userId !== userId) { const permissionManager = PermissionManager.getInstance(); const accessInfo = await permissionManager.canAccessHost( @@ -297,6 +292,7 @@ router.post( } const hostRecord = host as Record; + const hostRepository = createCurrentHostResolutionRepository(); // Backward compat: if authType is not stored but a credentialId is, treat as credential mode const rdpEffectiveAuthType = @@ -311,21 +307,13 @@ router.post( if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) { try { - const rdpCreds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.rdpCredentialId as number), - eq(sshCredentials.userId, host.userId as string), - ), - ), - "ssh_credentials", - userId, - ); - if (rdpCreds.length > 0) { - const cred = rdpCreds[0] as Record; + const cred = + await hostRepository.findCredentialByIdForOwnerDecryptedAs( + host.rdpCredentialId as number, + host.userId as string, + userId, + ); + if (cred) { if (cred.username) host.rdpUser = cred.username; if (cred.password) host.rdpPassword = cred.password; // domain is never sourced from credential @@ -341,21 +329,13 @@ router.post( if (vncEffectiveAuthType === "credential" && host.vncCredentialId) { try { - const vncCreds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.vncCredentialId as number), - eq(sshCredentials.userId, host.userId as string), - ), - ), - "ssh_credentials", - userId, - ); - if (vncCreds.length > 0) { - const cred = vncCreds[0] as Record; + const cred = + await hostRepository.findCredentialByIdForOwnerDecryptedAs( + host.vncCredentialId as number, + host.userId as string, + userId, + ); + if (cred) { if (cred.password) host.vncPassword = cred.password; if (cred.username) host.vncUser = cred.username; } @@ -373,24 +353,13 @@ router.post( hostRecord.telnetCredentialId ) { try { - const telnetCreds = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq( - sshCredentials.id, - hostRecord.telnetCredentialId as number, - ), - eq(sshCredentials.userId, host.userId as string), - ), - ), - "ssh_credentials", - userId, - ); - if (telnetCreds.length > 0) { - const cred = telnetCreds[0] as Record; + const cred = + await hostRepository.findCredentialByIdForOwnerDecryptedAs( + hostRecord.telnetCredentialId as number, + host.userId as string, + userId, + ); + if (cred) { if (cred.username) host.telnetUser = cred.username; if (cred.password) host.telnetPassword = cred.password; } @@ -630,11 +599,8 @@ router.get("/status", async (req, res) => { try { let dbUrl: string | undefined; try { - const db = getDb(); - const urlRow = db.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_url'") - .get() as { value: string } | undefined; - dbUrl = urlRow?.value; + dbUrl = + (await createCurrentSettingsRepository().get("guac_url")) ?? undefined; } catch { // Fall back to env vars } diff --git a/src/backend/ssh/alert-engine.ts b/src/backend/ssh/alert-engine.ts index 2f1dfb73..16d57040 100644 --- a/src/backend/ssh/alert-engine.ts +++ b/src/backend/ssh/alert-engine.ts @@ -1,4 +1,4 @@ -import { getDb } from "../database/db/index.js"; +import { createCurrentAlertRepository } from "../database/repositories/current-alert-repository.js"; import { statsLogger } from "../utils/logger.js"; import { sendNotification, @@ -28,25 +28,6 @@ interface AlertRule { cooldownMinutes: number; } -interface RawRule { - id: number; - user_id: string; - host_id: number | null; - name: string; - enabled: number; - trigger_type: string; - threshold_value: number | null; - threshold_duration_seconds: number | null; - cooldown_minutes: number; -} - -interface RawChannel { - id: number; - type: string; - config: string; - enabled: number; -} - export class AlertEngine { private static instance: AlertEngine; @@ -74,7 +55,7 @@ export class AlertEngine { disk?: { percent: number | null } | null; }, ): Promise { - const rules = this.loadRulesForHost(hostId).filter((r) => + const rules = (await this.loadRulesForHost(hostId)).filter((r) => ["cpu_threshold", "memory_threshold", "disk_threshold"].includes( r.triggerType, ), @@ -106,9 +87,9 @@ export class AlertEngine { const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000; if ( now - breachStart >= durationMs && - !this.isCoolingDown(rule.id, hostId) + !(await this.isCoolingDown(rule.id, hostId)) ) { - const hostName = this.getHostName(hostId); + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { value: currentValue, message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`, @@ -133,13 +114,13 @@ export class AlertEngine { const triggerType: AlertTriggerType = isOnline ? "host_online" : "host_offline"; - const rules = this.loadRulesForHost(hostId).filter( + const rules = (await this.loadRulesForHost(hostId)).filter( (r) => r.triggerType === triggerType, ); for (const rule of rules) { - if (!this.isCoolingDown(rule.id, hostId)) { - const hostName = this.getHostName(hostId); + if (!(await this.isCoolingDown(rule.id, hostId))) { + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { message: isOnline ? `Host "${hostName}" is back online` @@ -169,13 +150,13 @@ export class AlertEngine { if (!triggerType) return; - const rules = this.loadRulesForHostUser(hostId, userId).filter( + const rules = (await this.loadRulesForHostUser(hostId, userId)).filter( (r) => r.triggerType === triggerType, ); for (const rule of rules) { - if (!this.isCoolingDown(rule.id, hostId)) { - const hostName = this.getHostName(hostId); + if (!(await this.isCoolingDown(rule.id, hostId))) { + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { message: ok ? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}` @@ -192,13 +173,13 @@ export class AlertEngine { sshUser: string, fromIp: string, ): Promise { - const rules = this.loadRulesForHostUser(hostId, userId).filter( + const rules = (await this.loadRulesForHostUser(hostId, userId)).filter( (r) => r.triggerType === "user_login", ); for (const rule of rules) { - if (!this.isCoolingDown(rule.id, hostId)) { - const hostName = this.getHostName(hostId); + if (!(await this.isCoolingDown(rule.id, hostId))) { + const hostName = await this.getHostName(hostId); await this.fireAlert(rule, hostId, hostName, { message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`, severity: "info", @@ -233,28 +214,18 @@ export class AlertEngine { }; try { - const db = getDb(); - db.$client - .prepare( - `INSERT INTO alert_firings (user_id, rule_id, host_id, host_name, value, message, severity) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - rule.userId, - rule.id, - hostId, - hostName, - context.value ?? null, - context.message, - context.severity, - ); + const repository = createCurrentAlertRepository(); + await repository.createFiring({ + userId: rule.userId, + ruleId: rule.id, + hostId, + hostName, + value: context.value ?? null, + message: context.message, + severity: context.severity, + }); - // Prune old firings beyond 30 days - db.$client - .prepare( - `DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', '-30 days')`, - ) - .run(rule.userId); + repository.pruneFiringsOlderThan(rule.userId, 30); } catch (err) { statsLogger.warn("Failed to write alert firing", { operation: "alert_firing_insert_error", @@ -263,7 +234,7 @@ export class AlertEngine { }); } - const channels = this.loadChannelsForRule(rule.id); + const channels = await this.loadChannelsForRule(rule.id); for (const channel of channels) { sendNotification(channel, payload).catch((err) => { statsLogger.warn("Failed to send notification", { @@ -275,11 +246,14 @@ export class AlertEngine { } } - private isCoolingDown(ruleId: number, hostId: number): boolean { + private async isCoolingDown( + ruleId: number, + hostId: number, + ): Promise { const key = `${ruleId}:${hostId}`; const lastFired = this.cooldownMap.get(key); if (!lastFired) return false; - const rule = this.loadRuleById(ruleId); + const rule = await this.loadRuleById(ruleId); const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000; return Date.now() - lastFired < cooldownMs; } @@ -288,93 +262,60 @@ export class AlertEngine { this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now()); } - private loadRulesForHost(hostId: number): AlertRule[] { + private async loadRulesForHost(hostId: number): Promise { try { - const db = getDb(); - const rows = db.$client - .prepare( - `SELECT * FROM alert_rules - WHERE enabled = 1 AND (host_id = ? OR host_id IS NULL)`, - ) - .all(hostId) as RawRule[]; - return rows.map(mapRawRule); + return (await createCurrentAlertRepository().listEnabledRulesForHost( + hostId, + )) as AlertRule[]; } catch { return []; } } - private loadRulesForHostUser(hostId: number, userId: string): AlertRule[] { + private async loadRulesForHostUser( + hostId: number, + userId: string, + ): Promise { try { - const db = getDb(); - const rows = db.$client - .prepare( - `SELECT * FROM alert_rules - WHERE enabled = 1 AND user_id = ? AND (host_id = ? OR host_id IS NULL)`, - ) - .all(userId, hostId) as RawRule[]; - return rows.map(mapRawRule); + return (await createCurrentAlertRepository().listEnabledRulesForHostUser( + hostId, + userId, + )) as AlertRule[]; } catch { return []; } } - private loadRuleById(ruleId: number): AlertRule | null { + private async loadRuleById(ruleId: number): Promise { try { - const db = getDb(); - const row = db.$client - .prepare("SELECT * FROM alert_rules WHERE id = ?") - .get(ruleId) as RawRule | undefined; - return row ? mapRawRule(row) : null; + return (await createCurrentAlertRepository().findRuleById( + ruleId, + )) as AlertRule | null; } catch { return null; } } - private loadChannelsForRule(ruleId: number): NotificationChannel[] { + private async loadChannelsForRule( + ruleId: number, + ): Promise { try { - const db = getDb(); - const rows = db.$client - .prepare( - `SELECT nc.id, nc.type, nc.config, nc.enabled - FROM notification_channels nc - JOIN alert_rule_channels arc ON arc.channel_id = nc.id - WHERE arc.rule_id = ? AND nc.enabled = 1`, - ) - .all(ruleId) as RawChannel[]; - return rows.map((r) => ({ - id: r.id, - type: r.type, - config: r.config, - enabled: r.enabled === 1, - })); + return await createCurrentAlertRepository().listEnabledChannelsForRule( + ruleId, + ); } catch { return []; } } - private getHostName(hostId: number): string { + private async getHostName(hostId: number): Promise { try { - const db = getDb(); - const row = db.$client - .prepare("SELECT name, ip FROM ssh_data WHERE id = ?") - .get(hostId) as { name: string | null; ip: string } | undefined; - return row?.name || row?.ip || `Host #${hostId}`; + return ( + (await createCurrentAlertRepository().getHostDisplayName(hostId)) ?? + `Host #${hostId}` + ); } catch { return `Host #${hostId}`; } } } - -function mapRawRule(r: RawRule): AlertRule { - return { - id: r.id, - userId: r.user_id, - hostId: r.host_id, - name: r.name, - enabled: r.enabled === 1, - triggerType: r.trigger_type as AlertTriggerType, - thresholdValue: r.threshold_value, - thresholdDurationSeconds: r.threshold_duration_seconds, - cooldownMinutes: r.cooldown_minutes, - }; -} diff --git a/src/backend/ssh/auth-manager.ts b/src/backend/ssh/auth-manager.ts index 0d79a845..062c765c 100644 --- a/src/backend/ssh/auth-manager.ts +++ b/src/backend/ssh/auth-manager.ts @@ -1,9 +1,6 @@ import type { WebSocket } from "ws"; import { sshLogger, authLogger } from "../utils/logger.js"; -import { getDb } from "../database/db/index.js"; -import { sshCredentials } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; interface ResolvedCredentials { username: string; password?: string; @@ -59,22 +56,13 @@ export class SSHAuthManager { }; if (hostConfig.credentialId) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, hostConfig.credentialId), - eq(sshCredentials.userId, this.context.userId), - ), - ), - "ssh_credentials", - this.context.userId, - ); + const cred = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + hostConfig.credentialId, + this.context.userId, + ); - if (credentials.length > 0) { - const cred = credentials[0]; + if (cred) { resolvedCredentials = { username: (cred.username as string) || hostConfig.username, password: (cred.password as string) || undefined, diff --git a/src/backend/ssh/credential-username.test.ts b/src/backend/ssh/credential-username.test.ts index 992de795..c60d9869 100644 --- a/src/backend/ssh/credential-username.test.ts +++ b/src/backend/ssh/credential-username.test.ts @@ -63,19 +63,11 @@ describe("expandOidcUsername", () => { }); it("expands the placeholder with the user's OIDC identifier", async () => { - vi.doMock("../database/db/index.js", () => ({ - getDb: () => ({ - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => [{ oidcIdentifier: "jdoe" }], - }), - }), - }), + vi.doMock("../database/repositories/current-user-repository.js", () => ({ + createCurrentUserRepository: () => ({ + findById: async () => ({ oidcIdentifier: "jdoe" }), }), })); - vi.doMock("../database/db/schema.js", () => ({ users: {} })); - vi.doMock("drizzle-orm", () => ({ eq: vi.fn() })); const { expandOidcUsername: expand } = await import("./credential-username.js"); @@ -83,19 +75,11 @@ describe("expandOidcUsername", () => { }); it("leaves the placeholder as-is when the user has no OIDC identifier", async () => { - vi.doMock("../database/db/index.js", () => ({ - getDb: () => ({ - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => [{ oidcIdentifier: null }], - }), - }), - }), + vi.doMock("../database/repositories/current-user-repository.js", () => ({ + createCurrentUserRepository: () => ({ + findById: async () => ({ oidcIdentifier: null }), }), })); - vi.doMock("../database/db/schema.js", () => ({ users: {} })); - vi.doMock("drizzle-orm", () => ({ eq: vi.fn() })); const { expandOidcUsername: expand } = await import("./credential-username.js"); @@ -105,8 +89,8 @@ describe("expandOidcUsername", () => { }); it("returns the username unchanged when the DB lookup throws", async () => { - vi.doMock("../database/db/index.js", () => ({ - getDb: () => { + vi.doMock("../database/repositories/current-user-repository.js", () => ({ + createCurrentUserRepository: () => { throw new Error("DB unavailable"); }, })); diff --git a/src/backend/ssh/credential-username.ts b/src/backend/ssh/credential-username.ts index ba100396..e8ac661d 100644 --- a/src/backend/ssh/credential-username.ts +++ b/src/backend/ssh/credential-username.ts @@ -48,18 +48,10 @@ export async function expandOidcUsername( } try { - const { getDb } = await import("../database/db/index.js"); - const { users } = await import("../database/db/schema.js"); - const { eq } = await import("drizzle-orm"); - - const db = getDb(); - const rows = await db - .select({ oidcIdentifier: users.oidcIdentifier }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - - const oidcIdentifier = rows[0]?.oidcIdentifier; + const { createCurrentUserRepository } = + await import("../database/repositories/current-user-repository.js"); + const user = await createCurrentUserRepository().findById(userId); + const oidcIdentifier = user?.oidcIdentifier; if (!oidcIdentifier) return username; return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier); diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts index d845e268..7a619680 100644 --- a/src/backend/ssh/docker-console.ts +++ b/src/backend/ssh/docker-console.ts @@ -2,10 +2,7 @@ import { Client as SSHClient } from "ssh2"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { WebSocketServer, WebSocket } from "ws"; import { AuthManager } from "../utils/auth-manager.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { and, eq } from "drizzle-orm"; -import { getDb } from "../database/db/index.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { systemLogger } from "../utils/logger.js"; import type { SSHHost } from "../../types/index.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; @@ -92,24 +89,17 @@ async function createJumpHostChain( } let currentClient: SSHClient | null = null; + const repository = createCurrentHostResolutionRepository(); for (let i = 0; i < jumpHosts.length; i++) { const jumpHostId = jumpHosts[i].hostId; - const jumpHostData = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); - - if (jumpHostData.length === 0) { + const resolvedJumpHost = await repository.findHostById(jumpHostId, userId); + if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) { throw new Error(`Jump host ${jumpHostId} not found`); } - const jumpHost = jumpHostData[0] as unknown as SSHHost; + const jumpHost = resolvedJumpHost as unknown as SSHHost; if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) { try { jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts); @@ -134,22 +124,12 @@ async function createJumpHostChain( }; if (jumpHost.credentialId) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, jumpHost.credentialId as number), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", + const credential = await repository.findCredentialByIdForUser( + jumpHost.credentialId as number, userId, ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { resolvedCredentials = { password: credential.password as string | undefined, sshKey: (credential.key || credential.privateKey) as diff --git a/src/backend/ssh/file-manager-content-routes.ts b/src/backend/ssh/file-manager-content-routes.ts index 853c17d5..768b277e 100644 --- a/src/backend/ssh/file-manager-content-routes.ts +++ b/src/backend/ssh/file-manager-content-routes.ts @@ -16,6 +16,20 @@ type FileContentRoutesDeps = { verifySessionOwnership: (session: SSHSession, userId: string) => boolean; }; +const getRequiredQueryParam = ( + value: string | string[] | undefined, +): string | undefined => { + if (Array.isArray(value)) return value[0]; + return value; +}; + +const parseByteOffset = (value: string | undefined): number | null => { + if (!value) return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) return null; + return parsed; +}; + export function registerFileContentRoutes( app: Express, { sshSessions, verifySessionOwnership }: FileContentRoutesDeps, @@ -1512,264 +1526,125 @@ export function registerFileContentRoutes( * @openapi * /ssh/file_manager/ssh/uploadFileChunk: * post: - * summary: Upload a single chunk of a large file - * description: Receives one chunk of a large file upload via multipart form data and either creates a new remote file (chunkIndex=0) or appends to the existing file (chunkIndex>0). Used by the client to upload files larger than ~2GB that would otherwise hit browser-side ArrayBuffer limits. + * summary: Upload one raw file chunk + * description: Writes a raw request body to the remote file at the supplied byte offset, allowing browser clients to avoid multipart/FormData 2GB limits. * tags: * - File Manager - * requestBody: - * required: true - * content: - * multipart/form-data: - * schema: - * type: object - * required: - * - sessionId - * - path - * - fileName - * - chunkIndex - * - totalChunks - * - totalSize - * - chunk - * properties: - * sessionId: - * type: string - * path: - * type: string - * fileName: - * type: string - * chunkIndex: - * type: integer - * totalChunks: - * type: integer - * totalSize: - * type: integer - * chunk: - * type: string - * format: binary - * responses: - * 200: - * description: Chunk accepted. When chunkIndex === totalChunks - 1 the full file is complete. - * 400: - * description: Missing required parameters, invalid chunk metadata, or SSH connection not established. - * 403: - * description: Session access denied. - * 500: - * description: Failed to write chunk to remote filesystem. */ app.post( "/ssh/file_manager/ssh/uploadFileChunk", (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - - const contentType = req.headers["content-type"] || ""; - if (!contentType.includes("multipart/form-data")) { - return res - .status(400) - .json({ error: "Expected multipart/form-data request" }); - } - - let sessionId: string | undefined; - let remotePath: string | undefined; - let fileName: string | undefined; - let chunkIndex = -1; - let totalChunks = 0; - let totalSize = 0; - let resolved = false; - let chunkStartTime = Date.now(); - - const bb = Busboy({ - headers: req.headers, - limits: { - fileSize: 32 * 1024 * 1024, - }, - }); - - bb.on("field", (name: string, value: string) => { - if (name === "sessionId") sessionId = value; - else if (name === "path") remotePath = value; - else if (name === "fileName") fileName = value; - else if (name === "chunkIndex") { - const n = Number.parseInt(value, 10); - if (Number.isFinite(n) && n >= 0) chunkIndex = n; - } else if (name === "totalChunks") { - const n = Number.parseInt(value, 10); - if (Number.isFinite(n) && n > 0) totalChunks = n; - } else if (name === "totalSize") { - const n = Number.parseInt(value, 10); - if (Number.isFinite(n) && n >= 0) totalSize = n; - } - }); - - bb.on( - "file", - ( - fieldname: string, - fileStream: NodeJS.ReadableStream, - _info: { filename: string; encoding: string; mimeType: string }, - ) => { - if ( - !sessionId || - !remotePath || - !fileName || - chunkIndex < 0 || - totalChunks <= 0 || - chunkIndex >= totalChunks - ) { - fileStream.resume(); - if (!resolved) { - resolved = true; - res.status(400).json({ - error: - "Missing or invalid chunk metadata (sessionId, path, fileName, chunkIndex, totalChunks required)", - }); - } - return; - } - - const sshConn = sshSessions[sessionId]; - if (!sshConn?.isConnected) { - fileStream.resume(); - if (!resolved) { - resolved = true; - res.status(400).json({ error: "SSH connection not established" }); - } - return; - } - - if (!verifySessionOwnership(sshConn, userId)) { - fileStream.resume(); - if (!resolved) { - resolved = true; - res.status(403).json({ error: "Session access denied" }); - } - return; - } - - sshConn.lastActive = Date.now(); - chunkStartTime = Date.now(); - - const fullPath = remotePath.endsWith("/") - ? remotePath + fileName - : remotePath + "/" + fileName; - - const flags = chunkIndex === 0 ? "w" : "a"; - - fileLogger.info("Chunk upload started", { - operation: "file_upload_chunk_start", - sessionId, - userId, - path: fullPath, - chunkIndex, - totalChunks, - totalSize, - }); - - getSessionSftp(sshConn) - .then((sftp) => { - const writeStream = sftp.createWriteStream(fullPath, { - flags, - }); - - let chunkBytes = 0; - - fileStream.on("data", (data: Buffer) => { - chunkBytes += data.length; - }); - - writeStream.on("error", (err) => { - fileLogger.error( - "SFTP write stream error during chunk upload:", - err, - ); - fileStream.resume(); - if (!resolved) { - resolved = true; - res - .status(500) - .json({ error: `Chunk upload failed: ${err.message}` }); - } - }); - - writeStream.on("finish", () => { - if (resolved) return; - resolved = true; - const isFinalChunk = chunkIndex === totalChunks - 1; - fileLogger.success( - isFinalChunk - ? "Chunked file upload completed" - : "Chunk upload completed", - { - operation: isFinalChunk - ? "file_upload_chunked_complete" - : "file_upload_chunk_complete", - sessionId, - userId, - path: fullPath, - chunkIndex, - totalChunks, - chunkBytes, - totalSize, - duration: Date.now() - chunkStartTime, - }, - ); - res.json({ - message: isFinalChunk - ? "File uploaded successfully" - : "Chunk accepted", - path: fullPath, - chunkIndex, - totalChunks, - chunkBytes, - complete: isFinalChunk, - toast: isFinalChunk - ? { - type: "success", - message: `File uploaded: ${fullPath}`, - } - : undefined, - }); - }); - - fileStream.on("error", (err) => { - fileLogger.error( - "File read stream error during chunk upload:", - err, - ); - writeStream.destroy(); - if (!resolved) { - resolved = true; - res.status(500).json({ - error: `Chunk upload stream error: ${err.message}`, - }); - } - }); - - (fileStream as NodeJS.ReadableStream).pipe( - writeStream as unknown as NodeJS.WritableStream, - ); - }) - .catch((err: Error) => { - fileStream.resume(); - fileLogger.error("SFTP session error during chunk upload:", err); - if (!resolved) { - resolved = true; - res.status(500).json({ error: `SFTP error: ${err.message}` }); - } - }); - }, + const sessionId = getRequiredQueryParam(req.query.sessionId as string); + const remotePath = getRequiredQueryParam(req.query.path as string); + const fileName = getRequiredQueryParam(req.query.fileName as string); + const offset = parseByteOffset( + getRequiredQueryParam(req.query.offset as string), + ); + const totalSize = parseByteOffset( + getRequiredQueryParam(req.query.totalSize as string), ); - bb.on("error", (err: Error) => { - fileLogger.error("Busboy parse error during chunk upload:", err); - if (!resolved) { - resolved = true; - res - .status(500) - .json({ error: `Chunk upload parse error: ${err.message}` }); - } - }); + if (!sessionId || !remotePath || !fileName) { + req.resume(); + return res + .status(400) + .json({ error: "Missing sessionId, path, or fileName" }); + } - req.pipe(bb); + if (offset === null || totalSize === null || offset > totalSize) { + req.resume(); + return res.status(400).json({ error: "Invalid upload offset" }); + } + + const sshConn = sshSessions[sessionId]; + if (!sshConn?.isConnected) { + req.resume(); + return res + .status(400) + .json({ error: "SSH connection not established" }); + } + + if (!verifySessionOwnership(sshConn, userId)) { + req.resume(); + return res.status(403).json({ error: "Session access denied" }); + } + + sshConn.lastActive = Date.now(); + const fullPath = remotePath.endsWith("/") + ? remotePath + fileName + : remotePath + "/" + fileName; + let resolved = false; + let bytesWritten = 0; + const uploadStartTime = Date.now(); + + getSessionSftp(sshConn) + .then((sftp) => { + const writeStream = sftp.createWriteStream(fullPath, { + flags: offset === 0 ? "w" : "r+", + start: offset, + }); + + const fail = (status: number, error: string) => { + if (resolved) return; + resolved = true; + req.unpipe(writeStream as unknown as NodeJS.WritableStream); + writeStream.destroy(); + res.status(status).json({ error }); + }; + + writeStream.on("error", (err) => { + fileLogger.error( + "SFTP write stream error during chunk upload:", + err, + ); + fail(500, `Upload failed: ${err.message}`); + }); + + writeStream.on("finish", () => { + if (resolved) return; + resolved = true; + const nextOffset = offset + bytesWritten; + fileLogger.info("File chunk upload completed", { + operation: "file_upload_chunk_complete", + sessionId, + userId, + path: fullPath, + offset, + bytesWritten, + nextOffset, + totalSize, + duration: Date.now() - uploadStartTime, + }); + res.json({ + message: "File chunk uploaded successfully", + path: fullPath, + offset, + bytesWritten, + nextOffset, + complete: nextOffset >= totalSize, + }); + }); + + req.on("error", (err) => { + fileLogger.error("Request stream error during chunk upload:", err); + fail(500, `Upload stream error: ${err.message}`); + }); + + req.on("data", (chunk: Buffer) => { + bytesWritten += chunk.length; + }); + + req.pipe(writeStream as unknown as NodeJS.WritableStream); + }) + .catch((err: Error) => { + req.resume(); + fileLogger.error("SFTP session error during chunk upload:", err); + if (!resolved) { + resolved = true; + res.status(500).json({ error: `SFTP error: ${err.message}` }); + } + }); }, ); } diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 96610d10..b9929f5f 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -4,11 +4,8 @@ import cookieParser from "cookie-parser"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { getDb } from "../database/db/index.js"; -import { hosts } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { fileLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; import { @@ -1178,18 +1175,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { if (hostId && userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), - "ssh_data", - userId, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === userId && host.name + ? host.name : `${username}@${ip}:${port}`; const authManager = AuthManager.getInstance(); @@ -1877,23 +1871,15 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { if (session.hostId && session.userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, session.hostId!), - eq(hosts.userId, session.userId!), - ), - ), - "ssh_data", - session.userId!, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + session.hostId!, + session.userId!, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === session.userId && host.name + ? host.name : `${session.username}@${session.ip}:${session.port}`; const authManager = AuthManager.getInstance(); @@ -2084,23 +2070,15 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => { if (session.hostId && session.userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, session.hostId!), - eq(hosts.userId, session.userId!), - ), - ), - "ssh_data", - session.userId!, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + session.hostId!, + session.userId!, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === session.userId && host.name + ? host.name : `${session.username}@${session.ip}:${session.port}`; await axios.post( diff --git a/src/backend/ssh/host-key-verifier.ts b/src/backend/ssh/host-key-verifier.ts index baf20a95..7eb64443 100644 --- a/src/backend/ssh/host-key-verifier.ts +++ b/src/backend/ssh/host-key-verifier.ts @@ -1,7 +1,5 @@ import type { WebSocket } from "ws"; -import { db } from "../database/db/index.js"; -import { hosts } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { sshLogger } from "../utils/logger.js"; interface HostKeyVerificationData { @@ -36,17 +34,9 @@ export class SSHHostKeyVerifier { } | null> { if (!hostId) return null; try { - const host = await db.query.hosts.findFirst({ - where: eq(hosts.id, hostId), - columns: { - hostKeyFingerprint: true, - hostKeyType: true, - hostKeyAlgorithm: true, - hostKeyChangedCount: true, - name: true, - }, - }); - return host ?? null; + return await createCurrentHostResolutionRepository().findHostKeyVerificationData( + hostId, + ); } catch { return null; } @@ -89,7 +79,9 @@ export class SSHHostKeyVerifier { const host = preloadedHost !== undefined ? preloadedHost - : await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) }); + : await createCurrentHostResolutionRepository().findHostKeyVerificationData( + hostId, + ); if (!host) { sshLogger.warn( @@ -189,9 +181,8 @@ export class SSHHostKeyVerifier { // Verify first, then update the timestamp asynchronously so the // DB write doesn't delay the SSH key exchange critical path. verify(true); - db.update(hosts) - .set({ hostKeyLastVerified: new Date().toISOString() }) - .where(eq(hosts.id, hostId)) + createCurrentHostResolutionRepository() + .touchHostKeyLastVerified(hostId) .catch((err) => { sshLogger.error("Failed to update hostKeyLastVerified", err, { operation: "host_key_update_timestamp", @@ -314,16 +305,12 @@ export class SSHHostKeyVerifier { keyType: string, algorithm: string, ): Promise { - await db - .update(hosts) - .set({ - hostKeyFingerprint: fingerprint, - hostKeyType: keyType, - hostKeyAlgorithm: algorithm, - hostKeyFirstSeen: new Date().toISOString(), - hostKeyLastVerified: new Date().toISOString(), - }) - .where(eq(hosts.id, hostId)); + await createCurrentHostResolutionRepository().storeHostKey( + hostId, + fingerprint, + keyType, + algorithm, + ); } private static async updateHostKey( @@ -333,16 +320,13 @@ export class SSHHostKeyVerifier { algorithm: string, currentChangeCount: number, ): Promise { - await db - .update(hosts) - .set({ - hostKeyFingerprint: fingerprint, - hostKeyType: keyType, - hostKeyAlgorithm: algorithm, - hostKeyLastVerified: new Date().toISOString(), - hostKeyChangedCount: currentChangeCount + 1, - }) - .where(eq(hosts.id, hostId)); + await createCurrentHostResolutionRepository().updateHostKey( + hostId, + fingerprint, + keyType, + algorithm, + currentChangeCount, + ); } private static async promptUserForNewKey( diff --git a/src/backend/ssh/host-metrics-history-routes.ts b/src/backend/ssh/host-metrics-history-routes.ts index 05caddef..5a014afc 100644 --- a/src/backend/ssh/host-metrics-history-routes.ts +++ b/src/backend/ssh/host-metrics-history-routes.ts @@ -1,6 +1,6 @@ import type { Express, RequestHandler } from "express"; import type { AuthenticatedRequest } from "../../types/index.js"; -import { getDb } from "../database/db/index.js"; +import { createCurrentHostMetricsHistoryRepository } from "../database/repositories/current-host-metrics-history-repository.js"; import { statsLogger } from "../utils/logger.js"; type HistoryRoutesDeps = { @@ -98,19 +98,24 @@ export function registerHostMetricsHistoryRoutes( fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); } - const db = getDb(); // SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z). // Normalize both sides to that format for reliable string comparison. const toSqlite = (iso: string) => iso.replace("T", " ").replace(/\.\d{3}Z$/, ""); - const rows = db.$client - .prepare( - `SELECT ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes - FROM host_metrics_history - WHERE host_id = ? AND ts >= ? AND ts <= ? - ORDER BY ts ASC`, + const rows = ( + await createCurrentHostMetricsHistoryRepository().listRange( + hostId, + toSqlite(fromTs), + toSqlite(toTs), ) - .all(hostId, toSqlite(fromTs), toSqlite(toTs)); + ).map((row) => ({ + ts: row.ts, + cpu_percent: row.cpuPercent, + mem_percent: row.memPercent, + disk_percent: row.diskPercent, + net_rx_bytes: row.netRxBytes, + net_tx_bytes: row.netTxBytes, + })); res.json({ rows, fromTs, toTs }); } catch (error) { diff --git a/src/backend/ssh/host-metrics-jump-hosts.ts b/src/backend/ssh/host-metrics-jump-hosts.ts index a54403b8..700fa7d2 100644 --- a/src/backend/ssh/host-metrics-jump-hosts.ts +++ b/src/backend/ssh/host-metrics-jump-hosts.ts @@ -1,14 +1,11 @@ import { Client } from "ssh2"; -import { and, eq } from "drizzle-orm"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { statsLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; @@ -38,17 +35,14 @@ async function resolveJumpHost( userId: string, ): Promise { try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const resolvedHost = await repository.findHostById(hostId, userId); - if (hostResults.length === 0) { + if (!resolvedHost) { return null; } - const host = hostResults[0]; + const host = resolvedHost as Record; const ownerId = (host.userId || userId) as string; if (host.credentialId) { @@ -80,22 +74,12 @@ async function resolveJumpHost( } } - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", + const credential = (await repository.findCredentialByIdForUser( + host.credentialId as number, ownerId, - ); + )) as Record | null; - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { return { ...host, password: credential.password as string | undefined, diff --git a/src/backend/ssh/host-metrics-preferences-routes.ts b/src/backend/ssh/host-metrics-preferences-routes.ts index ba9be2e0..16a0e054 100644 --- a/src/backend/ssh/host-metrics-preferences-routes.ts +++ b/src/backend/ssh/host-metrics-preferences-routes.ts @@ -1,6 +1,6 @@ import type { Express, RequestHandler } from "express"; import type { AuthenticatedRequest } from "../../types/index.js"; -import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; +import { createCurrentHostMetricsPreferenceRepository } from "../database/repositories/current-host-metrics-preference-repository.js"; import { statsLogger } from "../utils/logger.js"; import { deriveEnabledWidgets, @@ -96,12 +96,11 @@ export function registerHostMetricsPreferencesRoutes( return res.status(404).json({ error: "Host not found" }); } - const db = getDb(); - const row = db.$client - .prepare( - "SELECT layout FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?", - ) - .get(userId, hostId) as { layout: string } | undefined; + const row = + await createCurrentHostMetricsPreferenceRepository().findByUserAndHost( + userId, + hostId, + ); if (row?.layout) { try { @@ -180,29 +179,15 @@ export function registerHostMetricsPreferencesRoutes( return res.status(400).json({ error: "Invalid layout" }); } - const db = getDb(); const now = new Date().toISOString(); const layoutJson = JSON.stringify(layout); - const existing = db.$client - .prepare( - "SELECT id FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?", - ) - .get(userId, hostId) as { id: number } | undefined; - - if (existing) { - db.$client - .prepare( - "UPDATE host_metrics_preferences SET layout = ?, updated_at = ? WHERE id = ?", - ) - .run(layoutJson, now, existing.id); - } else { - db.$client - .prepare( - "INSERT INTO host_metrics_preferences (user_id, host_id, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", - ) - .run(userId, hostId, layoutJson, now, now); - } + await createCurrentHostMetricsPreferenceRepository().upsertLayout( + userId, + hostId, + layoutJson, + now, + ); // Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the // user owns. stats_config is plain JSON text (not an encrypted field). @@ -213,11 +198,11 @@ export function registerHostMetricsPreferencesRoutes( ...current, enabledWidgets: deriveEnabledWidgets(layout.slots), }; - db.$client - .prepare( - "UPDATE ssh_data SET stats_config = ? WHERE id = ? AND user_id = ?", - ) - .run(JSON.stringify(merged), hostId, userId); + await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig( + userId, + hostId, + JSON.stringify(merged), + ); } catch (syncErr) { statsLogger.warn("Failed to sync enabledWidgets from layout", { operation: "host_metrics_prefs_sync_widgets", @@ -228,7 +213,6 @@ export function registerHostMetricsPreferencesRoutes( } } - DatabaseSaveTrigger.triggerSave("host_metrics_preferences_updated"); return res.json({ success: true }); } catch (error) { statsLogger.error("Failed to save host metrics preferences", { diff --git a/src/backend/ssh/host-metrics-settings-routes.ts b/src/backend/ssh/host-metrics-settings-routes.ts index 453485e9..ad5a789e 100644 --- a/src/backend/ssh/host-metrics-settings-routes.ts +++ b/src/backend/ssh/host-metrics-settings-routes.ts @@ -1,6 +1,6 @@ import type { Express, RequestHandler } from "express"; -import { getDb } from "../database/db/index.js"; import { statsLogger } from "../utils/logger.js"; +import { createCurrentSettingsRepository } from "../database/repositories/current-settings-repository.js"; type HostMetricsSettingsConfig = { statusCheckInterval: number; @@ -13,6 +13,12 @@ type HostMetricsSettingsRoutesDeps = { refreshAllPolling: () => Promise; }; +function isMissingSettingsTable(error: unknown): boolean { + return ( + error instanceof Error && error.message.includes("no such table: settings") + ); +} + export function registerHostMetricsSettingsRoutes( app: Express, { @@ -36,17 +42,23 @@ export function registerHostMetricsSettingsRoutes( */ app.get("/global-settings", requireAdmin, async (_req, res) => { try { - const db = getDb(); + const settings = createCurrentSettingsRepository(); + const statusValue = await settings.get("global_status_check_interval"); + const metricsValue = await settings.get("global_metrics_interval"); - try { - db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); - } catch (tableError) { + res.json({ + statusCheckInterval: statusValue + ? parseInt(statusValue, 10) + : DEFAULT_STATS_CONFIG.statusCheckInterval, + metricsInterval: metricsValue + ? parseInt(metricsValue, 10) + : DEFAULT_STATS_CONFIG.metricsInterval, + }); + } catch (error) { + if (isMissingSettingsTable(error)) { statsLogger.warn("Settings table does not exist, using defaults", { operation: "global_settings_table_check", - error: - tableError instanceof Error - ? tableError.message - : String(tableError), + error: error.message, }); return res.json({ statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval, @@ -54,26 +66,6 @@ export function registerHostMetricsSettingsRoutes( }); } - const statusRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_status_check_interval'", - ) - .get() as { value: string } | undefined; - const metricsRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_metrics_interval'", - ) - .get() as { value: string } | undefined; - - res.json({ - statusCheckInterval: statusRow - ? parseInt(statusRow.value, 10) - : DEFAULT_STATS_CONFIG.statusCheckInterval, - metricsInterval: metricsRow - ? parseInt(metricsRow.value, 10) - : DEFAULT_STATS_CONFIG.metricsInterval, - }); - } catch (error) { statsLogger.error("Failed to fetch global settings", { operation: "global_settings_fetch_error", error: error instanceof Error ? error.message : String(error), @@ -133,40 +125,16 @@ export function registerHostMetricsSettingsRoutes( } try { - const db = getDb(); - - try { - db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); - } catch (tableError) { - statsLogger.error( - "Settings table does not exist, cannot save settings", - { - operation: "global_settings_table_check", - error: - tableError instanceof Error - ? tableError.message - : String(tableError), - }, - ); - return res.status(500).json({ - error: - "Database settings table is missing. Please check database initialization.", - }); - } + const settings = createCurrentSettingsRepository(); if (statusCheckInterval !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_status_check_interval', ?)", - ) - .run(String(statusCheckInterval)); + await settings.set( + "global_status_check_interval", + String(statusCheckInterval), + ); } if (metricsInterval !== undefined) { - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_metrics_interval', ?)", - ) - .run(String(metricsInterval)); + await settings.set("global_metrics_interval", String(metricsInterval)); } await refreshAllPolling(); @@ -176,6 +144,20 @@ export function registerHostMetricsSettingsRoutes( message: "Settings updated and polling refreshed", }); } catch (error) { + if (isMissingSettingsTable(error)) { + statsLogger.error( + "Settings table does not exist, cannot save settings", + { + operation: "global_settings_table_check", + error: error.message, + }, + ); + return res.status(500).json({ + error: + "Database settings table is missing. Please check database initialization.", + }); + } + statsLogger.error("Failed to save global settings", { operation: "global_settings_save_error", error: error instanceof Error ? error.message : String(error), @@ -200,15 +182,12 @@ export function registerHostMetricsSettingsRoutes( * 403: * description: Requires admin privileges. */ - app.get("/global-settings/history", requireAdmin, (_req, res) => { + app.get("/global-settings/history", requireAdmin, async (_req, res) => { try { - const db = getDb(); - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'metrics_history_retention_days'", - ) - .get() as { value: string } | undefined; - const days = row ? parseInt(row.value, 10) : 7; + const value = await createCurrentSettingsRepository().get( + "metrics_history_retention_days", + ); + const days = value ? parseInt(value, 10) : 7; res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days }); } catch (error) { statsLogger.error("Failed to fetch history retention setting", { @@ -247,7 +226,7 @@ export function registerHostMetricsSettingsRoutes( * 403: * description: Requires admin privileges. */ - app.post("/global-settings/history", requireAdmin, (req, res) => { + app.post("/global-settings/history", requireAdmin, async (req, res) => { const { metricsHistoryRetentionDays } = req.body; if ( typeof metricsHistoryRetentionDays !== "number" || @@ -259,12 +238,10 @@ export function registerHostMetricsSettingsRoutes( }); } try { - const db = getDb(); - db.$client - .prepare( - "INSERT OR REPLACE INTO settings (key, value) VALUES ('metrics_history_retention_days', ?)", - ) - .run(String(Math.floor(metricsHistoryRetentionDays))); + await createCurrentSettingsRepository().set( + "metrics_history_retention_days", + String(Math.floor(metricsHistoryRetentionDays)), + ); res.json({ success: true }); } catch (error) { statsLogger.error("Failed to save history retention setting", { diff --git a/src/backend/ssh/host-metrics-viewer-routes.ts b/src/backend/ssh/host-metrics-viewer-routes.ts index 505c4a3c..c044833d 100644 --- a/src/backend/ssh/host-metrics-viewer-routes.ts +++ b/src/backend/ssh/host-metrics-viewer-routes.ts @@ -1,7 +1,7 @@ import type { Express } from "express"; import type { AuthenticatedRequest } from "../../types/index.js"; import { statsLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { DataCrypto } from "../utils/data-crypto.js"; type ViewerStatsConfig = { metricsEnabled: boolean; @@ -70,7 +70,7 @@ export function registerHostMetricsViewerRoutes< const { viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -129,7 +129,7 @@ export function registerHostMetricsViewerRoutes< const { hostId } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -259,7 +259,7 @@ export function registerHostMetricsViewerRoutes< const { hostId, viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", diff --git a/src/backend/ssh/host-metrics.ts b/src/backend/ssh/host-metrics.ts index b3b1511d..967ca427 100644 --- a/src/backend/ssh/host-metrics.ts +++ b/src/backend/ssh/host-metrics.ts @@ -8,11 +8,11 @@ import { pickResolvedPassword, pickResolvedUsername, } from "./credential-username.js"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; +import { getCurrentSettingValue } from "../database/repositories/current-settings-repository.js"; +import { createCurrentHostMetricsHistoryRepository } from "../database/repositories/current-host-metrics-history-repository.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { statsLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { DataCrypto } from "../utils/data-crypto.js"; import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; @@ -27,7 +27,6 @@ import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; -import { collectTemperatureMetrics } from "./widgets/temperature-collector.js"; import { createSocks5Connection, type SOCKS5Config, @@ -51,7 +50,6 @@ import { supportsMetrics, tcpPingThroughJumpHost, } from "./host-metrics-helpers.js"; -import { applyAgentAuth } from "./terminal-auth-helpers.js"; import { cleanupMetricsSession, getSessionKey, @@ -143,7 +141,6 @@ const DEFAULT_STATS_CONFIG: StatsConfig = { "processes", "ports", "firewall", - "temperature", ], statusCheckEnabled: true, statusCheckInterval: 60, @@ -262,26 +259,18 @@ class PollingManager { metricsInterval: number; } { try { - const db = getDb(); - const statusRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_status_check_interval'", - ) - .get() as { value: string } | undefined; - const metricsRow = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'global_metrics_interval'", - ) - .get() as { value: string } | undefined; + const statusValue = getCurrentSettingValue( + "global_status_check_interval", + ); + const metricsValue = getCurrentSettingValue("global_metrics_interval"); return { - statusCheckInterval: statusRow - ? parseInt(statusRow.value, 10) || + statusCheckInterval: statusValue + ? parseInt(statusValue, 10) || DEFAULT_STATS_CONFIG.statusCheckInterval : DEFAULT_STATS_CONFIG.statusCheckInterval, - metricsInterval: metricsRow - ? parseInt(metricsRow.value, 10) || - DEFAULT_STATS_CONFIG.metricsInterval + metricsInterval: metricsValue + ? parseInt(metricsValue, 10) || DEFAULT_STATS_CONFIG.metricsInterval : DEFAULT_STATS_CONFIG.metricsInterval, }; } catch { @@ -387,8 +376,6 @@ class PollingManager { viewerUserId, }; - this.pollingConfigs.set(host.id, config); - if (isTcpPingEnabled(statsConfig)) { const intervalMs = this.intervalWithJitter( statsConfig.statusCheckInterval * 1000, @@ -446,6 +433,8 @@ class PollingManager { } else { this.metricsStore.delete(host.id); } + + this.pollingConfigs.set(host.id, config); } private async pollHostStatus( @@ -556,7 +545,7 @@ class PollingManager { data: metrics, timestamp: Date.now(), }); - this.insertMetricsHistory(refreshedHost.id, metrics); + await this.insertMetricsHistory(refreshedHost.id, metrics); AlertEngine.getInstance() .evaluateMetrics(refreshedHost.id, metrics) .catch(() => {}); @@ -600,20 +589,15 @@ class PollingManager { private getRetentionDays(): number { try { - const db = getDb(); - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'metrics_history_retention_days'", - ) - .get() as { value: string } | undefined; - const days = row ? parseInt(row.value, 10) : 7; + const value = getCurrentSettingValue("metrics_history_retention_days"); + const days = value ? parseInt(value, 10) : 7; return isNaN(days) || days < 1 ? 7 : Math.min(days, 90); } catch { return 7; } } - private insertMetricsHistory( + private async insertMetricsHistory( hostId: number, metrics: { cpu: { percent: number | null }; @@ -623,34 +607,24 @@ class PollingManager { interfaces: Array<{ rxBytes: string | null; txBytes: string | null }>; }; }, - ): void { + ): Promise { try { - const db = getDb(); const iface = metrics.network?.interfaces?.[0]; const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null; const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null; - db.$client - .prepare( - `INSERT INTO host_metrics_history - (host_id, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes) - VALUES (?, ?, ?, ?, ?, ?)`, - ) - .run( - hostId, - metrics.cpu?.percent ?? null, - metrics.memory?.percent ?? null, - metrics.disk?.percent ?? null, - isNaN(rxRaw as number) ? null : rxRaw, - isNaN(txRaw as number) ? null : txRaw, - ); + const repository = createCurrentHostMetricsHistoryRepository(); + await repository.create({ + hostId, + cpuPercent: metrics.cpu?.percent ?? null, + memPercent: metrics.memory?.percent ?? null, + diskPercent: metrics.disk?.percent ?? null, + netRxBytes: isNaN(rxRaw as number) ? null : rxRaw, + netTxBytes: isNaN(txRaw as number) ? null : txRaw, + }); const retentionDays = this.getRetentionDays(); - db.$client - .prepare( - `DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)`, - ) - .run(hostId, `-${retentionDays} days`); + repository.pruneOlderThan(hostId, retentionDays); } catch (err) { statsLogger.warn("Failed to write metrics history", { operation: "insert_metrics_history", @@ -883,11 +857,8 @@ async function fetchAllHosts( userId: string, ): Promise { try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.userId, userId)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const hostResults = await repository.findHostsByUserId(userId); const hostsWithCredentials: SSHHostWithCredentials[] = []; for (const host of hostResults) { @@ -915,7 +886,7 @@ async function fetchHostById( userId: string, ): Promise { try { - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return undefined; } @@ -934,17 +905,13 @@ async function fetchHostById( return undefined; } - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, id)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const host = await repository.findHostById(id, userId); - if (hostResults.length === 0) { + if (!host) { return undefined; } - const host = hostResults[0]; return await resolveHostCredentials(host, userId); } catch (err) { statsLogger.error(`Failed to fetch host ${id}`, err); @@ -972,15 +939,6 @@ async function resolveHostCredentials( : [], pin: !!host.pin, authType: host.authType, - terminalConfig: (() => { - try { - return typeof host.terminalConfig === "string" - ? JSON.parse(host.terminalConfig as string) - : host.terminalConfig || undefined; - } catch { - return undefined; - } - })(), enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, enableFileManager: !!host.enableFileManager, @@ -1060,17 +1018,13 @@ async function resolveHostCredentials( } } } else { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, host.credentialId as number)), - "ssh_credentials", - userId, - ); + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + host.credentialId as number, + userId, + ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { baseHost.credentialId = credential.id; baseHost.authType = credential.authType || @@ -1276,14 +1230,6 @@ async function buildSshConfig( } else { throw new Error(`Credential for host ${host.ip} could not be resolved`); } - } else if (host.authType === "agent") { - const result = await applyAgentAuth( - base as Record, - (host as { terminalConfig?: Record }).terminalConfig, - ); - if ("error" in result) { - throw new Error(result.error); - } } else { throw new Error( `Unsupported authentication type '${host.authType}' for host ${host.ip}`, @@ -1537,14 +1483,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ kernel: string | null; os: string | null; }; - temperature: { - source: "sysfs" | "sensors" | "none"; - highestCelsius: number | null; - sensors: Array<{ - label: string; - celsius: number; - }>; - }; }> { if (!supportsMetrics(host)) { throw new Error("Metrics collection only supported for SSH hosts"); @@ -1575,7 +1513,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ const uptime = await collectUptimeMetrics(client); const processes = await collectProcessesMetrics(client); const system = await collectSystemMetrics(client); - const temperature = await collectTemperatureMetrics(client); let login_stats = { recentLogins: [], @@ -1647,7 +1584,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ uptime, processes, system, - temperature, login_stats, ports, firewall, @@ -1775,7 +1711,7 @@ function tcpPing( app.get("/status", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1823,7 +1759,7 @@ app.get("/status/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1865,7 +1801,7 @@ app.get("/status/:id", validateHostId, async (req, res) => { app.post("/clear-connections", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1893,7 +1829,7 @@ app.post("/clear-connections", requireAdmin, async (req, res) => { app.post("/refresh", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -1937,7 +1873,7 @@ app.post("/host-updated", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -2000,7 +1936,7 @@ app.post("/host-deleted", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -2050,7 +1986,7 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -2113,7 +2049,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { const connectionLogs: Array> = []; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { connectionLogs.push( createConnectionLog("error", "stats_connecting", "Session expired"), ); @@ -2133,20 +2069,6 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { return res.status(404).json({ error: "Host not found", connectionLogs }); } - if (!supportsMetrics(host)) { - connectionLogs.push( - createConnectionLog( - "info", - "stats_connecting", - "Metrics collection is only supported for SSH hosts", - { - connectionType: host.connectionType || "ssh", - }, - ), - ); - return res.json({ success: true, skipped: true, connectionLogs }); - } - connectionLogs.push( createConnectionLog( "info", @@ -2192,11 +2114,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { "Using existing metrics session", ), ); - - const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - pollingManager.registerViewer(host.id, viewerSessionId, userId); - - return res.json({ success: true, viewerSessionId, connectionLogs }); + return res.json({ success: true, connectionLogs }); } const config = await buildSshConfig(host); @@ -2587,7 +2505,7 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { viewerSessionId } = req.body; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", @@ -2659,7 +2577,7 @@ app.post("/metrics/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; const userId = (req as AuthenticatedRequest).userId; - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index dec983db..05b40806 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -1,7 +1,5 @@ -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials, vaultProfiles } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; +import { createCurrentVaultProfileRepository } from "../database/repositories/current-vault-profile-repository.js"; import { logger } from "../utils/logger.js"; import { pickResolvedPassword, @@ -28,17 +26,11 @@ export async function resolveHostById( ); if (!access.hasAccess) return null; - const db = getDb(); + const repository = createCurrentHostResolutionRepository(); + const resolvedHost = await repository.findHostById(hostId, userId); + if (!resolvedHost) return null; - const hostResults = await SimpleDBOps.select( - db.select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); - - if (hostResults.length === 0) return null; - - const host = hostResults[0] as Record; + const host = resolvedHost as Record; // Parse JSON fields if (typeof host.jumpHosts === "string" && host.jumpHosts) { @@ -91,33 +83,16 @@ export async function resolveHostById( // Try user's own override credential first if (userId !== ownerId) { try { - const { hostAccess } = await import("../database/db/schema.js"); - const accessRecords = await db - .select() - .from(hostAccess) - .where( - and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), - ) - .limit(1); - const overrideCredId = accessRecords[0]?.overrideCredentialId as - | number - | null; + const overrideCredId = await repository.findOverrideCredentialId( + hostId, + userId, + ); if (overrideCredId) { - const userCreds = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, overrideCredId), - eq(sshCredentials.userId, userId), - ), - ), - "ssh_credentials", + const cred = (await repository.findCredentialByIdForUser( + overrideCredId, userId, - ); - if (userCreds.length > 0) { - const cred = userCreds[0] as Record; + )) as Record | null; + if (cred) { host.password = cred.password; host.key = cred.key; host.keyPassword = cred.keyPassword; @@ -183,22 +158,12 @@ export async function resolveHostById( return null; } - const credentials = await SimpleDBOps.select( - db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", + const cred = (await repository.findCredentialByIdForUser( + host.credentialId as number, ownerId, - ); + )) as Record | null; - if (credentials.length > 0) { - const cred = credentials[0] as Record; + if (cred) { host.password = pickResolvedPassword(host.password, cred.password); // Prefer the normalised private key; fall back to raw key field host.key = (cred.privateKey || cred.key) as string | null; @@ -232,13 +197,11 @@ export async function resolveHostById( // certificate itself is obtained per-user at connect time via Vault OIDC. if (host.vaultProfileId) { try { - const profiles = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, host.vaultProfileId as number)) - .limit(1); - if (profiles.length > 0) { - (host as Record).vaultProfile = profiles[0]; + const profile = await createCurrentVaultProfileRepository().findById( + host.vaultProfileId as number, + ); + if (profile) { + (host as Record).vaultProfile = profile; host.authType = "vault"; } } catch (e) { diff --git a/src/backend/ssh/jump-host-chain.ts b/src/backend/ssh/jump-host-chain.ts index b8b11b5c..873ffeb5 100644 --- a/src/backend/ssh/jump-host-chain.ts +++ b/src/backend/ssh/jump-host-chain.ts @@ -1,14 +1,11 @@ -import { and, eq } from "drizzle-orm"; import { Client as SSHClient } from "ssh2"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { fileLogger } from "../utils/logger.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; import { applyAgentAuth } from "./terminal-auth-helpers.js"; @@ -38,17 +35,14 @@ async function resolveJumpHost( userId: string, ): Promise { try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const resolvedHost = await repository.findHostById(hostId, userId); - if (hostResults.length === 0) { + if (!resolvedHost) { return null; } - const host = hostResults[0]; + const host = resolvedHost as Record; const ownerId = (host.userId || userId) as string; if (host.credentialId) { @@ -80,22 +74,12 @@ async function resolveJumpHost( } } - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", + const credential = (await repository.findCredentialByIdForUser( + host.credentialId as number, ownerId, - ); + )) as Record | null; - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { return { ...host, password: credential.password as string | undefined, diff --git a/src/backend/ssh/managers/health.ts b/src/backend/ssh/managers/health.ts index f8ded3f5..2aae89fd 100644 --- a/src/backend/ssh/managers/health.ts +++ b/src/backend/ssh/managers/health.ts @@ -2,7 +2,7 @@ import type { Express } from "express"; import type { Client } from "ssh2"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { execCommand } from "../widgets/common-utils.js"; -import { getDb, DatabaseSaveTrigger } from "../../database/db/index.js"; +import { createCurrentHostHealthRepository } from "../../database/repositories/current-host-health-repository.js"; import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { shellSingleQuote } from "./exec-elevated.js"; import { isValidPort } from "./validation.js"; @@ -115,46 +115,20 @@ function recordHistory( userId: string, hostId: number, results: HealthResult[], -): void { - const db = getDb(); - const now = new Date().toISOString(); - const insert = db.$client.prepare( - "INSERT INTO host_health_history (user_id, host_id, check_id, ts, ok, latency_ms, detail) VALUES (?, ?, ?, ?, ?, ?, ?)", - ); - for (const r of results) { - insert.run( - userId, - hostId, - r.checkId, - now, - r.ok ? 1 : 0, - r.latencyMs, - r.detail, - ); - } - // Prune to the most recent HISTORY_KEEP rows per (host, check). - db.$client - .prepare( - `DELETE FROM host_health_history - WHERE id IN ( - SELECT id FROM host_health_history - WHERE user_id = ? AND host_id = ? - AND id NOT IN ( - SELECT id FROM host_health_history - WHERE user_id = ? AND host_id = ? - ORDER BY ts DESC LIMIT ? - ) - )`, - ) - .run(userId, hostId, userId, hostId, HISTORY_KEEP); +): Promise { + return createCurrentHostHealthRepository() + .recordHistory(userId, hostId, results, HISTORY_KEEP) + .then(() => undefined); } -function loadChecks(userId: string, hostId: number): HealthCheck[] { - const row = getDb() - .$client.prepare( - "SELECT checks FROM host_health_checks WHERE user_id = ? AND host_id = ?", - ) - .get(userId, hostId) as { checks: string } | undefined; +async function loadChecks( + userId: string, + hostId: number, +): Promise { + const row = await createCurrentHostHealthRepository().findChecksByUserAndHost( + userId, + hostId, + ); if (!row?.checks) return []; try { const parsed = JSON.parse(row.checks); @@ -177,10 +151,10 @@ export function registerHealthRoutes( "health_get", async (client, host, req) => { const userId = (req as AuthenticatedRequest).userId; - const checks = loadChecks(userId, host.id); + const checks = await loadChecks(userId, host.id); const results = checks.length ? await runChecks(client, checks) : []; if (results.length) { - recordHistory(userId, host.id, results); + await recordHistory(userId, host.id, results); for (const r of results) { AlertEngine.getInstance() .evaluateHealthCheck( @@ -194,11 +168,19 @@ export function registerHealthRoutes( } } - const history = getDb() - .$client.prepare( - "SELECT check_id as checkId, ts, ok, latency_ms as latencyMs, detail FROM host_health_history WHERE user_id = ? AND host_id = ? ORDER BY ts DESC LIMIT 200", + const history = ( + await createCurrentHostHealthRepository().listHistory( + userId, + host.id, + 200, ) - .all(userId, host.id); + ).map((row) => ({ + checkId: row.checkId, + ts: row.ts, + ok: row.ok, + latencyMs: row.latencyMs, + detail: row.detail, + })); return { checks, results, history }; }, ), @@ -226,27 +208,14 @@ export function registerHealthRoutes( intervalSeconds <= 86400 ? Math.round(intervalSeconds) : 300; - const db = getDb(); const now = new Date().toISOString(); - const existing = db.$client - .prepare( - "SELECT id FROM host_health_checks WHERE user_id = ? AND host_id = ?", - ) - .get(userId, host.id) as { id: number } | undefined; - if (existing) { - db.$client - .prepare( - "UPDATE host_health_checks SET checks = ?, interval_seconds = ?, updated_at = ? WHERE id = ?", - ) - .run(JSON.stringify(checks), interval, now, existing.id); - } else { - db.$client - .prepare( - "INSERT INTO host_health_checks (user_id, host_id, checks, interval_seconds, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run(userId, host.id, JSON.stringify(checks), interval, now, now); - } - DatabaseSaveTrigger.triggerSave("host_health_checks_updated"); + await createCurrentHostHealthRepository().upsertChecks( + userId, + host.id, + JSON.stringify(checks), + interval, + now, + ); return { success: true }; }, ), @@ -261,10 +230,10 @@ export function registerHealthRoutes( "health_run", async (client, host, req) => { const userId = (req as AuthenticatedRequest).userId; - const checks = loadChecks(userId, host.id); + const checks = await loadChecks(userId, host.id); const results = await runChecks(client, checks); if (results.length) { - recordHistory(userId, host.id, results); + await recordHistory(userId, host.id, results); for (const r of results) { AlertEngine.getInstance() .evaluateHealthCheck( diff --git a/src/backend/ssh/opkssh-auth.ts b/src/backend/ssh/opkssh-auth.ts index 575b3bf0..c9c8829a 100644 --- a/src/backend/ssh/opkssh-auth.ts +++ b/src/backend/ssh/opkssh-auth.ts @@ -3,9 +3,7 @@ import { randomUUID } from "crypto"; import { WebSocket } from "ws"; import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js"; import { sshLogger } from "../utils/logger.js"; -import { getDb } from "../database/db/index.js"; -import { opksshTokens } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { createCurrentOpksshTokenRepository } from "../database/repositories/current-opkssh-token-repository.js"; import { UserCrypto } from "../utils/user-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { promises as fs } from "fs"; @@ -649,7 +647,6 @@ function handleOPKSSHOutput(requestId: string, output: string): void { async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise { try { - const db = getDb(); const userCrypto = UserCrypto.getInstance(); const expiresAt = new Date(); @@ -675,32 +672,17 @@ async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise { "private_key", ); - await db - .insert(opksshTokens) - .values({ - userId: session.userId, - hostId: session.hostId, - sshCert: encryptedCert, - privateKey: encryptedKey, - email: session.identity.email, - sub: session.identity.sub, - issuer: session.identity.issuer, - audience: session.identity.audience, - expiresAt: expiresAt.toISOString(), - }) - .onConflictDoUpdate({ - target: [opksshTokens.userId, opksshTokens.hostId], - set: { - sshCert: encryptedCert, - privateKey: encryptedKey, - email: session.identity.email, - sub: session.identity.sub, - issuer: session.identity.issuer, - audience: session.identity.audience, - expiresAt: expiresAt.toISOString(), - createdAt: new Date().toISOString(), - }, - }); + await createCurrentOpksshTokenRepository().upsert({ + userId: session.userId, + hostId: session.hostId, + sshCert: encryptedCert, + privateKey: encryptedKey, + email: session.identity.email, + sub: session.identity.sub, + issuer: session.identity.issuer, + audience: session.identity.audience, + expiresAt: expiresAt.toISOString(), + }); session.status = "completed"; session.ws.send( @@ -733,28 +715,17 @@ export async function getOPKSSHToken( hostId: number, ): Promise<{ sshCert: string; privateKey: string } | null> { try { - const db = getDb(); - const token = await db - .select() - .from(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ) - .limit(1); + const repository = createCurrentOpksshTokenRepository(); + const tokenData = await repository.findByUserAndHost(userId, hostId); - if (!token || token.length === 0) { + if (!tokenData) { return null; } - const tokenData = token[0]; const expiresAt = new Date(tokenData.expiresAt); if (expiresAt < new Date()) { - await db - .delete(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await repository.deleteByUserAndHost(userId, hostId); return null; } @@ -778,12 +749,7 @@ export async function getOPKSSHToken( "private_key", ); - await db - .update(opksshTokens) - .set({ lastUsed: new Date().toISOString() }) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await repository.updateLastUsed(userId, hostId); return { sshCert: decryptedCert, @@ -799,12 +765,10 @@ export async function deleteOPKSSHToken( userId: string, hostId: number, ): Promise { - const db = getDb(); - await db - .delete(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await createCurrentOpksshTokenRepository().deleteByUserAndHost( + userId, + hostId, + ); } export async function invalidateOPKSSHToken( @@ -813,12 +777,10 @@ export async function invalidateOPKSSHToken( reason: string, ): Promise { try { - const db = getDb(); - await db - .delete(opksshTokens) - .where( - and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), - ); + await createCurrentOpksshTokenRepository().deleteByUserAndHost( + userId, + hostId, + ); } catch (error) { sshLogger.error(`Failed to invalidate OPKSSH token`, { userId, diff --git a/src/backend/ssh/terminal-agent-auth.test.ts b/src/backend/ssh/terminal-agent-auth.test.ts index 2ac0414d..841feef3 100644 --- a/src/backend/ssh/terminal-agent-auth.test.ts +++ b/src/backend/ssh/terminal-agent-auth.test.ts @@ -6,7 +6,7 @@ vi.mock("fs/promises", () => ({ access: mockAccess, })); -import { applyAgentAuth, resolveAgentSocket } from "./terminal-auth-helpers.js"; +import { resolveAgentSocket } from "./terminal-auth-helpers.js"; describe("resolveAgentSocket", () => { const originalEnv = process.env.SSH_AUTH_SOCK; @@ -63,25 +63,6 @@ describe("resolveAgentSocket", () => { expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.789" }); }); - it("reads agent socket path from serialized terminal config", async () => { - Object.defineProperty(process, "platform", { value: "linux" }); - mockAccess.mockResolvedValue(undefined); - - const result = await resolveAgentSocket( - JSON.stringify({ agentSocketPath: "/tmp/serialized-agent.sock" }), - ); - - expect(result).toEqual({ socketPath: "/tmp/serialized-agent.sock" }); - }); - - it("returns error for invalid serialized terminal config", async () => { - const result = await resolveAgentSocket("{"); - - expect(result).toEqual({ - error: "Invalid terminal configuration for SSH agent auth.", - }); - }); - it("returns error when neither SSH_AUTH_SOCK nor explicit path is set", async () => { const result = await resolveAgentSocket({}); @@ -119,17 +100,4 @@ describe("resolveAgentSocket", () => { }); expect(mockAccess).not.toHaveBeenCalled(); }); - - it("attaches an ssh2 agent to a connect config", async () => { - Object.defineProperty(process, "platform", { value: "linux" }); - mockAccess.mockResolvedValue(undefined); - const config: Record = {}; - - const result = await applyAgentAuth(config, { - agentSocketPath: "/tmp/ssh-agent.sock", - }); - - expect(result).toEqual({ socketPath: "/tmp/ssh-agent.sock" }); - expect(config.agent).toBeDefined(); - }); }); diff --git a/src/backend/ssh/terminal-auth-helpers.ts b/src/backend/ssh/terminal-auth-helpers.ts index e34bc91e..8bbf3cc8 100644 --- a/src/backend/ssh/terminal-auth-helpers.ts +++ b/src/backend/ssh/terminal-auth-helpers.ts @@ -58,20 +58,10 @@ export class MemoryAgent extends BaseAgent { } export async function resolveAgentSocket( - terminalConfig: Record | string | undefined, + terminalConfig: Record | undefined, ): Promise<{ socketPath: string } | { error: string }> { - let parsedConfig: Record | undefined; - if (typeof terminalConfig === "string") { - try { - parsedConfig = JSON.parse(terminalConfig) as Record; - } catch { - return { error: "Invalid terminal configuration for SSH agent auth." }; - } - } else { - parsedConfig = terminalConfig; - } const explicit = ( - parsedConfig?.agentSocketPath as string | undefined + terminalConfig?.agentSocketPath as string | undefined )?.trim(); const resolved = explicit || process.env.SSH_AUTH_SOCK; @@ -97,7 +87,7 @@ export async function resolveAgentSocket( export async function applyAgentAuth( connectConfig: Record, - terminalConfig: Record | string | undefined, + terminalConfig: Record | undefined, ): Promise<{ socketPath: string } | { error: string }> { const result = await resolveAgentSocket(terminalConfig); if ("error" in result) return result; diff --git a/src/backend/ssh/terminal-jump-hosts.ts b/src/backend/ssh/terminal-jump-hosts.ts index 2f99e3a6..66434a96 100644 --- a/src/backend/ssh/terminal-jump-hosts.ts +++ b/src/backend/ssh/terminal-jump-hosts.ts @@ -1,10 +1,7 @@ -import { and, eq } from "drizzle-orm"; import ssh2Pkg, { type Client as SSHClientType } from "ssh2"; -import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { sshLogger } from "../utils/logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { createSocks5Connection, type SOCKS5Config, @@ -45,17 +42,14 @@ async function resolveJumpHost( hostId, }); try { - const hostResults = await SimpleDBOps.select( - getDb().select().from(hosts).where(eq(hosts.id, hostId)), - "ssh_data", - userId, - ); + const repository = createCurrentHostResolutionRepository(); + const resolvedHost = await repository.findHostById(hostId, userId); - if (hostResults.length === 0) { + if (!resolvedHost) { return null; } - const host = hostResults[0]; + const host = resolvedHost as Record; const ownerId = (host.userId || userId) as string; if (host.credentialId) { @@ -87,22 +81,12 @@ async function resolveJumpHost( } } - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, host.credentialId as number), - eq(sshCredentials.userId, ownerId), - ), - ), - "ssh_credentials", + const credential = (await repository.findCredentialByIdForUser( + host.credentialId as number, ownerId, - ); + )) as Record | null; - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { return { ...host, password: credential.password as string | undefined, diff --git a/src/backend/ssh/terminal-session-manager.test.ts b/src/backend/ssh/terminal-session-manager.test.ts index f43270f3..2cbaa72e 100644 --- a/src/backend/ssh/terminal-session-manager.test.ts +++ b/src/backend/ssh/terminal-session-manager.test.ts @@ -1,22 +1,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Stub all external imports before loading the module under test -const mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]); -const mockInsertValues = vi.fn().mockReturnValue({ returning: mockReturning }); -const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues }); +const mockCreate = vi.fn().mockResolvedValue({ id: 1 }); +const mockUpdateEnded = vi.fn().mockResolvedValue(undefined); vi.mock("../database/db/index.js", () => ({ - getDb: () => ({ - $client: { - prepare: () => ({ get: () => undefined }), - }, - insert: mockInsert, - }), + getDb: () => ({}), })); -vi.mock("../database/db/schema.js", () => ({ - sessionRecordings: {}, -})); +vi.mock( + "../database/repositories/current-session-recording-repository.js", + () => ({ + createCurrentSessionRecordingRepository: () => ({ + create: mockCreate, + updateEnded: mockUpdateEnded, + }), + }), +); vi.mock("../utils/logger.js", () => ({ sshLogger: { @@ -60,9 +60,8 @@ describe("TerminalSessionManager - session logging", () => { // Re-apply resolved values after clearAllMocks mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); - mockReturning.mockResolvedValue([{ id: 1 }]); - mockInsertValues.mockReturnValue({ returning: mockReturning }); - mockInsert.mockReturnValue({ values: mockInsertValues }); + mockCreate.mockResolvedValue({ id: 1 }); + mockUpdateEnded.mockResolvedValue(undefined); }); it("createSession stores sessionLoggingEnabled=true by default", () => { @@ -117,8 +116,7 @@ describe("TerminalSessionManager - session logging", () => { sessionManager.destroySession(id); await new Promise((r) => setTimeout(r, 20)); expect(mockWriteFile).toHaveBeenCalledOnce(); - expect(mockInsert).toHaveBeenCalledOnce(); - expect(mockInsertValues).toHaveBeenCalledOnce(); + expect(mockCreate).toHaveBeenCalledOnce(); }); it("does not write log file when buffer is empty", async () => { diff --git a/src/backend/ssh/terminal-session-manager.ts b/src/backend/ssh/terminal-session-manager.ts index d273e952..25af32c1 100644 --- a/src/backend/ssh/terminal-session-manager.ts +++ b/src/backend/ssh/terminal-session-manager.ts @@ -4,8 +4,8 @@ import fs from "fs"; import path from "path"; import { eq } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; -import { getDb } from "../database/db/index.js"; -import { sessionRecordings } from "../database/db/schema.js"; +import { getCurrentSettingValue } from "../database/repositories/current-settings-repository.js"; +import { createCurrentSessionRecordingRepository } from "../database/repositories/current-session-recording-repository.js"; const MAX_BUFFER_BYTES = 512 * 1024; const DATA_DIR = process.env.DATA_DIR ?? "./db/data"; @@ -428,27 +428,24 @@ class TerminalSessionManager { const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000); try { - const db = getDb(); + const repo = createCurrentSessionRecordingRepository(); if (session.recordingId == null) { - const rows = await db - .insert(sessionRecordings) - .values({ - hostId: session.hostId, - userId: session.userId, - startedAt: new Date(session.sessionStartedAt).toISOString(), - endedAt: new Date(endedAt).toISOString(), - duration, - recordingPath: session.recordingPath, - protocol: "ssh", - format: "asciicast", - }) - .returning({ id: sessionRecordings.id }); - session.recordingId = rows[0]?.id ?? null; + const created = await repo.create({ + hostId: session.hostId, + userId: session.userId, + startedAt: new Date(session.sessionStartedAt).toISOString(), + endedAt: new Date(endedAt).toISOString(), + duration, + recordingPath: session.recordingPath, + protocol: "ssh", + format: "asciicast", + }); + session.recordingId = created.id; } else { - await db - .update(sessionRecordings) - .set({ endedAt: new Date(endedAt).toISOString(), duration }) - .where(eq(sessionRecordings.id, session.recordingId)); + await repo.updateEnded(session.recordingId, { + endedAt: new Date(endedAt).toISOString(), + duration, + }); } } catch (err) { sshLogger.warn("Failed to insert session recording row", { @@ -550,14 +547,9 @@ class TerminalSessionManager { private getTimeoutMs(): number { try { - const db = getDb(); - const row = db.$client - .prepare( - "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", - ) - .get() as { value: string } | undefined; - if (row) { - const minutes = parseInt(row.value, 10); + const value = getCurrentSettingValue("terminal_session_timeout_minutes"); + if (value) { + const minutes = parseInt(value, 10); if (!isNaN(minutes) && minutes > 0) { return minutes * 60_000; } diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 0ca34d8a..07a9cf5b 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -7,12 +7,9 @@ import ssh2Pkg, { const { Client, utils: ssh2Utils } = ssh2Pkg; import { buildSSHAlgorithms } from "../utils/ssh-algorithms.js"; import axios from "axios"; -import { getDb } from "../database/db/index.js"; -import { hosts } from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { sshLogger, authLogger } from "../utils/logger.js"; import { logAudit } from "../utils/audit-logger.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import { UserCrypto } from "../utils/user-crypto.js"; import { @@ -30,9 +27,9 @@ import { waitForTmuxSession, } from "./tmux-helper.js"; import { - applyAgentAuth, MemoryAgent, performPortKnocking, + resolveAgentSocket, } from "./terminal-auth-helpers.js"; import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js"; import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; @@ -56,8 +53,6 @@ interface ConnectToHostData { credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; - passwordFallbackOnly?: boolean; - passwordFallbackAttempted?: boolean; jumpHosts?: Array<{ hostId: number }>; useSocks5?: boolean; socks5Host?: string; @@ -76,6 +71,8 @@ interface ConnectToHostData { [key: string]: unknown; }; enableSessionLogging?: boolean; + /** When true, ignore key material and force password auth (fallback path). */ + passwordFallbackOnly?: boolean; }; initialPath?: string; executeCommand?: string; @@ -786,15 +783,14 @@ wss.on("connection", async (ws: WebSocket, req) => { const opksshData = data as { hostId: number }; try { const { startOPKSSHAuth } = await import("./opkssh-auth.js"); - const { getRequestBaseUrl } = + const { getRequestOrigin } = await import("../utils/request-origin.js"); - const db = getDb(); - const hostRow = await db - .select() - .from(hosts) - .where(eq(hosts.id, opksshData.hostId)) - .limit(1); - if (!hostRow || hostRow.length === 0) { + const host = + await createCurrentHostResolutionRepository().findHostById( + opksshData.hostId, + userId, + ); + if (!host) { sshLogger.error( `Host ${opksshData.hostId} not found for OPKSSH auth`, { @@ -812,8 +808,8 @@ wss.on("connection", async (ws: WebSocket, req) => { ); break; } - const hostname = hostRow[0].name || hostRow[0].ip; - const requestOrigin = getRequestBaseUrl(req); + const hostname = host.name || host.ip; + const requestOrigin = getRequestOrigin(req); await startOPKSSHAuth( userId, opksshData.hostId, @@ -904,9 +900,12 @@ wss.on("connection", async (ws: WebSocket, req) => { try { const { loadVaultProfileForHost, startVaultAuth } = await import("./vault-oidc-auth.js"); - const { getRequestBaseUrl } = + const { getRequestOrigin } = await import("../utils/request-origin.js"); - const profile = await loadVaultProfileForHost(vaultData.hostId); + const profile = await loadVaultProfileForHost( + vaultData.hostId, + userId, + ); if (!profile) { ws.send( JSON.stringify({ @@ -917,7 +916,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ); break; } - const requestOrigin = getRequestBaseUrl(req); + const requestOrigin = getRequestOrigin(req); await startVaultAuth( userId, vaultData.hostId, @@ -1109,6 +1108,9 @@ wss.on("connection", async (ws: WebSocket, req) => { isConnecting = true; sshConn = new Client(); + sendLog("dns", "info", `Starting address resolution of ${ip}`); + sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); + const connectionTimeout = setTimeout(() => { if (sshConn && isConnecting && !isConnected) { sshLogger.error("SSH connection timeout", undefined, { @@ -1161,10 +1163,6 @@ wss.on("connection", async (ws: WebSocket, req) => { )) as unknown as typeof resolvedHostData; if (resolvedHostData) { - ip = resolvedHostData.ip || ip; - port = resolvedHostData.port || port; - username = resolvedHostData.username || username; - if ( (!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) && resolvedHostData.jumpHosts && @@ -1228,8 +1226,11 @@ wss.on("connection", async (ws: WebSocket, req) => { if (id && userId && !password && !key) { try { if (resolvedHostData) { + ip = resolvedHostData.ip || ip; + port = resolvedHostData.port || port; + username = resolvedHostData.username || username; resolvedCredentials = { - username, + username: resolvedHostData.username || username, password: resolvedHostData.password, key: resolvedHostData.key, keyPassword: keyPassword || resolvedHostData.keyPassword, @@ -1253,8 +1254,11 @@ wss.on("connection", async (ws: WebSocket, req) => { } else if (credentialId && id && userId) { try { if (resolvedHostData) { + ip = resolvedHostData.ip || ip; + port = resolvedHostData.port || port; + username = resolvedHostData.username || username; resolvedCredentials = { - username, + username: resolvedHostData.username || username, password: resolvedHostData.password, key: resolvedHostData.key, // Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value @@ -1819,23 +1823,15 @@ wss.on("connection", async (ws: WebSocket, req) => { if (id && hostConfig.userId) { (async () => { try { - const hostResults = await SimpleDBOps.select( - getDb() - .select() - .from(hosts) - .where( - and( - eq(hosts.id, id), - eq(hosts.userId, hostConfig.userId!), - ), - ), - "ssh_data", - hostConfig.userId!, - ); + const host = + await createCurrentHostResolutionRepository().findHostById( + id, + hostConfig.userId!, + ); const hostName = - hostResults.length > 0 && hostResults[0].name - ? hostResults[0].name + host?.userId === hostConfig.userId && host.name + ? host.name : `${username}@${ip}:${port}`; await axios.post( @@ -1884,72 +1880,6 @@ wss.on("connection", async (ws: WebSocket, req) => { keyboardInteractiveResponded, }); - const isAuthFailure = - err.message.includes("All configured authentication methods failed") || - err.message.includes("authentication failed") || - err.message.includes("Authentication failed") || - err.message.includes("Permission denied"); - - if ( - resolvedCredentials.authType === "key" && - resolvedCredentials.password && - isAuthFailure && - !hostConfig.passwordFallbackAttempted - ) { - sendLog( - "auth", - "warning", - "SSH key authentication failed; retrying with stored password", - ); - sshLogger.warn("Retrying SSH connection with stored password", { - operation: "terminal_key_password_fallback", - hostId: id, - userId, - }); - if (currentSessionId) { - sessionManager.destroySession(currentSessionId); - currentSessionId = null; - } - cleanupAuthState(connectionTimeout); - sshConn = null; - sshStream = null; - handleConnectToHost({ - ...data, - hostConfig: { - ...hostConfig, - authType: "password", - password: resolvedCredentials.password, - key: undefined, - keyPassword: undefined, - keyType: undefined, - passwordFallbackOnly: true, - passwordFallbackAttempted: true, - }, - }).catch((fallbackError) => { - const fallbackMessage = - fallbackError instanceof Error - ? fallbackError.message - : "Unknown error"; - sshLogger.error( - "Password fallback connection failed", - fallbackError, - { - operation: "terminal_key_password_fallback_error", - hostId: id, - userId, - }, - ); - ws.send( - JSON.stringify({ - type: "error", - message: - "Failed to retry with stored password: " + fallbackMessage, - }), - ); - }); - return; - } - if ( resolvedCredentials.authType === "opkssh" && err.message.includes("All configured authentication methods failed") @@ -2608,14 +2538,15 @@ wss.on("connection", async (ws: WebSocket, req) => { } } else if (resolvedCredentials.authType === "agent") { sendLog("auth", "info", "Using SSH agent authentication"); - const result = await applyAgentAuth( - connectConfig as Record, + const result = await resolveAgentSocket( hostConfig.terminalConfig as Record | undefined, ); if ("error" in result) { ws.send(JSON.stringify({ type: "error", message: result.error })); return; } + const { createAgent } = ssh2Pkg; + connectConfig.agent = createAgent(result.socketPath); sendLog( "auth", "info", diff --git a/src/backend/ssh/tmux-monitor.ts b/src/backend/ssh/tmux-monitor.ts index 9d94945b..d65bf2d3 100644 --- a/src/backend/ssh/tmux-monitor.ts +++ b/src/backend/ssh/tmux-monitor.ts @@ -1,12 +1,11 @@ import express from "express"; import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; -import { eq, and } from "drizzle-orm"; import { createCorsMiddleware } from "../utils/cors-config.js"; import { AuthManager } from "../utils/auth-manager.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; -import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; -import { tmuxSessionTags, users } from "../database/db/schema.js"; +import { DataCrypto } from "../utils/data-crypto.js"; +import { createCurrentTmuxSessionTagRepository } from "../database/repositories/current-tmux-session-tag-repository.js"; +import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js"; import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; import { sshLogger } from "../utils/logger.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; @@ -287,21 +286,10 @@ async function fetchSessionTags( userId: string, hostId: number, ): Promise> { - const rows = await getDb() - .select() - .from(tmuxSessionTags) - .where( - and( - eq(tmuxSessionTags.userId, userId), - eq(tmuxSessionTags.hostId, hostId), - ), - ); - const bySession = new Map(); - for (const row of rows) { - if (!bySession.has(row.sessionName)) bySession.set(row.sessionName, []); - bySession.get(row.sessionName)!.push(row.tag); - } - return bySession; + return createCurrentTmuxSessionTagRepository().listByUserAndHost( + userId, + hostId, + ); } async function collectPaneMetrics( @@ -367,7 +355,7 @@ async function requireHost( res.status(400).json({ error: "Invalid host ID" }); return null; } - if (!SimpleDBOps.isUserDataUnlocked(userId)) { + if (DataCrypto.getUserDataKey(userId) === null) { res.status(401).json({ error: "User data is locked" }); return null; } @@ -422,12 +410,8 @@ async function auditTmuxAction( const { ipAddress, userAgent } = getRequestMeta(req); let username = userId; try { - const actor = await getDb() - .select({ username: users.username }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - username = actor[0]?.username ?? userId; + const actor = await createCurrentUserRepository().findById(userId); + username = actor?.username ?? userId; } catch { // fall back to the raw user id } @@ -639,16 +623,11 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => { ), ), ); - await getDb() - .update(tmuxSessionTags) - .set({ sessionName: newName }) - .where( - and( - eq(tmuxSessionTags.hostId, host.id), - eq(tmuxSessionTags.sessionName, sessionName), - ), - ); - await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); + await createCurrentTmuxSessionTagRepository().renameSessionForHost( + host.id, + sessionName, + newName, + ); sshLogger.info("tmux session renamed", { operation: "tmux_session_rename", hostId: host.id, @@ -691,15 +670,10 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => { tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`), ), ); - await getDb() - .delete(tmuxSessionTags) - .where( - and( - eq(tmuxSessionTags.hostId, host.id), - eq(tmuxSessionTags.sessionName, sessionName), - ), - ); - await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); + await createCurrentTmuxSessionTagRepository().deleteSessionForHost( + host.id, + sessionName, + ); sshLogger.info("tmux session killed", { operation: "tmux_session_kill", hostId: host.id, @@ -936,27 +910,12 @@ app.put("/tmux_monitor/:hostId/tags", async (req, res) => { ].slice(0, 20); try { - const db = getDb(); - await db - .delete(tmuxSessionTags) - .where( - and( - eq(tmuxSessionTags.userId, userId), - eq(tmuxSessionTags.hostId, host.id), - eq(tmuxSessionTags.sessionName, sessionName), - ), - ); - if (cleanTags.length > 0) { - await db.insert(tmuxSessionTags).values( - cleanTags.map((tag) => ({ - userId, - hostId: host.id, - sessionName, - tag, - })), - ); - } - await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated"); + await createCurrentTmuxSessionTagRepository().replaceForUserHostSession( + userId, + host.id, + sessionName, + cleanTags, + ); res.json({ sessionName, tags: cleanTags }); } catch (err) { sshLogger.error( diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 232397f0..528143ff 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -12,9 +12,7 @@ import { WebSocketServer } from "ws"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { ChildProcess } from "child_process"; import axios from "axios"; -import { getDb } from "../database/db/index.js"; -import { sshCredentials } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import type { SSHHost, TunnelConfig, @@ -26,7 +24,6 @@ import { CONNECTION_STATES } from "../../types/index.js"; import { tunnelLogger } from "../utils/logger.js"; import { logAudit } from "../utils/audit-logger.js"; import { SystemCrypto } from "../utils/system-crypto.js"; -import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { DataCrypto } from "../utils/data-crypto.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; import { AuthManager } from "../utils/auth-manager.js"; @@ -1033,21 +1030,14 @@ async function connectSSHTunnel( if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) { try { - const userDataKey = DataCrypto.getUserDataKey( - tunnelConfig.endpointUserId, - ); - if (userDataKey) { - const credentials = await SimpleDBOps.select( - getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)), - "ssh_credentials", - tunnelConfig.endpointUserId, - ); + if (DataCrypto.getUserDataKey(tunnelConfig.endpointUserId) !== null) { + const credential = + await createCurrentHostResolutionRepository().findCredentialByIdForUser( + tunnelConfig.endpointCredentialId, + tunnelConfig.endpointUserId, + ); - if (credentials.length > 0) { - const credential = credentials[0]; + if (credential) { resolvedEndpointCredentials = { password: credential.password as string | undefined, sshKey: (credential.key || credential.privateKey) as diff --git a/src/backend/ssh/vault-oidc-auth.ts b/src/backend/ssh/vault-oidc-auth.ts index 29474d96..95e6241e 100644 --- a/src/backend/ssh/vault-oidc-auth.ts +++ b/src/backend/ssh/vault-oidc-auth.ts @@ -11,9 +11,8 @@ // ephemeral key, cache the cert, and notify the browser to reconnect. import { WebSocket } from "ws"; -import { eq } from "drizzle-orm"; -import { getDb } from "../database/db/index.js"; -import { hosts, vaultProfiles } from "../database/db/schema.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; +import { createCurrentVaultProfileRepository } from "../database/repositories/current-vault-profile-repository.js"; import { sshLogger } from "../utils/logger.js"; import { type VaultProfileConfig, @@ -61,23 +60,20 @@ function rowToProfileConfig(row: Record): VaultProfileConfig { /** Load the Vault profile referenced by a host, or null if not configured. */ export async function loadVaultProfileForHost( hostId: number, + userId: string, ): Promise { - const db = getDb(); - const hostRows = await db - .select() - .from(hosts) - .where(eq(hosts.id, hostId)) - .limit(1); - if (!hostRows.length || hostRows[0].vaultProfileId == null) return null; + const host = await createCurrentHostResolutionRepository().findHostById( + hostId, + userId, + ); + if (!host || host.vaultProfileId == null) return null; - const profileRows = await db - .select() - .from(vaultProfiles) - .where(eq(vaultProfiles.id, hostRows[0].vaultProfileId as number)) - .limit(1); - if (!profileRows.length) return null; + const profile = await createCurrentVaultProfileRepository().findById( + host.vaultProfileId as number, + ); + if (!profile) return null; - return rowToProfileConfig(profileRows[0] as Record); + return rowToProfileConfig(profile as Record); } /** diff --git a/src/backend/ssh/vault-signer-auth.ts b/src/backend/ssh/vault-signer-auth.ts index 457dcd9e..b9b111c7 100644 --- a/src/backend/ssh/vault-signer-auth.ts +++ b/src/backend/ssh/vault-signer-auth.ts @@ -16,9 +16,7 @@ // The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and // is re-exported here so callers have a single import surface. -import { and, eq } from "drizzle-orm"; -import { getDb } from "../database/db/index.js"; -import { vaultTokens } from "../database/db/schema.js"; +import { createCurrentVaultTokenRepository } from "../database/repositories/current-vault-token-repository.js"; import { UserCrypto } from "../utils/user-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { parseCertValidBefore } from "./vault-signer-core.js"; @@ -52,7 +50,6 @@ export async function storeVaultCert( privateKey: string, signedCert: string, ): Promise { - const db = getDb(); const userDataKey = UserCrypto.getInstance().getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); @@ -77,24 +74,13 @@ export async function storeVaultCert( "private_key", ); - await db - .insert(vaultTokens) - .values({ - userId, - profileId, - sshCert: encryptedCert, - privateKey: encryptedKey, - expiresAt, - }) - .onConflictDoUpdate({ - target: [vaultTokens.userId, vaultTokens.profileId], - set: { - sshCert: encryptedCert, - privateKey: encryptedKey, - expiresAt, - createdAt: new Date().toISOString(), - }, - }); + await createCurrentVaultTokenRepository().upsert({ + userId, + profileId, + sshCert: encryptedCert, + privateKey: encryptedKey, + expiresAt, + }); return expiresAt; } @@ -107,17 +93,10 @@ export async function getVaultCert( userId: string, profileId: number, ): Promise<{ privateKey: string; sshCert: string } | null> { - const db = getDb(); - const rows = await db - .select() - .from(vaultTokens) - .where( - and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), - ) - .limit(1); + const repository = createCurrentVaultTokenRepository(); + const row = await repository.findByUserAndProfile(userId, profileId); - if (!rows || rows.length === 0) return null; - const row = rows[0]; + if (!row) return null; const expiresMs = new Date(row.expiresAt).getTime(); if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) { @@ -144,12 +123,7 @@ export async function getVaultCert( "private_key", ); - await db - .update(vaultTokens) - .set({ lastUsed: new Date().toISOString() }) - .where( - and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), - ); + await repository.updateLastUsed(userId, profileId); return { privateKey, sshCert }; } @@ -158,10 +132,8 @@ export async function deleteVaultCert( userId: string, profileId: number, ): Promise { - const db = getDb(); - await db - .delete(vaultTokens) - .where( - and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), - ); + await createCurrentVaultTokenRepository().deleteByUserAndProfile( + userId, + profileId, + ); } diff --git a/src/backend/starter.ts b/src/backend/starter.ts index a9b57fdc..5fe654c5 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from "url"; import { AutoSSLSetup } from "./utils/auto-ssl-setup.js"; import { AuthManager } from "./utils/auth-manager.js"; import { DataCrypto } from "./utils/data-crypto.js"; +import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js"; +import { DatabaseSaveTrigger } from "./utils/database-save-trigger.js"; import { SystemCrypto } from "./utils/system-crypto.js"; import { systemLogger, @@ -95,12 +97,18 @@ import { version: version, }); + const { logRepositoryRolloutConfig } = + await import("./database/repositories/repository-rollout.js"); + logRepositoryRolloutConfig(); + const systemCrypto = SystemCrypto.getInstance(); await systemCrypto.initializeJWTSecret(); await systemCrypto.initializeDatabaseKey(); await systemCrypto.initializeEncryptionKey(); await systemCrypto.initializeInternalAuthToken(); + ensureDatabaseLayerPreupgradeBackup({ dataDir, version }); + await AutoSSLSetup.initialize(); systemLogger.success("SSL setup completed", { operation: "backend_init_ssl", @@ -151,27 +159,18 @@ import { await import("./homepage.js"); // Initialize log level from database settings - const { getDb: getDbForSettings } = await import("./database/db/index.js"); - const settingsDb = getDbForSettings(); - const logLevelRow = settingsDb.$client - .prepare("SELECT value FROM settings WHERE key = 'log_level'") - .get() as { value: string } | undefined; - if (logLevelRow) { - setGlobalLogLevel(logLevelRow.value); - systemLogger.info(`Log level set to: ${logLevelRow.value}`, { + const { getCurrentSettingValue } = + await import("./database/repositories/current-settings-repository.js"); + const logLevel = getCurrentSettingValue("log_level"); + if (logLevel) { + setGlobalLogLevel(logLevel); + systemLogger.info(`Log level set to: ${logLevel}`, { operation: "log_level_init", }); } // Initialize Guacamole server for RDP/VNC/Telnet support - const { getDb: getDbForGuac } = await import("./database/db/index.js"); - const guacDb = getDbForGuac(); - const guacEnabledRow = guacDb.$client - .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") - .get() as { value: string } | undefined; - const guacEnabled = guacEnabledRow - ? guacEnabledRow.value !== "false" - : true; + const guacEnabled = getCurrentSettingValue("guac_enabled") !== "false"; if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) { import("./guacamole/guacamole-server.js") @@ -203,9 +202,7 @@ import { operation: "shutdown", }); try { - const { saveMemoryDatabaseToFile } = - await import("./database/db/index.js"); - await saveMemoryDatabaseToFile(); + await DatabaseSaveTrigger.forceSave("shutdown_explicit_save"); systemLogger.info("Database saved to disk before exit", { operation: "shutdown_db_saved", }); diff --git a/src/backend/utils/audit-logger.test.ts b/src/backend/utils/audit-logger.test.ts index e88862ce..21c78863 100644 --- a/src/backend/utils/audit-logger.test.ts +++ b/src/backend/utils/audit-logger.test.ts @@ -1,37 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("../database/db/index.js", () => ({ - db: { - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }), - $client: { - prepare: vi.fn().mockReturnValue({ - get: vi.fn().mockReturnValue({ count: 0 }), - run: vi.fn(), - }), - }, - }, +const createMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("../database/repositories/current-audit-log-repository.js", () => ({ + createCurrentAuditLogRepository: vi.fn(() => ({ + create: createMock, + })), })); import { logAudit, getRequestMeta } from "./audit-logger.js"; -import { db } from "../database/db/index.js"; - -const mockDb = db as { - insert: ReturnType; - $client: { prepare: ReturnType }; -}; describe("logAudit", () => { beforeEach(() => { vi.clearAllMocks(); - mockDb.insert.mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }); - mockDb.$client.prepare.mockReturnValue({ - get: vi.fn().mockReturnValue({ count: 0 }), - run: vi.fn(), - }); + createMock.mockResolvedValue(undefined); }); it("inserts an audit log entry with all required fields", async () => { @@ -49,9 +31,8 @@ describe("logAudit", () => { await logAudit(params); - expect(mockDb.insert).toHaveBeenCalledOnce(); - const valuesFn = mockDb.insert.mock.results[0].value.values; - expect(valuesFn).toHaveBeenCalledWith( + expect(createMock).toHaveBeenCalledOnce(); + expect(createMock).toHaveBeenCalledWith( expect.objectContaining({ userId: "user-1", username: "alice", @@ -65,9 +46,7 @@ describe("logAudit", () => { }); it("does not throw when insert fails", async () => { - mockDb.insert.mockReturnValue({ - values: vi.fn().mockRejectedValue(new Error("db error")), - }); + createMock.mockRejectedValue(new Error("db error")); await expect( logAudit({ @@ -79,32 +58,6 @@ describe("logAudit", () => { }), ).resolves.toBeUndefined(); }); - - it("triggers pruning when row count exceeds threshold", async () => { - const prepareMock = vi.fn(); - const runMock = vi.fn(); - prepareMock.mockImplementation((sql: string) => { - if (sql.includes("COUNT(*)")) { - return { get: () => ({ count: 10001 }) }; - } - return { run: runMock }; - }); - mockDb.$client.prepare = prepareMock; - - await logAudit({ - userId: "u", - username: "u", - action: "x", - resourceType: "y", - success: true, - }); - - const deleteCalled = prepareMock.mock.calls.some((args: unknown[]) => - String(args[0]).includes("DELETE"), - ); - expect(deleteCalled).toBe(true); - expect(runMock).toHaveBeenCalledWith(1001); - }); }); describe("getRequestMeta", () => { diff --git a/src/backend/utils/audit-logger.ts b/src/backend/utils/audit-logger.ts index 9fe31acb..c78d468f 100644 --- a/src/backend/utils/audit-logger.ts +++ b/src/backend/utils/audit-logger.ts @@ -1,9 +1,5 @@ import type { Request } from "express"; -import { db } from "../database/db/index.js"; -import { auditLogs } from "../database/db/schema.js"; - -const PRUNE_MAX = 10000; -const PRUNE_TARGET = 9000; +import { createCurrentAuditLogRepository } from "../database/repositories/current-audit-log-repository.js"; export interface AuditLogParams { userId: string; @@ -21,7 +17,7 @@ export interface AuditLogParams { export async function logAudit(params: AuditLogParams): Promise { try { - await db.insert(auditLogs).values({ + await createCurrentAuditLogRepository().create({ userId: params.userId, username: params.username, action: params.action, @@ -34,21 +30,6 @@ export async function logAudit(params: AuditLogParams): Promise { success: params.success, errorMessage: params.errorMessage ?? null, }); - - const countResult = db.$client - .prepare("SELECT COUNT(*) as count FROM audit_logs") - .get() as { count: number }; - - if (countResult.count >= PRUNE_MAX) { - const deleteCount = countResult.count - PRUNE_TARGET; - db.$client - .prepare( - `DELETE FROM audit_logs WHERE id IN ( - SELECT id FROM audit_logs ORDER BY timestamp ASC LIMIT ? - )`, - ) - .run(deleteCount); - } } catch { // audit logging must never throw and break the caller } diff --git a/src/backend/utils/auth-manager-oidc-logout.test.ts b/src/backend/utils/auth-manager-oidc-logout.test.ts index e8db7c42..9bab0eaf 100644 --- a/src/backend/utils/auth-manager-oidc-logout.test.ts +++ b/src/backend/utils/auth-manager-oidc-logout.test.ts @@ -17,8 +17,10 @@ vi.mock("../database/db/index.js", async () => { const { drizzle } = await import("drizzle-orm/better-sqlite3"); const sqlite = new Database(":memory:"); mocks.sqlite = sqlite; + const db = drizzle(sqlite); return { - db: drizzle(sqlite), + db, + getDb: () => db, saveMemoryDatabaseToFile: mocks.saveDatabase, }; }); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index d730379d..7a0f6df6 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -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 { 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 { 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 { 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[]> { 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[]> { 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 { 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 { + 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 { 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 { - await db - .delete(trustedDevices) - .where( - and( - eq(trustedDevices.userId, userId), - eq(trustedDevices.deviceFingerprint, deviceFingerprint), - ), - ); + await createCurrentTrustedDeviceRepository().deleteByUserAndFingerprint( + userId, + deviceFingerprint, + ); } } diff --git a/src/backend/utils/credential-system-encryption-migration.test.ts b/src/backend/utils/credential-system-encryption-migration.test.ts new file mode 100644 index 00000000..e35dd03b --- /dev/null +++ b/src/backend/utils/credential-system-encryption-migration.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CredentialSystemEncryptionMigration } from "./credential-system-encryption-migration.js"; +import { DataCrypto } from "./data-crypto.js"; +import { FieldCrypto } from "./field-crypto.js"; +import { SystemCrypto } from "./system-crypto.js"; + +const credentialRepository = { + listMissingSystemEncryptionByUserId: vi.fn(), + updateSystemEncryptionForUser: vi.fn(), +}; + +const sharedCredentialRepository = { + markNeedsReEncryptionByOriginalCredentialId: vi.fn(), +}; + +vi.mock("../database/repositories/current-credential-repository.js", () => ({ + createCurrentCredentialRepository: vi.fn(() => credentialRepository), +})); + +vi.mock( + "../database/repositories/current-shared-credential-repository.js", + () => ({ + createCurrentSharedCredentialRepository: vi.fn( + () => sharedCredentialRepository, + ), + }), +); + +describe("CredentialSystemEncryptionMigration", () => { + beforeEach(() => { + vi.restoreAllMocks(); + credentialRepository.listMissingSystemEncryptionByUserId.mockReset(); + credentialRepository.updateSystemEncryptionForUser.mockReset(); + sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId.mockReset(); + }); + + it("migrates missing credential system-key copies through repositories", async () => { + vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue( + Buffer.from("user-key"), + ); + vi.spyOn( + SystemCrypto.getInstance(), + "getCredentialSharingKey", + ).mockResolvedValue(Buffer.from("system-key")); + vi.spyOn(FieldCrypto, "decryptField").mockImplementation( + (_value, _key, _recordId, fieldName) => `plain-${fieldName}`, + ); + vi.spyOn(FieldCrypto, "encryptField").mockImplementation( + (value, _key, _recordId, fieldName) => `system-${fieldName}-${value}`, + ); + credentialRepository.listMissingSystemEncryptionByUserId.mockResolvedValue([ + { + id: 123, + userId: "user-1", + password: "encrypted-password", + key: "encrypted-key", + keyPassword: "encrypted-key-password", + }, + ]); + + await expect( + new CredentialSystemEncryptionMigration().migrateUserCredentials( + "user-1", + ), + ).resolves.toEqual({ migrated: 1, failed: 0, skipped: 0 }); + + expect( + credentialRepository.listMissingSystemEncryptionByUserId, + ).toHaveBeenCalledWith("user-1"); + expect( + credentialRepository.updateSystemEncryptionForUser, + ).toHaveBeenCalledWith( + "user-1", + 123, + expect.objectContaining({ + systemPassword: "system-password-plain-password", + systemKey: "system-key-plain-key", + systemKeyPassword: "system-key_password-plain-keyPassword", + }), + ); + expect( + sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId, + ).toHaveBeenCalledWith(123); + }); +}); diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts index a749ae29..7443c322 100644 --- a/src/backend/utils/credential-system-encryption-migration.ts +++ b/src/backend/utils/credential-system-encryption-migration.ts @@ -1,6 +1,5 @@ -import { db } from "../database/db/index.js"; -import { sshCredentials, sharedCredentials } from "../database/db/schema.js"; -import { eq, and, or, isNull } from "drizzle-orm"; +import { createCurrentCredentialRepository } from "../database/repositories/current-credential-repository.js"; +import { createCurrentSharedCredentialRepository } from "../database/repositories/current-shared-credential-repository.js"; import { DataCrypto } from "./data-crypto.js"; import { SystemCrypto } from "./system-crypto.js"; import { FieldCrypto } from "./field-crypto.js"; @@ -20,20 +19,12 @@ export class CredentialSystemEncryptionMigration { const systemCrypto = SystemCrypto.getInstance(); const CSKEK = await systemCrypto.getCredentialSharingKey(); + const credentialRepository = createCurrentCredentialRepository(); + const sharedCredentialRepository = + createCurrentSharedCredentialRepository(); - const credentials = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.userId, userId), - or( - isNull(sshCredentials.systemPassword), - isNull(sshCredentials.systemKey), - isNull(sshCredentials.systemKeyPassword), - ), - ), - ); + const credentials = + await credentialRepository.listMissingSystemEncryptionByUserId(userId); let migrated = 0; let failed = 0; @@ -95,20 +86,20 @@ export class CredentialSystemEncryptionMigration { ) : null; - await db - .update(sshCredentials) - .set({ + await credentialRepository.updateSystemEncryptionForUser( + userId, + cred.id, + { systemPassword, systemKey, systemKeyPassword, updatedAt: new Date().toISOString(), - }) - .where(eq(sshCredentials.id, cred.id)); + }, + ); - await db - .update(sharedCredentials) - .set({ needsReEncryption: true }) - .where(eq(sharedCredentials.originalCredentialId, cred.id)); + await sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId( + cred.id, + ); migrated++; } catch (error) { diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index f129b443..bfdaaccb 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -1,20 +1,15 @@ import { FieldCrypto } from "./field-crypto.js"; import { LazyFieldEncryption } from "./lazy-field-encryption.js"; import { UserCrypto } from "./user-crypto.js"; +import { DatabaseSaveTrigger } from "./database-save-trigger.js"; import { databaseLogger } from "./logger.js"; - -interface DatabaseInstance { - prepare: (sql: string) => { - all: (param?: unknown) => DatabaseRecord[]; - get: (param?: unknown) => DatabaseRecord; - run: (...params: unknown[]) => unknown; - }; -} - -interface DatabaseRecord { - id: number | string; - [key: string]: unknown; -} +import { + createCurrentUserEncryptionMigrationStore, + RawSqliteUserEncryptionMigrationStore, + type LegacyDatabaseInstance, + type UserEncryptionMigrationStore, + type UserEncryptionMigrationRecord, +} from "./user-encryption-migration-store.js"; class DataCrypto { private static userCrypto: UserCrypto; @@ -30,14 +25,14 @@ class DataCrypto { userDataKey: Buffer, ): T { const encryptedRecord: Record = { ...record }; - const recordId = record.id || "temp-" + Date.now(); + const recordId = String(record.id || "temp-" + Date.now()); for (const [fieldName, value] of Object.entries(record)) { if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) { encryptedRecord[fieldName] = FieldCrypto.encryptField( value as string, userDataKey, - recordId as string, + recordId, fieldName, ); } @@ -55,14 +50,14 @@ class DataCrypto { if (!record) return record; const decryptedRecord: Record = { ...record }; - const recordId = record.id; + const recordId = String(record.id); for (const [fieldName, value] of Object.entries(record)) { if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) { decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue( value as string, userDataKey, - recordId as string, + recordId, fieldName, ); } @@ -86,7 +81,34 @@ class DataCrypto { static async migrateUserSensitiveFields( userId: string, userDataKey: Buffer, - db: DatabaseInstance, + db: LegacyDatabaseInstance, + ): Promise<{ + migrated: boolean; + migratedTables: string[]; + migratedFieldsCount: number; + }> { + try { + const store = new RawSqliteUserEncryptionMigrationStore(db); + return await this.migrateUserSensitiveFieldsInStore( + userId, + userDataKey, + store, + ); + } catch (error) { + databaseLogger.error("User sensitive fields migration failed", error, { + operation: "user_sensitive_migration_failed", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }); + + return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; + } + } + + private static async migrateUserSensitiveFieldsInStore( + userId: string, + userDataKey: Buffer, + store: UserEncryptionMigrationStore, ): Promise<{ migrated: boolean; migratedTables: string[]; @@ -101,16 +123,14 @@ class DataCrypto { await LazyFieldEncryption.checkUserNeedsMigration( userId, userDataKey, - db, + store, ); if (!needsMigration) { return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; } - const sshDataRecords = db - .prepare("SELECT * FROM ssh_data WHERE user_id = ?") - .all(userId) as DatabaseRecord[]; + const sshDataRecords = store.listHostRecords(userId); for (const record of sshDataRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data"); @@ -123,22 +143,7 @@ class DataCrypto { ); if (needsUpdate) { - const updateQuery = ` - UPDATE ssh_data - SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `; - db.prepare(updateQuery).run( - updatedRecord.password || null, - updatedRecord.key || null, - updatedRecord.key_password || null, - updatedRecord.key_type || null, - updatedRecord.autostart_password || null, - updatedRecord.autostart_key || null, - updatedRecord.autostart_key_password || null, - updatedRecord.sudo_password || null, - record.id, - ); + store.updateHostSensitiveFields(record.id, updatedRecord); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("ssh_data")) { @@ -148,9 +153,7 @@ class DataCrypto { } } - const sshCredentialsRecords = db - .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") - .all(userId) as DatabaseRecord[]; + const sshCredentialsRecords = store.listCredentialRecords(userId); for (const record of sshCredentialsRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials"); @@ -163,20 +166,7 @@ class DataCrypto { ); if (needsUpdate) { - const updateQuery = ` - UPDATE ssh_credentials - SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `; - db.prepare(updateQuery).run( - updatedRecord.password || null, - updatedRecord.key || null, - updatedRecord.key_password || null, - updatedRecord.private_key || null, - updatedRecord.public_key || null, - updatedRecord.key_type || null, - record.id, - ); + store.updateCredentialSensitiveFields(record.id, updatedRecord); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("ssh_credentials")) { @@ -186,9 +176,7 @@ class DataCrypto { } } - const userRecord = db - .prepare("SELECT * FROM users WHERE id = ?") - .get(userId) as DatabaseRecord | undefined; + const userRecord = store.getUserRecord(userId); if (userRecord) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("users"); @@ -201,18 +189,7 @@ class DataCrypto { ); if (needsUpdate) { - const updateQuery = ` - UPDATE users - SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ? - WHERE id = ? - `; - db.prepare(updateQuery).run( - updatedRecord.totp_secret || null, - updatedRecord.totp_backup_codes || null, - updatedRecord.client_secret || null, - updatedRecord.oidc_identifier || null, - userId, - ); + store.updateUserSensitiveFields(userId, updatedRecord); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("users")) { @@ -234,6 +211,29 @@ class DataCrypto { } } + static async migrateCurrentUserSensitiveFields( + userId: string, + userDataKey: Buffer, + ): Promise<{ + migrated: boolean; + migratedTables: string[]; + migratedFieldsCount: number; + }> { + const result = await this.migrateUserSensitiveFieldsInStore( + userId, + userDataKey, + await createCurrentUserEncryptionMigrationStore(), + ); + + if (result.migrated) { + await DatabaseSaveTrigger.forceSave( + "user_sensitive_migration_explicit_save", + ); + } + + return result; + } + static getUserDataKey(userId: string): Buffer | null { return this.userCrypto.getUserDataKey(userId); } @@ -241,7 +241,7 @@ class DataCrypto { static async reencryptUserDataAfterPasswordReset( userId: string, newUserDataKey: Buffer, - db: DatabaseInstance, + db: LegacyDatabaseInstance, ): Promise<{ success: boolean; reencryptedTables: string[]; @@ -256,7 +256,14 @@ class DataCrypto { }; try { - const tablesToReencrypt = [ + const store = new RawSqliteUserEncryptionMigrationStore(db); + type PasswordResetTable = Parameters< + UserEncryptionMigrationStore["updatePasswordResetFields"] + >[0]; + const tablesToReencrypt: Array<{ + table: PasswordResetTable; + fields: string[]; + }> = [ { table: "ssh_data", fields: [ @@ -292,17 +299,19 @@ class DataCrypto { for (const { table, fields } of tablesToReencrypt) { try { - const selectQuery = - table === "users" - ? `SELECT * FROM ${table} WHERE id = ?` - : `SELECT * FROM ${table} WHERE user_id = ?`; - const records = db - .prepare(selectQuery) - .all(userId) as DatabaseRecord[]; + const records = + table === "ssh_data" + ? store.listHostRecords(userId) + : table === "ssh_credentials" + ? store.listCredentialRecords(userId) + : [store.getUserRecord(userId)].filter( + (record): record is UserEncryptionMigrationRecord => + Boolean(record), + ); for (const record of records) { const recordId = record.id.toString(); - const updatedRecord: DatabaseRecord = { ...record }; + const updatedRecord: UserEncryptionMigrationRecord = { ...record }; let needsUpdate = false; for (const fieldName of fields) { @@ -350,19 +359,12 @@ class DataCrypto { (field) => updatedRecord[field] !== record[field], ); if (updateFields.length > 0) { - const setClause = updateFields - .map((f) => `${f} = ?`) - .join(", "); - const updateQuery = - table === "users" - ? `UPDATE ${table} SET ${setClause} WHERE id = ?` - : `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; - const updateValues = updateFields.map( - (field) => updatedRecord[field], + store.updatePasswordResetFields( + table, + record.id, + updateFields, + updatedRecord, ); - updateValues.push(record.id); - - db.prepare(updateQuery).run(...updateValues); if (!result.reencryptedTables.includes(table)) { result.reencryptedTables.push(table); diff --git a/src/backend/utils/database-layer-preupgrade-backup.test.ts b/src/backend/utils/database-layer-preupgrade-backup.test.ts new file mode 100644 index 00000000..bb96ddb7 --- /dev/null +++ b/src/backend/utils/database-layer-preupgrade-backup.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER, + DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX, + DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV, + ensureDatabaseLayerPreupgradeBackup, +} from "./database-layer-preupgrade-backup.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-prebackup-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ensureDatabaseLayerPreupgradeBackup", () => { + it("copies existing database files and writes a marker", () => { + const dataDir = makeTempDir(); + const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted"); + const metadataPath = `${encryptedDbPath}.meta`; + const envPath = path.join(dataDir, ".env"); + fs.writeFileSync(encryptedDbPath, "encrypted-db"); + fs.writeFileSync(metadataPath, '{"version":"v2"}'); + fs.writeFileSync(envPath, "DATABASE_KEY=test-key"); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + version: "2.5.0-test", + now: new Date("2026-06-27T12:00:00.000Z"), + env: { + DATABASE_LAYER_REPOSITORY_ROLLOUT: "settings,users", + } as NodeJS.ProcessEnv, + }); + + expect(result.status).toBe("created"); + expect(result.backupDir).toContain(DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX); + expect( + fs.existsSync( + path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), + ), + ).toBe(true); + expect( + fs.readFileSync( + path.join(result.backupDir!, "db.sqlite.encrypted"), + "utf8", + ), + ).toBe("encrypted-db"); + expect( + fs.readFileSync( + path.join(result.backupDir!, "db.sqlite.encrypted.meta"), + "utf8", + ), + ).toBe('{"version":"v2"}'); + expect(fs.readFileSync(path.join(result.backupDir!, ".env"), "utf8")).toBe( + "DATABASE_KEY=test-key", + ); + + const manifest = JSON.parse( + fs.readFileSync(path.join(result.backupDir!, "manifest.json"), "utf8"), + ) as { sourceVersion: string; rollout: { mode: string } }; + expect(manifest.sourceVersion).toBe("2.5.0-test"); + expect(manifest.rollout.mode).toBe("partial"); + }); + + it("skips when the marker already exists", () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); + fs.writeFileSync( + path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), + "{}", + ); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }); + + expect(result).toMatchObject({ + status: "skipped", + reason: "marker_exists", + }); + expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false); + }); + + it("skips new installs without a database file", () => { + const dataDir = makeTempDir(); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }); + + expect(result).toMatchObject({ + status: "skipped", + reason: "no_database_file", + }); + expect( + fs.existsSync( + path.join(dataDir, DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER), + ), + ).toBe(false); + }); + + it("honors the explicit skip environment variable", () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); + + const result = ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: { + [DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]: "1", + } as NodeJS.ProcessEnv, + }); + + expect(result).toMatchObject({ + status: "skipped", + reason: "skip_env", + }); + expect(fs.existsSync(path.join(dataDir, "backups"))).toBe(false); + }); + + it("fails closed when the backup cannot be written", () => { + const dataDir = makeTempDir(); + fs.writeFileSync(path.join(dataDir, "db.sqlite.encrypted"), "encrypted-db"); + fs.writeFileSync(path.join(dataDir, "backups"), "not-a-directory"); + + expect(() => + ensureDatabaseLayerPreupgradeBackup({ + dataDir, + env: {} as NodeJS.ProcessEnv, + }), + ).toThrow("Failed to create database layer pre-upgrade backup"); + }); +}); diff --git a/src/backend/utils/database-layer-preupgrade-backup.ts b/src/backend/utils/database-layer-preupgrade-backup.ts new file mode 100644 index 00000000..25299b51 --- /dev/null +++ b/src/backend/utils/database-layer-preupgrade-backup.ts @@ -0,0 +1,201 @@ +import fs from "fs"; +import path from "path"; +import { databaseLogger } from "./logger.js"; +import { getRepositoryRolloutStatus } from "../database/repositories/repository-rollout.js"; + +export const DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER = + ".database-layer-preupgrade-backup.json"; +export const DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX = + "pre-database-layer-refactor"; +export const DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV = + "DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP"; +export const DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV = + "DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP"; + +interface PreupgradeBackupOptions { + dataDir?: string; + version?: string; + now?: Date; + env?: NodeJS.ProcessEnv; +} + +interface BackupFileEntry { + source: string; + backup: string; + size: number; +} + +export interface PreupgradeBackupManifest { + reason: "pre-database-layer-refactor"; + createdAt: string; + sourceVersion: string; + dataDir: string; + backupDir: string; + rollout: ReturnType; + files: BackupFileEntry[]; +} + +export interface PreupgradeBackupResult { + status: "created" | "skipped"; + reason: "backup_created" | "skip_env" | "marker_exists" | "no_database_file"; + backupDir?: string; + markerPath: string; +} + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +function timestampForPath(now: Date): string { + return now.toISOString().replace(/[:.]/g, "-"); +} + +function parseKeepCount(env: NodeJS.ProcessEnv): number { + const raw = env[DATABASE_LAYER_PREUPGRADE_BACKUP_KEEP_ENV]; + if (!raw) return 3; + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 1) return 3; + return parsed; +} + +function copyIfExists( + source: string, + backupDir: string, +): BackupFileEntry | null { + if (!fs.existsSync(source)) return null; + + const backup = path.join(backupDir, path.basename(source)); + fs.copyFileSync(source, backup); + + return { + source, + backup, + size: fs.statSync(backup).size, + }; +} + +function cleanupOldBackups(backupsRoot: string, keepCount: number): void { + if (!fs.existsSync(backupsRoot)) return; + + const entries = fs + .readdirSync(backupsRoot, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith(`${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-`), + ) + .map((entry) => { + const fullPath = path.join(backupsRoot, entry.name); + return { + fullPath, + mtimeMs: fs.statSync(fullPath).mtimeMs, + }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + for (const entry of entries.slice(keepCount)) { + fs.rmSync(entry.fullPath, { recursive: true, force: true }); + } +} + +export function ensureDatabaseLayerPreupgradeBackup( + options: PreupgradeBackupOptions = {}, +): PreupgradeBackupResult { + const env = options.env ?? process.env; + const dataDir = path.resolve(options.dataDir ?? env.DATA_DIR ?? "./db/data"); + const markerPath = path.join( + dataDir, + DATABASE_LAYER_PREUPGRADE_BACKUP_MARKER, + ); + + if ( + TRUE_VALUES.has( + env[DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV]?.trim().toLowerCase() ?? + "", + ) + ) { + databaseLogger.warn("Database layer pre-upgrade backup skipped by env", { + operation: "database_layer_preupgrade_backup_skipped", + envKey: DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV, + }); + return { status: "skipped", reason: "skip_env", markerPath }; + } + + if (fs.existsSync(markerPath)) { + return { status: "skipped", reason: "marker_exists", markerPath }; + } + + const encryptedDbPath = path.join(dataDir, "db.sqlite.encrypted"); + const encryptedMetadataPath = `${encryptedDbPath}.meta`; + const plaintextDbPath = path.join(dataDir, "db.sqlite"); + const envPath = path.join(dataDir, ".env"); + const databaseFiles = [ + encryptedDbPath, + encryptedMetadataPath, + plaintextDbPath, + ].filter((file) => fs.existsSync(file)); + + if (databaseFiles.length === 0) { + return { status: "skipped", reason: "no_database_file", markerPath }; + } + + const backupsRoot = path.join(dataDir, "backups"); + const backupDir = path.join( + backupsRoot, + `${DATABASE_LAYER_PREUPGRADE_BACKUP_PREFIX}-${timestampForPath( + options.now ?? new Date(), + )}`, + ); + + try { + fs.mkdirSync(backupDir, { recursive: true }); + + const files = [...databaseFiles, envPath] + .map((source) => copyIfExists(source, backupDir)) + .filter((entry): entry is BackupFileEntry => entry !== null); + + const manifest: PreupgradeBackupManifest = { + reason: "pre-database-layer-refactor", + createdAt: (options.now ?? new Date()).toISOString(), + sourceVersion: options.version ?? env.VERSION ?? "unknown", + dataDir, + backupDir, + rollout: getRepositoryRolloutStatus(env), + files, + }; + + fs.writeFileSync( + path.join(backupDir, "manifest.json"), + JSON.stringify(manifest, null, 2), + ); + fs.writeFileSync(markerPath, JSON.stringify(manifest, null, 2)); + + cleanupOldBackups(backupsRoot, parseKeepCount(env)); + + databaseLogger.info("Database layer pre-upgrade backup created", { + operation: "database_layer_preupgrade_backup_created", + backupDir, + files: files.map((file) => path.basename(file.backup)), + }); + + return { + status: "created", + reason: "backup_created", + backupDir, + markerPath, + }; + } catch (error) { + databaseLogger.error( + "Failed to create database layer pre-upgrade backup", + error, + { + operation: "database_layer_preupgrade_backup_failed", + dataDir, + backupDir, + }, + ); + throw new Error( + `Failed to create database layer pre-upgrade backup. Set ${DATABASE_LAYER_SKIP_PREUPGRADE_BACKUP_ENV}=1 only if you already have a verified external backup.`, + { cause: error }, + ); + } +} diff --git a/src/backend/utils/database-migration.ts b/src/backend/utils/database-migration.ts index 0d35a694..3ddcb86e 100644 --- a/src/backend/utils/database-migration.ts +++ b/src/backend/utils/database-migration.ts @@ -1,8 +1,8 @@ -import Database from "better-sqlite3"; import fs from "fs"; import path from "path"; import { databaseLogger } from "./logger.js"; import { DatabaseFileEncryption } from "./database-file-encryption.js"; +import { LegacySqliteDatabaseCopyStore } from "./legacy-sqlite-database-copy-store.js"; export interface MigrationResult { success: boolean; @@ -124,80 +124,6 @@ export class DatabaseMigration { } } - private async verifyMigration( - originalDb: Database.Database, - memoryDb: Database.Database, - ): Promise { - try { - memoryDb.exec("PRAGMA foreign_keys = OFF"); - - const originalTables = originalDb - .prepare( - ` - SELECT name FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - ORDER BY name - `, - ) - .all() as { name: string }[]; - - const memoryTables = memoryDb - .prepare( - ` - SELECT name FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - ORDER BY name - `, - ) - .all() as { name: string }[]; - - if (originalTables.length !== memoryTables.length) { - databaseLogger.error( - "Table count mismatch during migration verification", - null, - { - operation: "migration_verify_failed", - originalCount: originalTables.length, - memoryCount: memoryTables.length, - }, - ); - return false; - } - - for (const table of originalTables) { - const originalCount = originalDb - .prepare(`SELECT COUNT(*) as count FROM ${table.name}`) - .get() as { count: number }; - const memoryCount = memoryDb - .prepare(`SELECT COUNT(*) as count FROM ${table.name}`) - .get() as { count: number }; - - if (originalCount.count !== memoryCount.count) { - databaseLogger.error( - "Row count mismatch for table during migration verification", - null, - { - operation: "migration_verify_table_failed", - table: table.name, - originalRows: originalCount.count, - memoryRows: memoryCount.count, - }, - ); - return false; - } - } - - memoryDb.exec("PRAGMA foreign_keys = ON"); - - return true; - } catch (error) { - databaseLogger.error("Migration verification failed", error, { - operation: "migration_verify_error", - }); - return false; - } - } - async migrateDatabase(): Promise { const startTime = Date.now(); let backupPath: string | undefined; @@ -207,121 +133,46 @@ export class DatabaseMigration { try { backupPath = this.createBackup(); - const originalDb = new Database(this.unencryptedDbPath, { - readonly: true, + const copyResult = + new LegacySqliteDatabaseCopyStore().copyDatabaseToMemoryBuffer( + this.unencryptedDbPath, + ); + migratedTables = copyResult.migratedTables; + migratedRows = copyResult.migratedRows; + + await DatabaseFileEncryption.encryptDatabaseFromBuffer( + copyResult.buffer, + this.encryptedDbPath, + ); + + if ( + !DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath) + ) { + throw new Error("Encrypted database file verification failed"); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`; + + fs.renameSync(this.unencryptedDbPath, migratedPath); + + databaseLogger.success("Database migration completed successfully", { + operation: "migration_complete", + migratedTables, + migratedRows, + duration: Date.now() - startTime, + backupPath, + migratedPath, + encryptedDbPath: this.encryptedDbPath, }); - const memoryDb = new Database(":memory:"); - - try { - const tables = originalDb - .prepare( - ` - SELECT name, sql FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - `, - ) - .all() as { name: string; sql: string }[]; - - for (const table of tables) { - memoryDb.exec(table.sql); - migratedTables++; - } - - memoryDb.exec("PRAGMA foreign_keys = OFF"); - - for (const table of tables) { - const rows = originalDb - .prepare(`SELECT * FROM ${table.name}`) - .all() as Record[]; - - if (rows.length > 0) { - const columns = Object.keys(rows[0]); - const placeholders = columns.map(() => "?").join(", "); - const insertStmt = memoryDb.prepare( - `INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`, - ); - - const insertTransaction = memoryDb.transaction( - (dataRows: Record[]) => { - for (const row of dataRows) { - const values = columns.map((col) => row[col]); - insertStmt.run(values); - } - }, - ); - - insertTransaction(rows); - migratedRows += rows.length; - } - } - - memoryDb.exec("PRAGMA foreign_keys = ON"); - - const fkCheckResult = memoryDb - .prepare("PRAGMA foreign_key_check") - .all(); - if (fkCheckResult.length > 0) { - databaseLogger.error( - "Foreign key constraints violations detected after migration", - null, - { - operation: "migration_fk_check_failed", - violations: fkCheckResult, - }, - ); - throw new Error( - `Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`, - ); - } - - const verificationPassed = await this.verifyMigration( - originalDb, - memoryDb, - ); - if (!verificationPassed) { - throw new Error("Migration integrity verification failed"); - } - - const buffer = memoryDb.serialize(); - - await DatabaseFileEncryption.encryptDatabaseFromBuffer( - buffer, - this.encryptedDbPath, - ); - - if ( - !DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath) - ) { - throw new Error("Encrypted database file verification failed"); - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`; - - fs.renameSync(this.unencryptedDbPath, migratedPath); - - databaseLogger.success("Database migration completed successfully", { - operation: "migration_complete", - migratedTables, - migratedRows, - duration: Date.now() - startTime, - backupPath, - migratedPath, - encryptedDbPath: this.encryptedDbPath, - }); - - return { - success: true, - migratedTables, - migratedRows, - backupPath, - duration: Date.now() - startTime, - }; - } finally { - originalDb.close(); - memoryDb.close(); - } + return { + success: true, + migratedTables, + migratedRows, + backupPath, + duration: Date.now() - startTime, + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; diff --git a/src/backend/utils/database-save-trigger.test.ts b/src/backend/utils/database-save-trigger.test.ts new file mode 100644 index 00000000..c0c629e1 --- /dev/null +++ b/src/backend/utils/database-save-trigger.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DatabaseSaveTrigger } from "./database-save-trigger.js"; + +describe("DatabaseSaveTrigger", () => { + afterEach(() => { + vi.useRealTimers(); + DatabaseSaveTrigger.cleanup(); + }); + + it("force saves through the initialized save function", async () => { + const save = vi.fn().mockResolvedValue(undefined); + DatabaseSaveTrigger.initialize(save); + + await DatabaseSaveTrigger.forceSave("test_force_save"); + + expect(save).toHaveBeenCalledTimes(1); + expect(DatabaseSaveTrigger.getStatus()).toMatchObject({ + initialized: true, + pendingSave: false, + hasPendingTimeout: false, + }); + }); + + it("debounces dirty saves and marks the database clean after saving", async () => { + vi.useFakeTimers(); + const save = vi.fn().mockResolvedValue(undefined); + DatabaseSaveTrigger.initialize(save); + + await DatabaseSaveTrigger.triggerSave("first"); + await DatabaseSaveTrigger.triggerSave("second"); + + expect(DatabaseSaveTrigger.isDirty).toBe(true); + expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(true); + + await vi.advanceTimersByTimeAsync(2000); + + expect(save).toHaveBeenCalledTimes(1); + expect(DatabaseSaveTrigger.isDirty).toBe(false); + expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false); + }); +}); diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index e831ea9f..0c3d0a33 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -1,13 +1,6 @@ import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; - -interface DatabaseInstance { - prepare: (sql: string) => { - all: (param?: unknown) => unknown[]; - get: (param?: unknown) => unknown; - run: (...params: unknown[]) => unknown; - }; -} +import type { UserEncryptionMigrationStore } from "./user-encryption-migration-store.js"; export class LazyFieldEncryption { private static readonly LEGACY_FIELD_NAME_MAP: Record = { @@ -367,7 +360,7 @@ export class LazyFieldEncryption { static async checkUserNeedsMigration( userId: string, userKEK: Buffer, - db: DatabaseInstance, + store: UserEncryptionMigrationStore, ): Promise<{ needsMigration: boolean; plaintextFields: Array<{ @@ -384,11 +377,7 @@ export class LazyFieldEncryption { let needsMigration = false; try { - const sshHosts = db - .prepare("SELECT * FROM ssh_data WHERE user_id = ?") - .all(userId) as Array< - Record & { id: string | number } - >; + const sshHosts = store.listHostRecords(userId); for (const host of sshHosts) { const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data"); const hostPlaintextFields: string[] = []; @@ -418,11 +407,7 @@ export class LazyFieldEncryption { } } - const sshCredentials = db - .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") - .all(userId) as Array< - Record & { id: string | number } - >; + const sshCredentials = store.listCredentialRecords(userId); for (const credential of sshCredentials) { const sensitiveFields = this.getSensitiveFieldsForTable("ssh_credentials"); @@ -453,16 +438,18 @@ export class LazyFieldEncryption { } } - const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId); + const user = store.getUserRecord(userId); if (user) { const sensitiveFields = this.getSensitiveFieldsForTable("users"); const userPlaintextFields: string[] = []; for (const field of sensitiveFields) { const column = this.propertyToColumn(field); + const value = user[column]; if ( - user[column] && - this.fieldNeedsMigration(user[column], userKEK, userId, field) + typeof value === "string" && + value && + this.fieldNeedsMigration(value, userKEK, userId, field) ) { userPlaintextFields.push(field); needsMigration = true; diff --git a/src/backend/utils/legacy-sqlite-database-copy-store.test.ts b/src/backend/utils/legacy-sqlite-database-copy-store.test.ts new file mode 100644 index 00000000..712f5ee4 --- /dev/null +++ b/src/backend/utils/legacy-sqlite-database-copy-store.test.ts @@ -0,0 +1,71 @@ +import Database from "better-sqlite3"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { LegacySqliteDatabaseCopyStore } from "./legacy-sqlite-database-copy-store.js"; + +describe("LegacySqliteDatabaseCopyStore", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + function createLegacyDatabase(): string { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-legacy-copy-")); + const dbPath = path.join(tempDir, "db.sqlite"); + const db = new Database(dbPath); + + try { + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL + ); + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + INSERT INTO users (id, username) VALUES ('user-1', 'alice'); + INSERT INTO ssh_data (user_id, name) VALUES ('user-1', 'host-1'); + `); + } finally { + db.close(); + } + + return dbPath; + } + + it("copies legacy SQLite schema and rows into a serialized memory buffer", () => { + const dbPath = createLegacyDatabase(); + + const result = + new LegacySqliteDatabaseCopyStore().copyDatabaseToMemoryBuffer(dbPath); + + expect(result.migratedTables).toBe(2); + expect(result.migratedRows).toBe(2); + expect(result.buffer.length).toBeGreaterThan(0); + + const copied = new Database(result.buffer); + try { + expect( + copied.prepare("SELECT username FROM users WHERE id = ?").get("user-1"), + ).toEqual({ username: "alice" }); + expect( + copied + .prepare("SELECT name FROM ssh_data WHERE user_id = ?") + .get("user-1"), + ).toEqual({ name: "host-1" }); + expect(copied.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + } finally { + copied.close(); + } + }); +}); diff --git a/src/backend/utils/legacy-sqlite-database-copy-store.ts b/src/backend/utils/legacy-sqlite-database-copy-store.ts new file mode 100644 index 00000000..b94c9284 --- /dev/null +++ b/src/backend/utils/legacy-sqlite-database-copy-store.ts @@ -0,0 +1,169 @@ +import Database from "better-sqlite3"; +import { databaseLogger } from "./logger.js"; + +interface LegacyTableDefinition { + name: string; + sql: string; +} + +export interface LegacyDatabaseCopyResult { + buffer: Buffer; + migratedTables: number; + migratedRows: number; +} + +export class LegacySqliteDatabaseCopyStore { + copyDatabaseToMemoryBuffer(sourcePath: string): LegacyDatabaseCopyResult { + const originalDb = new Database(sourcePath, { readonly: true }); + const memoryDb = new Database(":memory:"); + + try { + const tables = this.listTableDefinitions(originalDb); + let migratedTables = 0; + let migratedRows = 0; + + for (const table of tables) { + memoryDb.exec(table.sql); + migratedTables++; + } + + memoryDb.exec("PRAGMA foreign_keys = OFF"); + + for (const table of tables) { + migratedRows += this.copyTableRows(originalDb, memoryDb, table.name); + } + + memoryDb.exec("PRAGMA foreign_keys = ON"); + this.assertNoForeignKeyViolations(memoryDb); + this.assertRowCountsMatch(originalDb, memoryDb); + + return { + buffer: memoryDb.serialize(), + migratedTables, + migratedRows, + }; + } finally { + originalDb.close(); + memoryDb.close(); + } + } + + private listTableDefinitions(db: Database.Database): LegacyTableDefinition[] { + return db + .prepare( + ` + SELECT name, sql FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `, + ) + .all() as LegacyTableDefinition[]; + } + + private listTableNames(db: Database.Database): string[] { + return db + .prepare( + ` + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `, + ) + .all() + .map((table) => (table as { name: string }).name); + } + + private copyTableRows( + originalDb: Database.Database, + memoryDb: Database.Database, + tableName: string, + ): number { + const rows = originalDb + .prepare(`SELECT * FROM ${tableName}`) + .all() as Record[]; + + if (rows.length === 0) { + return 0; + } + + const columns = Object.keys(rows[0]); + const placeholders = columns.map(() => "?").join(", "); + const insertStmt = memoryDb.prepare( + `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`, + ); + + const insertTransaction = memoryDb.transaction( + (dataRows: Record[]) => { + for (const row of dataRows) { + insertStmt.run(columns.map((column) => row[column])); + } + }, + ); + + insertTransaction(rows); + return rows.length; + } + + private assertNoForeignKeyViolations(memoryDb: Database.Database): void { + const fkCheckResult = memoryDb.prepare("PRAGMA foreign_key_check").all(); + if (fkCheckResult.length === 0) { + return; + } + + databaseLogger.error( + "Foreign key constraints violations detected after migration", + null, + { + operation: "migration_fk_check_failed", + violations: fkCheckResult, + }, + ); + throw new Error( + `Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`, + ); + } + + private assertRowCountsMatch( + originalDb: Database.Database, + memoryDb: Database.Database, + ): void { + const originalTables = this.listTableNames(originalDb); + const memoryTables = this.listTableNames(memoryDb); + + if (originalTables.length !== memoryTables.length) { + databaseLogger.error( + "Table count mismatch during migration verification", + null, + { + operation: "migration_verify_failed", + originalCount: originalTables.length, + memoryCount: memoryTables.length, + }, + ); + throw new Error("Migration integrity verification failed"); + } + + for (const tableName of originalTables) { + const originalCount = originalDb + .prepare(`SELECT COUNT(*) as count FROM ${tableName}`) + .get() as { count: number }; + const memoryCount = memoryDb + .prepare(`SELECT COUNT(*) as count FROM ${tableName}`) + .get() as { count: number }; + + if (originalCount.count !== memoryCount.count) { + databaseLogger.error( + "Row count mismatch for table during migration verification", + null, + { + operation: "migration_verify_table_failed", + table: tableName, + originalRows: originalCount.count, + memoryRows: memoryCount.count, + }, + ); + throw new Error("Migration integrity verification failed"); + } + } + } +} diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts index bd214958..77090514 100644 --- a/src/backend/utils/permission-manager.ts +++ b/src/backend/utils/permission-manager.ts @@ -1,13 +1,8 @@ import type { Request, Response, NextFunction } from "express"; -import { db } from "../database/db/index.js"; -import { - hostAccess, - roles, - userRoles, - hosts, - users, -} from "../database/db/schema.js"; -import { eq, and, or, isNull, gte, sql } from "drizzle-orm"; +import { createCurrentRbacAccessRepository } from "../database/repositories/current-rbac-access-repository.js"; +import { createCurrentRoleRepository } from "../database/repositories/current-role-repository.js"; +import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js"; +import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js"; import { databaseLogger } from "./logger.js"; interface AuthenticatedRequest extends Request { @@ -65,15 +60,7 @@ class PermissionManager { private async cleanupExpiredAccess(): Promise { try { - const now = new Date().toISOString(); - await db - .delete(hostAccess) - .where( - and( - sql`${hostAccess.expiresAt} IS NOT NULL`, - sql`${hostAccess.expiresAt} <= ${now}`, - ), - ); + await createCurrentRbacAccessRepository().deleteExpiredHostAccess(); } catch (error) { databaseLogger.error("Failed to cleanup expired host access", error, { operation: "host_access_cleanup_failed", @@ -96,13 +83,8 @@ class PermissionManager { } try { - const userRoleRecords = await db - .select({ - permissions: roles.permissions, - }) - .from(userRoles) - .innerJoin(roles, eq(userRoles.roleId, roles.id)) - .where(eq(userRoles.userId, userId)); + const userRoleRecords = + await createCurrentRoleRepository().listUserRolePermissions(userId); const allPermissions = new Set(); for (const record of userRoleRecords) { @@ -165,13 +147,9 @@ class PermissionManager { action: "read" | "write" | "execute" | "delete" | "share" = "read", ): Promise { try { - const host = await db - .select() - .from(hosts) - .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) - .limit(1); + const hostResolutionRepository = createCurrentHostResolutionRepository(); - if (host.length > 0) { + if (await hostResolutionRepository.isHostOwnedByUser(hostId, userId)) { return { hasAccess: true, isOwner: true, @@ -179,43 +157,20 @@ class PermissionManager { }; } - const userRoleIds = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); - const roleIds = userRoleIds.map((r) => r.roleId); + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); - const now = new Date().toISOString(); - const sharedAccess = await db - .select() - .from(hostAccess) - .where( - and( - eq(hostAccess.hostId, hostId), - or( - eq(hostAccess.userId, userId), - roleIds.length > 0 - ? sql`${hostAccess.roleId} IN (${sql.join( - roleIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`false`, - ), - or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), - ), - ) - .limit(1); + const access = + await createCurrentRbacAccessRepository().findActiveHostAccess( + hostId, + userId, + roleIds, + ); - if (sharedAccess.length > 0) { - const access = sharedAccess[0]; + if (access) { + const ownerId = await hostResolutionRepository.findHostOwnerId(hostId); - const hostOwnerCheck = await db - .select({ ownerId: hosts.userId }) - .from(hosts) - .where(eq(hosts.id, hostId)) - .limit(1); - - if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) { + if (ownerId === userId) { return { hasAccess: true, isOwner: true, @@ -234,12 +189,7 @@ class PermissionManager { } try { - await db - .update(hostAccess) - .set({ - lastAccessedAt: now, - }) - .where(eq(hostAccess.id, access.id)); + await createCurrentRbacAccessRepository().touchHostAccess(access.id); } catch (error) { databaseLogger.warn("Failed to update host access timestamp", { operation: "update_host_access_timestamp", @@ -278,28 +228,16 @@ class PermissionManager { async isAdmin(userId: string): Promise { try { - const user = await db - .select({ isAdmin: users.isAdmin }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); + const user = await createCurrentUserRepository().findById(userId); - if (user.length > 0 && user[0].isAdmin) { + if (user?.isAdmin) { return true; } - const adminRoles = await db - .select({ roleName: roles.name }) - .from(userRoles) - .innerJoin(roles, eq(userRoles.roleId, roles.id)) - .where( - and( - eq(userRoles.userId, userId), - or(eq(roles.name, "admin"), eq(roles.name, "super_admin")), - ), - ); - - return adminRoles.length > 0; + return createCurrentRoleRepository().userHasAnyRoleName(userId, [ + "admin", + "super_admin", + ]); } catch (error) { databaseLogger.error("Failed to check admin status", error, { operation: "is_admin", diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts index a000ecda..978f178f 100644 --- a/src/backend/utils/shared-credential-manager.ts +++ b/src/backend/utils/shared-credential-manager.ts @@ -1,12 +1,8 @@ -import { db } from "../database/db/index.js"; -import { - sharedCredentials, - sshCredentials, - hostAccess, - userRoles, - hosts, -} from "../database/db/schema.js"; -import { eq, and } from "drizzle-orm"; +import { createCurrentCredentialRepository } from "../database/repositories/current-credential-repository.js"; +import { createCurrentRbacAccessRepository } from "../database/repositories/current-rbac-access-repository.js"; +import { createCurrentRoleRepository } from "../database/repositories/current-role-repository.js"; +import { createCurrentSharedCredentialRepository } from "../database/repositories/current-shared-credential-repository.js"; +import type { SharedCredentialRecord } from "../database/repositories/shared-credential-repository.js"; import { DataCrypto } from "./data-crypto.js"; import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; @@ -39,18 +35,15 @@ class SharedCredentialManager { ownerId: string, ): Promise { try { - const existing = await db - .select({ id: sharedCredentials.id }) - .from(sharedCredentials) - .where( - and( - eq(sharedCredentials.hostAccessId, hostAccessId), - eq(sharedCredentials.targetUserId, targetUserId), - ), - ) - .limit(1); + const sharedCredentialRepository = + createCurrentSharedCredentialRepository(); + const existing = + await sharedCredentialRepository.existsForHostAccessAndTargetUser( + hostAccessId, + targetUserId, + ); - if (existing.length > 0) return; + if (existing) return; const ownerDEK = DataCrypto.getUserDataKey(ownerId); @@ -78,7 +71,7 @@ class SharedCredentialManager { hostAccessId, ); - await db.insert(sharedCredentials).values({ + await sharedCredentialRepository.create({ hostAccessId, originalCredentialId, targetUserId, @@ -106,7 +99,7 @@ class SharedCredentialManager { hostAccessId, ); - await db.insert(sharedCredentials).values({ + await sharedCredentialRepository.create({ hostAccessId, originalCredentialId, targetUserId, @@ -131,12 +124,10 @@ class SharedCredentialManager { ownerId: string, ): Promise { try { - const roleUsers = await db - .select({ userId: userRoles.userId }) - .from(userRoles) - .where(eq(userRoles.roleId, roleId)); + const roleUserIds = + await createCurrentRoleRepository().listRoleUserIds(roleId); - for (const { userId } of roleUsers) { + for (const userId of roleUserIds) { try { await this.createSharedCredentialForUser( hostAccessId, @@ -176,27 +167,26 @@ class SharedCredentialManager { targetUserId: string, ): Promise { try { - const hostsSharedWithRole = await db - .select() - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .where(eq(hostAccess.roleId, roleId)); + const hostsSharedWithRole = + await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( + roleId, + ); - for (const { host_access, ssh_data } of hostsSharedWithRole) { + for (const sharedHost of hostsSharedWithRole) { const activeCredentialId = - ssh_data.credentialId ?? - ssh_data.rdpCredentialId ?? - ssh_data.vncCredentialId ?? - ssh_data.telnetCredentialId; + sharedHost.credentialId ?? + sharedHost.rdpCredentialId ?? + sharedHost.vncCredentialId ?? + sharedHost.telnetCredentialId; if (!activeCredentialId) continue; try { await this.createSharedCredentialForUser( - host_access.id, + sharedHost.hostAccessId, activeCredentialId, targetUserId, - ssh_data.userId, + sharedHost.hostOwnerId, ); } catch (error) { databaseLogger.error( @@ -206,7 +196,7 @@ class SharedCredentialManager { operation: "create_shared_credentials_role_member", roleId, targetUserId, - hostId: ssh_data.id, + hostId: sharedHost.hostId, }, ); } @@ -227,12 +217,10 @@ class SharedCredentialManager { async createSharedCredentialsForUserRoles(userId: string): Promise { try { - const roleRows = await db - .select({ roleId: userRoles.roleId }) - .from(userRoles) - .where(eq(userRoles.userId, userId)); + const roleIds = + await createCurrentRoleRepository().listUserRoleIds(userId); - for (const { roleId } of roleRows) { + for (const roleId of roleIds) { await this.createSharedCredentialsForRoleMember(roleId, userId); } } catch (error) { @@ -258,37 +246,23 @@ class SharedCredentialManager { throw new Error(`User ${userId} data not unlocked`); } - const sharedCred = await db - .select() - .from(sharedCredentials) - .innerJoin( - hostAccess, - eq(sharedCredentials.hostAccessId, hostAccess.id), - ) - .where( - and( - eq(hostAccess.hostId, hostId), - eq(sharedCredentials.targetUserId, userId), - ), - ) - .limit(1); + const cred = + await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser( + hostId, + userId, + ); - if (sharedCred.length === 0) { + if (!cred) { return null; } - const cred = sharedCred[0].shared_credentials; - if (cred.needsReEncryption) { await this.reEncryptSharedCredential(cred.id, userId); - const refreshed = await db - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.id, cred.id)) - .limit(1); + const refreshed = + await createCurrentSharedCredentialRepository().findById(cred.id); - if (refreshed.length === 0 || refreshed[0].needsReEncryption) { + if (!refreshed || refreshed.needsReEncryption) { databaseLogger.warn( "Shared credential needs re-encryption but cannot be accessed yet", { @@ -300,7 +274,7 @@ class SharedCredentialManager { return null; } - return this.decryptSharedCredential(refreshed[0], userDEK); + return this.decryptSharedCredential(refreshed, userDEK); } return this.decryptSharedCredential(cred, userDEK); @@ -319,10 +293,12 @@ class SharedCredentialManager { ownerId: string, ): Promise { try { - const sharedCreds = await db - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.originalCredentialId, credentialId)); + const sharedCredentialRepository = + createCurrentSharedCredentialRepository(); + const sharedCreds = + await sharedCredentialRepository.listByOriginalCredentialId( + credentialId, + ); const ownerDEK = DataCrypto.getUserDataKey(ownerId); let credentialData: CredentialData; @@ -347,10 +323,9 @@ class SharedCredentialManager { error: error instanceof Error ? error.message : "Unknown error", }, ); - await db - .update(sharedCredentials) - .set({ needsReEncryption: true }) - .where(eq(sharedCredentials.originalCredentialId, credentialId)); + await sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId( + credentialId, + ); return; } } @@ -359,10 +334,9 @@ class SharedCredentialManager { const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId); if (!targetDEK) { - await db - .update(sharedCredentials) - .set({ needsReEncryption: true }) - .where(eq(sharedCredentials.id, sharedCred.id)); + await sharedCredentialRepository.updateById(sharedCred.id, { + needsReEncryption: true, + }); continue; } @@ -373,14 +347,11 @@ class SharedCredentialManager { sharedCred.hostAccessId, ); - await db - .update(sharedCredentials) - .set({ - ...encryptedForTarget, - needsReEncryption: false, - updatedAt: new Date().toISOString(), - }) - .where(eq(sharedCredentials.id, sharedCred.id)); + await sharedCredentialRepository.updateById(sharedCred.id, { + ...encryptedForTarget, + needsReEncryption: false, + updatedAt: new Date().toISOString(), + }); } } catch (error) { databaseLogger.error("Failed to update shared credentials", error, { @@ -394,9 +365,9 @@ class SharedCredentialManager { credentialId: number, ): Promise { try { - await db - .delete(sharedCredentials) - .where(eq(sharedCredentials.originalCredentialId, credentialId)); + await createCurrentSharedCredentialRepository().deleteByOriginalCredentialId( + credentialId, + ); } catch (error) { databaseLogger.error("Failed to delete shared credentials", error, { operation: "delete_shared_credentials", @@ -412,14 +383,9 @@ class SharedCredentialManager { return; } - const pendingCreds = await db - .select() - .from(sharedCredentials) - .where( - and( - eq(sharedCredentials.targetUserId, userId), - eq(sharedCredentials.needsReEncryption, true), - ), + const pendingCreds = + await createCurrentSharedCredentialRepository().listPendingByTargetUserId( + userId, ); for (const cred of pendingCreds) { @@ -438,23 +404,15 @@ class SharedCredentialManager { ownerId: string, ownerDEK: Buffer, ): Promise { - const creds = await db - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, ownerId), - ), - ) - .limit(1); + const cred = await createCurrentCredentialRepository().findByIdForUser( + ownerId, + credentialId, + ); - if (creds.length === 0) { + if (!cred) { throw new Error(`Credential ${credentialId} not found`); } - const cred = creds[0]; - return { username: cred.username, authType: cred.authType, @@ -479,18 +437,13 @@ class SharedCredentialManager { private async getDecryptedCredentialViaSystemKey( credentialId: number, ): Promise { - const creds = await db - .select() - .from(sshCredentials) - .where(eq(sshCredentials.id, credentialId)) - .limit(1); + const cred = + await createCurrentCredentialRepository().findById(credentialId); - if (creds.length === 0) { + if (!cred) { throw new Error(`Credential ${credentialId} not found`); } - const cred = creds[0]; - if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) { throw new Error( "Credential not yet migrated for offline sharing. " + @@ -580,7 +533,7 @@ class SharedCredentialManager { } private decryptSharedCredential( - sharedCred: typeof sharedCredentials.$inferSelect, + sharedCred: SharedCredentialRecord, userDEK: Buffer, ): CredentialData { const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`; @@ -649,7 +602,7 @@ class SharedCredentialManager { originalCredentialId: number, targetUserId: string, ): Promise { - await db.insert(sharedCredentials).values({ + await createCurrentSharedCredentialRepository().create({ hostAccessId, originalCredentialId, targetUserId, @@ -670,13 +623,10 @@ class SharedCredentialManager { userId: string, ): Promise { try { - const sharedCred = await db - .select() - .from(sharedCredentials) - .where(eq(sharedCredentials.id, sharedCredId)) - .limit(1); + const cred = + await createCurrentSharedCredentialRepository().findById(sharedCredId); - if (sharedCred.length === 0) { + if (!cred) { databaseLogger.warn("Re-encrypt: shared credential not found", { operation: "reencrypt_not_found", sharedCredId, @@ -684,16 +634,12 @@ class SharedCredentialManager { return; } - const cred = sharedCred[0]; + const ownerId = + await createCurrentRbacAccessRepository().findHostAccessOwnerId( + cred.hostAccessId, + ); - const access = await db - .select() - .from(hostAccess) - .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) - .where(eq(hostAccess.id, cred.hostAccessId)) - .limit(1); - - if (access.length === 0) { + if (!ownerId) { databaseLogger.warn("Re-encrypt: host access not found", { operation: "reencrypt_access_not_found", sharedCredId, @@ -701,8 +647,6 @@ class SharedCredentialManager { return; } - const ownerId = access[0].ssh_data.userId; - const userDEK = DataCrypto.getUserDataKey(userId); if (!userDEK) { databaseLogger.warn("Re-encrypt: user DEK not available", { @@ -747,14 +691,11 @@ class SharedCredentialManager { cred.hostAccessId, ); - await db - .update(sharedCredentials) - .set({ - ...encryptedForTarget, - needsReEncryption: false, - updatedAt: new Date().toISOString(), - }) - .where(eq(sharedCredentials.id, sharedCredId)); + await createCurrentSharedCredentialRepository().updateById(sharedCredId, { + ...encryptedForTarget, + needsReEncryption: false, + updatedAt: new Date().toISOString(), + }); } catch (error) { databaseLogger.error("Failed to re-encrypt shared credential", error, { operation: "reencrypt_shared_credential", diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index a28d78aa..5c106f39 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -1,8 +1,6 @@ import crypto from "crypto"; -import { getDb } from "../database/db/index.js"; -import { settings } from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; import { databaseLogger } from "./logger.js"; +import { createCurrentSettingsRepository } from "../database/repositories/current-settings-repository.js"; import { SystemCrypto } from "./system-crypto.js"; interface KEKSalt { @@ -385,10 +383,6 @@ class UserCrypto { await this.storeKEKSalt(userId, newKekSalt); await this.storeEncryptedDEK(userId, newEncryptedDEK); - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - oldKEK.fill(0); newKEK.fill(0); DEK.fill(0); @@ -422,10 +416,6 @@ class UserCrypto { await this.storeKEKSalt(userId, newKekSalt); await this.storeEncryptedDEK(userId, newEncryptedDEK); - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); - newKEK.fill(0); const session = this.userSessions.get(userId); @@ -472,23 +462,7 @@ class UserCrypto { const key = `user_encrypted_dek_oidc_${userId}`; const value = JSON.stringify(oidcEncryptedDEK); - const { getDb } = await import("../database/db/index.js"); - const { settings } = await import("../database/db/schema.js"); - const { eq } = await import("drizzle-orm"); - - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } + await this.setSetting(key, value); databaseLogger.info( "Converted user encryption to dual-auth (password + OIDC)", @@ -497,10 +471,6 @@ class UserCrypto { userId, }, ); - - const { saveMemoryDatabaseToFile } = - await import("../database/db/index.js"); - await saveMemoryDatabaseToFile(); } catch (error) { databaseLogger.error("Failed to convert to OIDC encryption", error, { operation: "convert_to_oidc_encryption_error", @@ -655,10 +625,7 @@ class UserCrypto { try { const dek = this.decryptDEK(encryptedDEK, legacyKey); const value = JSON.stringify(this.encryptDEK(dek, systemKey)); - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, settingKey)); + await createCurrentSettingsRepository().set(settingKey, value); databaseLogger.info("Migrated legacy system-wrapped user key", { operation: "user_key_wrap_migrated", userId, @@ -704,38 +671,31 @@ class UserCrypto { return decrypted; } + private async setSetting(key: string, value: string): Promise { + await createCurrentSettingsRepository().set(key, value); + } + + private async getSetting(key: string): Promise { + return createCurrentSettingsRepository().get(key); + } + private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise { const key = `user_kek_salt_${userId}`; const value = JSON.stringify(kekSalt); - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } + await this.setSetting(key, value); } private async getKEKSalt(userId: string): Promise { try { const key = `user_kek_salt_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); + const value = await this.getSetting(key); - if (result.length === 0) { + if (value === null) { return null; } - return JSON.parse(result[0].value); + return JSON.parse(value); } catch { return null; } @@ -748,34 +708,19 @@ class UserCrypto { const key = `user_encrypted_dek_${userId}`; const value = JSON.stringify(encryptedDEK); - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } + await this.setSetting(key, value); } private async getEncryptedDEK(userId: string): Promise { try { const key = `user_encrypted_dek_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); + const value = await this.getSetting(key); - if (result.length === 0) { + if (value === null) { return null; } - return JSON.parse(result[0].value); + return JSON.parse(value); } catch { return null; } @@ -786,16 +731,13 @@ class UserCrypto { ): Promise { try { const key = `user_encrypted_dek_oidc_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); + const value = await this.getSetting(key); - if (result.length === 0) { + if (value === null) { return null; } - return JSON.parse(result[0].value); + return JSON.parse(value); } catch { return null; } @@ -807,20 +749,7 @@ class UserCrypto { ): Promise { const key = `user_encrypted_dek_webauthn_${userId}`; const value = JSON.stringify(encryptedDEK); - - const existing = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); - - if (existing.length > 0) { - await getDb() - .update(settings) - .set({ value }) - .where(eq(settings.key, key)); - } else { - await getDb().insert(settings).values({ key, value }); - } + await this.setSetting(key, value); } private async getWebAuthnEncryptedDEK( @@ -828,16 +757,13 @@ class UserCrypto { ): Promise { try { const key = `user_encrypted_dek_webauthn_${userId}`; - const result = await getDb() - .select() - .from(settings) - .where(eq(settings.key, key)); + const value = await this.getSetting(key); - if (result.length === 0) { + if (value === null) { return null; } - return JSON.parse(result[0].value); + return JSON.parse(value); } catch { return null; } diff --git a/src/backend/utils/user-data-export.ts b/src/backend/utils/user-data-export.ts index c0b51f36..1419cdac 100644 --- a/src/backend/utils/user-data-export.ts +++ b/src/backend/utils/user-data-export.ts @@ -1,15 +1,8 @@ -import { getDb } from "../database/db/index.js"; -import { - users, - hosts, - sshCredentials, - fileManagerRecent, - fileManagerPinned, - fileManagerShortcuts, - transferRecent, - dismissedAlerts, -} from "../database/db/schema.js"; -import { eq } from "drizzle-orm"; +import { createCurrentDismissedAlertRepository } from "../database/repositories/current-dismissed-alert-repository.js"; +import { createCurrentFileManagerBookmarkRepository } from "../database/repositories/current-file-manager-bookmark-repository.js"; +import { createCurrentTransferRecentRepository } from "../database/repositories/current-transfer-recent-repository.js"; +import { createCurrentUserDataExportRepository } from "../database/repositories/current-user-data-export-repository.js"; +import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger } from "./logger.js"; @@ -54,16 +47,11 @@ class UserDataExport { } = options; try { - const user = await getDb() - .select() - .from(users) - .where(eq(users.id, userId)); - if (!user || user.length === 0) { + const userRecord = await createCurrentUserRepository().findById(userId); + if (!userRecord) { throw new Error(`User not found: ${userId}`); } - const userRecord = user[0]; - let userDataKey: Buffer | null = null; if (format === "plaintext") { userDataKey = DataCrypto.getUserDataKey(userId); @@ -74,10 +62,8 @@ class UserDataExport { } } - const sshHosts = await getDb() - .select() - .from(hosts) - .where(eq(hosts.userId, userId)); + const exportRepository = createCurrentUserDataExportRepository(); + const sshHosts = await exportRepository.listHostsByUserId(userId); const processedSshHosts = format === "plaintext" && userDataKey ? sshHosts.map((host) => @@ -87,10 +73,8 @@ class UserDataExport { let sshCredentialsData: unknown[] = []; if (includeCredentials) { - const credentials = await getDb() - .select() - .from(sshCredentials) - .where(eq(sshCredentials.userId, userId)); + const credentials = + await exportRepository.listCredentialsByUserId(userId); sshCredentialsData = format === "plaintext" && userDataKey ? credentials.map((cred) => @@ -106,28 +90,20 @@ class UserDataExport { const [recentFiles, pinnedFiles, shortcuts, transferRecentData] = await Promise.all([ - getDb() - .select() - .from(fileManagerRecent) - .where(eq(fileManagerRecent.userId, userId)), - getDb() - .select() - .from(fileManagerPinned) - .where(eq(fileManagerPinned.userId, userId)), - getDb() - .select() - .from(fileManagerShortcuts) - .where(eq(fileManagerShortcuts.userId, userId)), - getDb() - .select() - .from(transferRecent) - .where(eq(transferRecent.userId, userId)), + createCurrentFileManagerBookmarkRepository().listRecentByUserId( + userId, + ), + createCurrentFileManagerBookmarkRepository().listPinnedByUserId( + userId, + ), + createCurrentFileManagerBookmarkRepository().listShortcutsByUserId( + userId, + ), + createCurrentTransferRecentRepository().listByUserId(userId), ]); - const alerts = await getDb() - .select() - .from(dismissedAlerts) - .where(eq(dismissedAlerts.userId, userId)); + const alerts = + await createCurrentDismissedAlertRepository().listByUserId(userId); const exportData: UserExportData = { version: this.EXPORT_VERSION, diff --git a/src/backend/utils/user-encryption-migration-store.test.ts b/src/backend/utils/user-encryption-migration-store.test.ts new file mode 100644 index 00000000..4bab39e4 --- /dev/null +++ b/src/backend/utils/user-encryption-migration-store.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { RawSqliteUserEncryptionMigrationStore } from "./user-encryption-migration-store.js"; + +describe("RawSqliteUserEncryptionMigrationStore", () => { + function createDb() { + const all = vi.fn(() => [{ id: 1 }]); + const get = vi.fn(() => ({ id: "user-1" })); + const run = vi.fn(); + const prepare = vi.fn(() => ({ all, get, run })); + + return { db: { prepare }, all, get, run }; + } + + it("owns legacy user encryption migration reads", () => { + const { db, all, get } = createDb(); + const store = new RawSqliteUserEncryptionMigrationStore(db); + + expect(store.listHostRecords("user-1")).toEqual([{ id: 1 }]); + expect(store.listCredentialRecords("user-1")).toEqual([{ id: 1 }]); + expect(store.getUserRecord("user-1")).toEqual({ id: "user-1" }); + + expect(db.prepare).toHaveBeenCalledWith( + "SELECT * FROM ssh_data WHERE user_id = ?", + ); + expect(db.prepare).toHaveBeenCalledWith( + "SELECT * FROM ssh_credentials WHERE user_id = ?", + ); + expect(db.prepare).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?"); + expect(all).toHaveBeenCalledWith("user-1"); + expect(get).toHaveBeenCalledWith("user-1"); + }); + + it("owns legacy sensitive field update statements", () => { + const { db, run } = createDb(); + const store = new RawSqliteUserEncryptionMigrationStore(db); + + store.updateHostSensitiveFields(12, { + password: "p", + key: "k", + key_password: "kp", + key_type: "ed25519", + autostart_password: "ap", + autostart_key: "ak", + autostart_key_password: "akp", + sudo_password: "sp", + }); + store.updateCredentialSensitiveFields(13, { + password: "p", + key: "k", + key_password: "kp", + private_key: "priv", + public_key: "pub", + key_type: "rsa", + }); + store.updateUserSensitiveFields("user-1", { + totp_secret: "totp", + totp_backup_codes: "codes", + client_secret: "client", + oidc_identifier: "oidc", + }); + + expect(db.prepare).toHaveBeenCalledWith( + expect.stringContaining("UPDATE ssh_data"), + ); + expect(db.prepare).toHaveBeenCalledWith( + expect.stringContaining("UPDATE ssh_credentials"), + ); + expect(db.prepare).toHaveBeenCalledWith( + expect.stringContaining("UPDATE users"), + ); + expect(run).toHaveBeenCalledWith( + "p", + "k", + "kp", + "ed25519", + "ap", + "ak", + "akp", + "sp", + 12, + ); + expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13); + expect(run).toHaveBeenCalledWith( + "totp", + "codes", + "client", + "oidc", + "user-1", + ); + }); + + it("owns password-reset dynamic field updates", () => { + const { db, run } = createDb(); + const store = new RawSqliteUserEncryptionMigrationStore(db); + + store.updatePasswordResetFields( + "ssh_credentials", + 99, + ["password", "key"], + { password: "p", key: "k" }, + ); + + expect(db.prepare).toHaveBeenCalledWith( + "UPDATE ssh_credentials SET password = ?, key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + ); + expect(run).toHaveBeenCalledWith("p", "k", 99); + }); +}); diff --git a/src/backend/utils/user-encryption-migration-store.ts b/src/backend/utils/user-encryption-migration-store.ts new file mode 100644 index 00000000..03c17af8 --- /dev/null +++ b/src/backend/utils/user-encryption-migration-store.ts @@ -0,0 +1,150 @@ +import { getCurrentRepositorySqlite } from "../database/repositories/current-repository-runtime.js"; + +export interface UserEncryptionMigrationRecord { + id: number | string; + [key: string]: unknown; +} + +export interface UserEncryptionMigrationStore { + listHostRecords(userId: string): UserEncryptionMigrationRecord[]; + listCredentialRecords(userId: string): UserEncryptionMigrationRecord[]; + getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined; + updateHostSensitiveFields( + recordId: number | string, + record: Record, + ): void; + updateCredentialSensitiveFields( + recordId: number | string, + record: Record, + ): void; + updateUserSensitiveFields( + userId: string, + record: Record, + ): void; + updatePasswordResetFields( + table: "ssh_data" | "ssh_credentials" | "users", + recordId: number | string, + fields: string[], + record: Record, + ): void; +} + +export interface LegacyDatabaseInstance { + prepare: (sql: string) => { + all: (param?: unknown) => UserEncryptionMigrationRecord[]; + get: (param?: unknown) => UserEncryptionMigrationRecord | undefined; + run: (...params: unknown[]) => unknown; + }; +} + +export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigrationStore { + constructor(private readonly db: LegacyDatabaseInstance) {} + + listHostRecords(userId: string): UserEncryptionMigrationRecord[] { + return this.db + .prepare("SELECT * FROM ssh_data WHERE user_id = ?") + .all(userId); + } + + listCredentialRecords(userId: string): UserEncryptionMigrationRecord[] { + return this.db + .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") + .all(userId); + } + + getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined { + return this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId); + } + + updateHostSensitiveFields( + recordId: number | string, + record: Record, + ): void { + this.db + .prepare( + ` + UPDATE ssh_data + SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + ) + .run( + record.password || null, + record.key || null, + record.key_password || null, + record.key_type || null, + record.autostart_password || null, + record.autostart_key || null, + record.autostart_key_password || null, + record.sudo_password || null, + recordId, + ); + } + + updateCredentialSensitiveFields( + recordId: number | string, + record: Record, + ): void { + this.db + .prepare( + ` + UPDATE ssh_credentials + SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, + ) + .run( + record.password || null, + record.key || null, + record.key_password || null, + record.private_key || null, + record.public_key || null, + record.key_type || null, + recordId, + ); + } + + updateUserSensitiveFields( + userId: string, + record: Record, + ): void { + this.db + .prepare( + ` + UPDATE users + SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ? + WHERE id = ? + `, + ) + .run( + record.totp_secret || null, + record.totp_backup_codes || null, + record.client_secret || null, + record.oidc_identifier || null, + userId, + ); + } + + updatePasswordResetFields( + table: "ssh_data" | "ssh_credentials" | "users", + recordId: number | string, + fields: string[], + record: Record, + ): void { + const setClause = fields.map((field) => `${field} = ?`).join(", "); + const updateQuery = + table === "users" + ? `UPDATE ${table} SET ${setClause} WHERE id = ?` + : `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; + const updateValues = fields.map((field) => record[field]); + updateValues.push(recordId); + + this.db.prepare(updateQuery).run(...updateValues); + } +} + +export async function createCurrentUserEncryptionMigrationStore(): Promise { + return new RawSqliteUserEncryptionMigrationStore( + getCurrentRepositorySqlite(), + ); +}