draft: database layer refactor (#1054)

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

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

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

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

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

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

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

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

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

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

---------

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Bumps the major-updates group with 5 updates:

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


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

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

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

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

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

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

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

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

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

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

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

Two bugs introduced in v2.4.1:

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

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

Fixes #928

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

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

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

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

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

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

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

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

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

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

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

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

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

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

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

* chore: start database layer refactor

* docs: plan database layer refactor

* docs: audit database layer refactor phase zero

* chore: add database runtime adapter skeleton

* chore: add settings repository skeleton

* chore: add user session repository skeleton

* chore: add host credential repository skeleton

* chore: add field encryption boundary

* chore: migrate settings route slice

* chore: migrate user settings routes

* chore: migrate host metrics settings routes

* chore: migrate acme settings route

* chore: migrate terminal settings route

* chore: migrate tailscale settings read

* chore: migrate guacamole settings reads

* chore: migrate session timeout settings reads

* chore: migrate auth route settings reads

* chore: migrate host metrics settings reads

* chore: migrate startup settings reads

* chore: migrate user settings cleanup

* chore: migrate password reset settings

* chore: migrate oidc legacy settings read

* chore: migrate user route settings slice

* chore: migrate oidc state settings

* chore: migrate user login settings reads

* chore: migrate user crypto settings

* chore: consolidate startup settings defaults

* chore: consolidate database settings import export

* chore: migrate core session auth paths

* chore: migrate remaining session auth paths

* chore: migrate admin user routes

* chore: migrate user route admin checks

* chore: migrate user lifecycle routes

* chore: migrate auth user lookups

* chore: migrate oidc user routes

* chore: migrate api key repository paths

* docs: add database gray rollout guide

* chore: migrate trusted device paths

* chore: migrate user session route user lookups

* chore: add database repository rollout guard

* chore: expose repository rollout status

* chore: warn on repository rollout misconfiguration

* chore: migrate remaining user lookup helpers

* chore: migrate ssh user lookups

* chore: migrate user settings admin lookups

* chore: migrate acme ssl user lookups

* chore: migrate audit log admin checks

* chore: migrate oidc account user updates

* chore: migrate password reset user updates

* chore: migrate user deletion core records

* chore: migrate snippet audit user lookups

* chore: migrate ldap user sync paths

* chore: migrate totp user updates

* chore: migrate rbac user checks

* chore: migrate rbac role paths

* chore: migrate permission role lookups

* chore: migrate rbac access list reads

* chore: migrate shared rbac reads

* chore: migrate rbac access writes

* chore: migrate permission host access

* chore: migrate role host access lookup

* chore: migrate snippet access lookup

* chore: migrate shared credential access lookups

* chore: migrate host access cleanup writes

* chore: migrate host list access checks

* chore: migrate host access cleanup routes

* chore: migrate shared credential role lookups

* chore: migrate user role cleanup

* chore: migrate admin role sync

* chore: migrate ldap role sync

* chore: migrate user role assignment

* chore: migrate sso provider access

* chore: migrate audit log access

* chore: migrate user preference access

* chore: migrate open tab access

* chore: migrate dismissed alert access

* chore: migrate homepage layout access

* chore: migrate network topology access

* chore: migrate dashboard service link access

* chore: migrate command history access

* chore: migrate recent activity cleanup

* chore: migrate ssh credential usage access

* chore: migrate transfer recent access

* chore: migrate file manager bookmark access

* chore: migrate c2s tunnel preset access

* chore: migrate homepage item access

* chore: migrate session recording access

* chore: migrate tmux session tag access

* chore: migrate opkssh token access

* chore: migrate vault token access

* chore: migrate vault profile access

* chore: migrate host metrics preference access

* chore: migrate host health access

* chore: migrate host metrics history access

* chore: migrate alert persistence access

* chore: route alert host lookup through repository

* chore: migrate user data export reads

* chore: route host metrics stats sync through repository

* chore: migrate host folder persistence

* chore: migrate host resolution reads

* chore: route jump host resolution reads

* chore: route docker console jump host reads

* chore: route docker ssh resolution reads

* chore: route proxmox discovery resolution reads

* chore: route file manager activity host reads

* chore: route host metrics resolution reads

* chore: route ssh auth credential reads

* chore: route tunnel endpoint credential reads

* chore: route credential deployment resolution reads

* chore: route command history host flag reads

* chore: route snippet execution resolution reads

* chore: route terminal host resolution reads

* chore: route vault oidc host resolution reads

* chore: route wake on lan host reads

* chore: route internal host list reads

* chore: route host key verification persistence

* chore: route credential read paths

* chore: route credential host usage reads

* chore: route credential folder rename

* chore: route host owner access checks

* chore: route shared credential source reads

* chore: route user host credential cleanup

* chore: route credential delete reads

* chore: route credential update reads

* chore: route host credential reads

* chore: route host read paths

* chore: route host projection reads

* chore: route host list reads

* chore: route snippet read paths

* chore: route snippet folder writes

* chore: route snippet crud paths

* chore: route snippet bulk import

* chore: route rbac ownership reads

* chore: route user count reads

* chore: route cleanup snippets folders

* chore: route shared credential persistence

* chore: route dashboard activity

* chore: route guacamole host reads

* chore: route host bulk lookups

* chore: remove unlock-only simple db ops

* chore: route host autostart persistence

* chore: route ldap provisioning through users

* chore: route credential encrypted writes

* chore: route host encrypted writes

* chore: route bulk host encrypted writes

* chore: route termix id credentials

* chore: route termix id ca persistence

* chore: route termix identity persistence

* chore: route credential system migration

* chore: isolate user encryption migration storage

* chore: remove legacy simple db ops

* chore: isolate legacy sqlite migration copy

* chore: route database settings import export

* chore: route database host credential export

* chore: route database host credential import

* chore: route database file-manager import export

* chore: route database alert usage import export

* chore: route database user checks

* chore: isolate auth lazy migration storage

* chore: route explicit database saves

* chore: initialize database save boundary

* chore: route migration snapshot saves

* chore: isolate sqlite import constraints

* chore: route import sqlite boundary

* chore: route user encryption migration store

* chore: centralize current repository runtime

* chore: route more current repositories

* chore: route activity repository runtimes

* chore: route token repository runtimes

* chore: route health repository runtimes

* chore: route identity repository runtimes

* chore: route rbac repository runtime

* chore: centralize current sqlite runtime access

* chore: route user deletion key cleanup

* chore: route user deletion vault cleanup

* chore: route user deletion homepage cleanup

* chore: route user deletion health cleanup

* chore: route user deletion alert cleanup

* chore: route user deletion identity cleanup

* chore: add database layer preupgrade backup

* Fix database repository type errors

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
This commit is contained in:
ZacharyZcR
2026-07-16 12:28:10 +08:00
committed by GitHub
parent ed6cda4ea4
commit 1e7c9ea53c
229 changed files with 24327 additions and 8382 deletions
File diff suppressed because one or more lines are too long
+548
View File
@@ -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<void>;
close(): Promise<void>;
migrate(): Promise<void>;
transaction<T>(fn: (tx: DatabaseTransaction) => Promise<T>): Promise<T>;
}
```
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.
File diff suppressed because it is too large Load Diff
+38 -102
View File
@@ -1,19 +1,15 @@
import express from "express"; import express from "express";
import cookieParser from "cookie-parser"; import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "./utils/cors-config.js"; 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 { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js"; import { AuthManager } from "./utils/auth-manager.js";
import type { AuthenticatedRequest } from "../types/index.js"; import type { AuthenticatedRequest } from "../types/index.js";
import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.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 app = express();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
@@ -23,6 +19,10 @@ const serverStartTime = Date.now();
const activityRateLimiter = new Map<string, number>(); const activityRateLimiter = new Map<string, number>();
const RATE_LIMIT_MS = 1000; const RATE_LIMIT_MS = 1000;
function isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
app.use(createCorsMiddleware()); app.use(createCorsMiddleware());
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
@@ -103,7 +103,7 @@ app.get("/activity/recent", async (req, res) => {
try { try {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
error: "Session expired - please log in again", error: "Session expired - please log in again",
code: "SESSION_EXPIRED", code: "SESSION_EXPIRED",
@@ -112,16 +112,8 @@ app.get("/activity/recent", async (req, res) => {
const limit = Number(req.query.limit) || 20; const limit = Number(req.query.limit) || 20;
const activities = await SimpleDBOps.select( const activities =
getDb() await createCurrentRecentActivityRepository().listByUserId(userId, limit);
.select()
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp))
.limit(limit),
"recent_activity",
userId,
);
res.json(activities); res.json(activities);
} catch (err) { } catch (err) {
@@ -168,7 +160,7 @@ app.post("/activity/log", async (req, res) => {
try { try {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
error: "Session expired - please log in again", error: "Session expired - please log in again",
code: "SESSION_EXPIRED", code: "SESSION_EXPIRED",
@@ -223,97 +215,45 @@ app.post("/activity/log", async (req, res) => {
entriesToDelete.forEach((key) => activityRateLimiter.delete(key)); entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
} }
const ownedHosts = await SimpleDBOps.select( const isOwnedHost =
getDb() await createCurrentHostResolutionRepository().isHostOwnedByUser(
.select() hostId,
.from(hosts) userId,
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), );
"ssh_data",
userId,
);
if (ownedHosts.length === 0) { if (!isOwnedHost) {
const userRoleIds = await getDb() const roleIds =
.select({ roleId: userRoles.roleId }) await createCurrentRoleRepository().listUserRoleIds(userId);
.from(userRoles) const sharedHosts =
.where(eq(userRoles.userId, userId)); await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
const roleIds = userRoleIds.map((r) => r.roleId); userId,
roleIds,
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)),
),
); );
const hasSharedAccess = sharedHosts.some(
(access) => access.hostId === hostId,
);
if (sharedHosts.length === 0) { if (!hasSharedAccess) {
return res return res
.status(404) .status(404)
.json({ error: "Host not found or access denied" }); .json({ error: "Host not found or access denied" });
} }
} }
const result = (await SimpleDBOps.insert( const result = await createCurrentRecentActivityRepository().create({
recentActivity,
"recent_activity",
{
userId,
type,
hostId,
hostName,
},
userId, userId,
)) as unknown as { id: number }; type,
hostId,
hostName,
});
// Best-effort trim of old activity entries; failures here should not // Best-effort trim of old activity entries; failures here should not
// cause the primary /activity/log request to 500. // cause the primary /activity/log request to 500.
try { try {
const allActivities = await SimpleDBOps.select<{ await createCurrentRecentActivityRepository().trimUserActivity(
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",
userId, 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) { } catch (trimErr) {
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", { dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
operation: "trim_recent_activity", operation: "trim_recent_activity",
@@ -349,18 +289,14 @@ app.delete("/activity/reset", async (req, res) => {
try { try {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) { if (!isUserDataUnlocked(userId)) {
return res.status(401).json({ return res.status(401).json({
error: "Session expired - please log in again", error: "Session expired - please log in again",
code: "SESSION_EXPIRED", code: "SESSION_EXPIRED",
}); });
} }
await SimpleDBOps.delete( await createCurrentRecentActivityRepository().deleteByUserId(userId);
recentActivity,
"recent_activity",
eq(recentActivity.userId, userId),
);
dashboardLogger.success("Recent activity cleared", { dashboardLogger.success("Recent activity cleared", {
operation: "reset_recent_activity", operation: "reset_recent_activity",
+318 -367
View File
@@ -34,27 +34,24 @@ import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js"; import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js"; import { UserDataExport } from "../utils/user-data-export.js";
import { AutoSSLSetup } from "../utils/auto-ssl-setup.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 { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js"; import { getProxyAgent } from "../utils/proxy-agent.js";
import {
users,
hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
sshCredentialUsage,
settings,
} from "./db/schema.js";
import type { import type {
CacheEntry, CacheEntry,
GitHubRelease, GitHubRelease,
GitHubAPIResponse, GitHubAPIResponse,
AuthenticatedRequest, AuthenticatedRequest,
} from "../../types/index.js"; } from "../../types/index.js";
import { getDb, DatabaseSaveTrigger } from "./db/index.js"; import { DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
@@ -70,6 +67,45 @@ const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware(); const requireAdmin = authManager.createAdminMiddleware();
app.use(createCorsMiddleware()); 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<SettingData[]> {
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<void> {
await createCurrentSettingsRepository().upsert(setting.key, setting.value);
}
const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads"); const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads");
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -621,12 +657,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const deviceInfo = parseUserAgent(req); const deviceInfo = parseUserAgent(req);
const user = await getDb().select().from(users).where(eq(users.id, userId)); const userRepository = createCurrentUserRepository();
if (!user || user.length === 0) { const user = await userRepository.findById(userId);
if (!user) {
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
} }
const isOidcUser = !!user[0].isOidc; const isOidcUser = !!user.isOidc;
if (!DataCrypto.getUserDataKey(userId)) { if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) { if (isOidcUser) {
@@ -867,22 +904,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
userRecord.totpBackupCodes || null, userRecord.totpBackupCodes || null,
); );
const sshHosts = await getDb() const sshHosts =
.select() await createCurrentHostRepository().listDecryptedByUserId(userId);
.from(hosts)
.where(eq(hosts.userId, userId));
const insertHost = exportDb.prepare(` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
for (const host of sshHosts) { for (const decrypted of sshHosts) {
const decrypted = DataCrypto.decryptRecord(
"ssh_data",
host,
userId,
userDataKey,
);
insertHost.run( insertHost.run(
decrypted.id, decrypted.id,
decrypted.userId, decrypted.userId,
@@ -940,22 +969,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
); );
} }
const credentials = await getDb() const credentials =
.select() await createCurrentCredentialRepository().listDecryptedByUserId(userId);
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const insertCred = exportDb.prepare(` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
for (const cred of credentials) { for (const decrypted of credentials) {
const decrypted = DataCrypto.decryptRecord(
"ssh_credentials",
cred,
userId,
userDataKey,
);
insertCred.run( insertCred.run(
decrypted.id, decrypted.id,
decrypted.userId, decrypted.userId,
@@ -979,19 +1000,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
); );
} }
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([ const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
getDb() fileManagerRepository.listRecentByUserId(userId),
.select() fileManagerRepository.listPinnedByUserId(userId),
.from(fileManagerRecent) fileManagerRepository.listShortcutsByUserId(userId),
.where(eq(fileManagerRecent.userId, userId)),
getDb()
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId)),
getDb()
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId)),
]); ]);
const insertRecent = exportDb.prepare(` const insertRecent = exportDb.prepare(`
@@ -1039,10 +1053,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
); );
} }
const alerts = await getDb() const dismissedAlertRepository = createCurrentDismissedAlertRepository();
.select() const alerts = await dismissedAlertRepository.listByUserId(userId);
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const insertAlert = exportDb.prepare(` const insertAlert = exportDb.prepare(`
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at) INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
@@ -1056,10 +1068,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
); );
} }
const usage = await getDb() const sshCredentialUsageRepository =
.select() createCurrentSshCredentialUsageRepository();
.from(sshCredentialUsage) const usage = await sshCredentialUsageRepository.listByUserId(userId);
.where(eq(sshCredentialUsage.userId, userId));
const insertUsage = exportDb.prepare(` const insertUsage = exportDb.prepare(`
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at) INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
@@ -1074,20 +1085,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
); );
} }
const settingsData = await getDb().select().from(settings); writeSettingsToExportDatabase(exportDb, await getExportableSettings());
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);
}
} finally { } finally {
exportDb.close(); exportDb.close();
} }
@@ -1182,19 +1180,16 @@ app.post(
} }
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const mainDb = getDb();
const deviceInfo = parseUserAgent(req); const deviceInfo = parseUserAgent(req);
const userRecords = await mainDb const userRepository = createCurrentUserRepository();
.select() const userRecord = await userRepository.findById(userId);
.from(users)
.where(eq(users.id, userId));
if (!userRecords || userRecords.length === 0) { if (!userRecord) {
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
} }
const isOidcUser = !!userRecords[0].isOidc; const isOidcUser = !!userRecord.isOidc;
if (!DataCrypto.getUserDataKey(userId)) { if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) { if (isOidcUser) {
@@ -1276,332 +1271,287 @@ app.post(
}; };
try { try {
mainDb.$client.exec("PRAGMA foreign_keys = OFF"); await withCurrentSqliteForeignKeysDisabled(async () => {
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) {
try { try {
const importedItems = importDb const importedHosts = importDb
.prepare(`SELECT * FROM ${table}`) .prepare("SELECT * FROM ssh_data")
.all(); .all();
for (const item of importedItems) { for (const host of importedHosts) {
try { try {
const existing = await mainDb const hostRepository = createCurrentHostRepository();
.select() const exists = await hostRepository.existsForImportIdentity(
.from(schema) userId,
.where( host.ip,
and( host.port,
eq(schema.userId, userId), host.username,
eq(schema.path, item.path), );
eq(schema.name, item.name),
),
);
if (existing.length > 0) { if (exists) {
result.summary.skippedItems++; result.summary.skippedItems++;
continue; continue;
} }
const itemData = { const hostData = {
userId: userId, userId: userId,
hostId: item.host_id, name: host.name,
name: item.name, ip: host.ip,
path: item.path, port: host.port,
...(table === "file_manager_recent" && { username: host.username,
lastOpened: item.last_opened, folder: host.folder,
}), tags: host.tags,
...(table === "file_manager_pinned" && { pin: Boolean(host.pin),
pinnedAt: item.pinned_at, authType: host.auth_type,
}), forceKeyboardInteractive: host.force_keyboard_interactive,
...(table === "file_manager_shortcuts" && { password: host.password,
createdAt: item.created_at, 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); await hostRepository.createEncryptedForUser(userId, hostData);
result.summary[key]++; result.summary.sshHostsImported++;
} catch (itemError) { } catch (hostError) {
result.summary.errors.push( result.summary.errors.push(
`${table} import error: ${itemError.message}`, `SSH host import error: ${hostError.message}`,
); );
} }
} }
} catch { } catch {
apiLogger.info(`${table} table not found in import file, skipping`); apiLogger.info("ssh_data table not found in import file, skipping");
} }
}
try { try {
const importedAlerts = importDb const importedCreds = importDb
.prepare("SELECT * FROM dismissed_alerts") .prepare("SELECT * FROM ssh_credentials")
.all(); .all();
for (const alert of importedAlerts) { for (const cred of importedCreds) {
try { try {
const existing = await mainDb const credentialRepository =
.select() createCurrentCredentialRepository();
.from(dismissedAlerts) const exists =
.where( await credentialRepository.existsForImportIdentity(
and( userId,
eq(dismissedAlerts.userId, userId), cred.name,
eq(dismissedAlerts.alertId, alert.alert_id), 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({ const fileManagerTables = [
userId: userId, {
alertId: alert.alert_id, table: "file_manager_recent",
dismissedAt: alert.dismissed_at || new Date().toISOString(), key: "fileManagerItemsImported",
}); },
result.summary.dismissedAlertsImported++; {
} catch (alertError) { table: "file_manager_pinned",
result.summary.errors.push( key: "fileManagerItemsImported",
`Dismissed alert import error: ${alertError.message}`, },
{
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 const dismissedAlertRepository =
.select() createCurrentDismissedAlertRepository();
.from(users)
.where(eq(users.id, userId));
if (targetUser.length > 0 && targetUser[0].isAdmin) {
try { try {
const importedSettings = importDb const importedAlerts = importDb
.prepare("SELECT * FROM settings") .prepare("SELECT * FROM dismissed_alerts")
.all(); .all();
for (const setting of importedSettings) { for (const alert of importedAlerts) {
try { try {
const existing = await mainDb const created = await dismissedAlertRepository.createForImport(
.select() userId,
.from(settings) alert.alert_id,
.where(eq(settings.key, setting.key)); alert.dismissed_at,
);
if (existing.length > 0) { if (created) {
await mainDb result.summary.dismissedAlertsImported++;
.update(settings)
.set({ value: setting.value })
.where(eq(settings.key, setting.key));
result.summary.settingsImported++;
} else { } else {
await mainDb.insert(settings).values({ result.summary.skippedItems++;
key: setting.key,
value: setting.value,
});
result.summary.settingsImported++;
} }
} catch (settingError) { } catch (alertError) {
result.summary.errors.push( result.summary.errors.push(
`Setting import error (${setting.key}): ${settingError.message}`, `Dismissed alert import error: ${alertError.message}`,
); );
} }
} }
} catch { } 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"); const targetUser = await userRepository.findById(userId);
result.success = true; 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 { result.success = true;
await DatabaseSaveTrigger.forceSave("database_import");
} catch (saveError) { try {
apiLogger.error( await DatabaseSaveTrigger.forceSave("database_import");
"Failed to persist imported data to disk", } catch (saveError) {
saveError, apiLogger.error(
{ "Failed to persist imported data to disk",
operation: "import_force_save_failed", saveError,
userId, {
}, operation: "import_force_save_failed",
); userId,
} },
);
}
});
} finally { } finally {
if (importDb) { if (importDb) {
importDb.close(); importDb.close();
@@ -1945,6 +1895,7 @@ app.get(
res.json({ res.json({
migrationStatus: status, migrationStatus: status,
repositoryRollout: getRepositoryRolloutStatus(),
files: { files: {
unencryptedDbSize: unencryptedSize, unencryptedDbSize: unencryptedSize,
encryptedDbSize: encryptedSize, encryptedDbSize: encryptedSize,
+55 -72
View File
@@ -25,6 +25,28 @@ let memoryDatabase: Database.Database;
let isNewDatabase = false; let isNewDatabase = false;
let sqlite: Database.Database; 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<void> { async function initializeDatabaseAsync(): Promise<void> {
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();
@@ -615,16 +637,7 @@ async function initializeCompleteDatabase(): Promise<void> {
migrateSchema(); migrateSchema();
try { try {
const row = sqlite ensureRawSettingDefault("allow_registration", "true");
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get();
if (!row) {
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
)
.run();
}
} catch (e) { } catch (e) {
databaseLogger.warn("Could not initialize default settings", { databaseLogger.warn("Could not initialize default settings", {
operation: "db_init", operation: "db_init",
@@ -633,16 +646,7 @@ async function initializeCompleteDatabase(): Promise<void> {
} }
try { try {
const row = sqlite ensureRawSettingDefault("allow_password_login", "true");
.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();
}
} catch (e) { } catch (e) {
databaseLogger.warn("Could not initialize allow_password_login setting", { databaseLogger.warn("Could not initialize allow_password_login setting", {
operation: "db_init", operation: "db_init",
@@ -651,16 +655,7 @@ async function initializeCompleteDatabase(): Promise<void> {
} }
try { try {
const row = sqlite ensureRawSettingDefault("guac_enabled", "true");
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
.get();
if (!row) {
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
)
.run();
}
} catch (e) { } catch (e) {
databaseLogger.warn("Could not initialize guac_enabled setting", { databaseLogger.warn("Could not initialize guac_enabled setting", {
operation: "db_init", operation: "db_init",
@@ -669,16 +664,7 @@ async function initializeCompleteDatabase(): Promise<void> {
} }
try { try {
const row = sqlite ensureRawSettingDefault("guac_url", getDefaultGuacdUrl());
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get();
if (!row) {
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
)
.run(getDefaultGuacdUrl());
}
} catch (e) { } catch (e) {
databaseLogger.warn("Could not initialize guac_url setting", { databaseLogger.warn("Could not initialize guac_url setting", {
operation: "db_init", operation: "db_init",
@@ -1937,31 +1923,30 @@ const migrateSchema = () => {
// Migrate legacy single oidc_config settings blob into sso_providers table // Migrate legacy single oidc_config settings blob into sso_providers table
try { try {
const migrationDone = sqlite const migrationDone = getRawSettingValue("sso_migration_v1");
.prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'")
.get();
if (!migrationDone) { if (!migrationDone) {
const providerCount = ( 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; ).c;
if (providerCount === 0) { if (providerCount === 0) {
const legacyRow = sqlite const legacyConfig = getRawSettingValue("oidc_config");
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'") if (legacyConfig) {
.get() as { value: string } | undefined;
if (legacyRow) {
sqlite sqlite
.prepare( .prepare(
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)", "INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
) )
.run("OIDC", legacyRow.value); .run("OIDC", legacyConfig);
databaseLogger.info("Migrated legacy oidc_config into sso_providers table", { databaseLogger.info(
operation: "sso_migration_v1", "Migrated legacy oidc_config into sso_providers table",
}); {
operation: "sso_migration_v1",
},
);
} }
} }
sqlite setRawSettingValue("sso_migration_v1", "true");
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')")
.run();
} }
} catch (e) { } catch (e) {
databaseLogger.warn("Failed to run SSO migration v1", { databaseLogger.warn("Failed to run SSO migration v1", {
@@ -2100,19 +2085,15 @@ const migrateSchema = () => {
// Seed default metrics history retention setting // Seed default metrics history retention setting
try { try {
const retentionRow = sqlite ensureRawSettingDefault("metrics_history_retention_days", "7");
.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();
}
} catch (e) { } catch (e) {
databaseLogger.warn("Could not initialize metrics_history_retention_days setting", { databaseLogger.warn(
operation: "schema_migration", "Could not initialize metrics_history_retention_days setting",
error: e, {
}); operation: "schema_migration",
error: e,
},
);
} }
// --- homepage begin --- // --- homepage begin ---
@@ -2203,21 +2184,23 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
} }
async function handlePostInitFileEncryption() { async function handlePostInitFileEncryption() {
if (!enableFileEncryption) return;
try { try {
if (memoryDatabase) { if (memoryDatabase) {
await saveMemoryDatabaseToFile(); DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
if (enableFileEncryption) {
await saveMemoryDatabaseToFile();
}
setInterval(() => { setInterval(() => {
if (DatabaseSaveTrigger.isDirty) { if (DatabaseSaveTrigger.isDirty) {
saveMemoryDatabaseToFile(); saveMemoryDatabaseToFile();
} }
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
} }
if (!enableFileEncryption) return;
try { try {
const migration = new DatabaseMigration(dataDir); const migration = new DatabaseMigration(dataDir);
migration.cleanupOldBackups(); migration.cleanupOldBackups();
@@ -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<void>,
): Promise<AlertRepository> {
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);
});
});
@@ -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<void>,
) {}
async listNotificationChannels(
userId: string,
): Promise<NotificationChannelRow[]> {
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<NotificationChannelRow | null> {
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<NotificationChannelRow> {
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<NotificationChannelRow | null> {
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<boolean> {
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<AlertRuleWithChannelsRow[]> {
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<AlertRuleWithChannelsRow> {
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<AlertRuleRow | null> {
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<AlertRuleWithChannelsRow | null> {
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<boolean> {
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<void> {
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<void> {
await this.context.drizzle
.update(alertFirings)
.set({ acknowledged: true })
.where(eq(alertFirings.userId, userId));
await this.afterWrite();
}
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
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<AlertEngineRule[]> {
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<AlertEngineRule | null> {
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<void> {
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<AlertEngineChannel[]> {
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<string | null> {
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<number[]> {
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<number[]> {
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<void> {
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,
};
}
@@ -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");
});
});
@@ -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<void>,
) {}
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
const rows = await this.context.drizzle
.insert(apiKeys)
.values(apiKey)
.returning();
await this.afterWrite();
return rows[0];
}
async listAllWithUsers(): Promise<ApiKeyListRecord[]> {
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<ApiKeyRecord | null> {
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<ApiKeyRecord[]> {
return this.context.drizzle
.select()
.from(apiKeys)
.where(
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
);
}
async updateLastUsedAt(id: string, lastUsedAt: string): Promise<void> {
await this.context.drizzle
.update(apiKeys)
.set({ lastUsedAt })
.where(eq(apiKeys.id, id));
await this.afterWrite();
}
async delete(id: string): Promise<ApiKeyRecord | null> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<AuditLogRepository> {
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"]);
});
});
@@ -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<void>,
) {}
async create(entry: NewAuditLogRecord): Promise<void> {
await this.context.drizzle.insert(auditLogs).values(entry);
await this.pruneIfNeeded();
await this.afterWrite();
}
async listPage(input: {
filters: AuditLogFilters;
limit: number;
offset: number;
}): Promise<AuditLogPage> {
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<number>`COUNT(*)` })
.from(auditLogs)
.where(whereClause),
]);
return {
logs,
total: totalResult[0]?.count ?? 0,
};
}
async listDistinctActions(): Promise<string[]> {
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<number> {
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<void> {
const countResult = await this.context.drizzle
.select({ count: sql<number>`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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<C2sTunnelPresetRepository> {
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);
});
});
@@ -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<C2sTunnelPresetCreateInput>;
export class C2sTunnelPresetRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<C2sTunnelPresetRecord[]> {
return this.context.drizzle
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.orderBy(asc(c2sTunnelPresets.name));
}
async findByIdForUser(
userId: string,
id: number,
): Promise<C2sTunnelPresetRecord | null> {
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<boolean> {
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<C2sTunnelPresetRecord> {
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<C2sTunnelPresetRecord | null> {
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<boolean> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<CommandHistoryRepository> {
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);
});
});
@@ -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<void>,
) {}
async create(
userId: string,
hostId: number,
command: string,
executedAt = new Date().toISOString(),
): Promise<CommandHistoryRecord> {
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<string[]> {
const rows = await this.context.drizzle
.select({
command: commandHistory.command,
maxExecutedAt: sql<number>`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<string[]> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<NewCredentialRecord, "id" | "userId">
>;
export type CredentialSystemEncryptionUpdate = Pick<
CredentialUpdate,
"systemPassword" | "systemKey" | "systemKeyPassword" | "updatedAt"
>;
export class CredentialRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
const rows = await this.context.drizzle
.insert(sshCredentials)
.values(credential)
.returning();
await this.afterWrite();
return rows[0];
}
async createEncryptedForUser(
userId: string,
credential: NewCredentialRecord | Record<string, unknown>,
): Promise<CredentialRecord> {
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<NewCredentialRecord>).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<CredentialRecord | null> {
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<CredentialRecord | null> {
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<CredentialRecord[]> {
return this.context.drizzle
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt));
}
async listMissingSystemEncryptionByUserId(
userId: string,
): Promise<CredentialRecord[]> {
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<boolean> {
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<CredentialRecord | null> {
const row = await this.findByIdForUser(userId, credentialId);
return this.decryptOne(row, userId);
}
async listDecryptedByUserId(userId: string): Promise<CredentialRecord[]> {
const rows = await this.listByUserId(userId);
return this.decryptMany(rows, userId);
}
async listFolders(userId: string): Promise<string[]> {
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<number> {
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<CredentialRecord | null> {
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<CredentialRecord | null> {
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<CredentialRecord | null> {
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<boolean> {
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<number> {
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<void> {
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<T extends Record<string, unknown>>(
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<T extends Record<string, unknown>>(
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<string, unknown>,
>(record: T, userId: string, userDataKey: Buffer): Promise<T> {
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<void> {
await this.onWrite?.();
}
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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",
),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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<void> {
return () => DatabaseSaveTrigger.forceSave(reason);
}
export function getCurrentRepositorySqlite() {
return getSqlite();
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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;
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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());
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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"),
);
}
@@ -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<void>,
): Promise<DashboardServiceLinkRepository> {
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);
});
});
@@ -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<void>,
) {}
async listByUserId(userId: string): Promise<DashboardServiceLinkRecord[]> {
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<DashboardServiceLinkRecord> {
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<DashboardServiceLinkRecord | null> {
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<DashboardServiceLinkRecord | null> {
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<boolean> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<DismissedAlertRepository> {
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);
});
});
@@ -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<void>,
) {}
async listByUserId(userId: string): Promise<DismissedAlertRecord[]> {
return this.context.drizzle
.select()
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
}
async listAlertIdsByUserId(userId: string): Promise<string[]> {
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<DismissedAlertRecord | null> {
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<void> {
await this.context.drizzle.insert(dismissedAlerts).values({
userId,
alertId,
});
await this.afterWrite();
}
async createForImport(
userId: string,
alertId: string,
dismissedAt = new Date().toISOString(),
): Promise<boolean> {
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<boolean> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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",
);
});
});
@@ -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<T extends Record<string, unknown>>(
tableName: string,
record: T,
userDataKey: Buffer,
recordId = record.id,
): T {
const id = this.requireRecordId(recordId);
const encryptedRecord: Record<string, unknown> = { ...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<T extends Record<string, unknown>>(
tableName: string,
record: T,
userDataKey: Buffer,
recordId = record.id,
): T {
const id = this.requireRecordId(recordId);
const decryptedRecord: Record<string, unknown> = { ...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);
}
}
@@ -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<void>,
): Promise<FileManagerBookmarkRepository> {
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);
});
});
@@ -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<void>,
) {}
async listRecentForHost(
userId: string,
hostId: number,
limit = 20,
): Promise<FileManagerRecentRecord[]> {
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<FileManagerRecentRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
}
async upsertRecent(
userId: string,
input: FileManagerBookmarkInput,
lastOpened = new Date().toISOString(),
): Promise<void> {
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<boolean> {
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<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
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<FileManagerPinnedRecord[]> {
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<FileManagerPinnedRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
}
async createPinned(
userId: string,
input: FileManagerBookmarkInput,
pinnedAt = new Date().toISOString(),
): Promise<boolean> {
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<boolean> {
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<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
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<FileManagerShortcutRecord[]> {
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<FileManagerShortcutRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
}
async createShortcut(
userId: string,
input: FileManagerBookmarkInput,
createdAt = new Date().toISOString(),
): Promise<boolean> {
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<boolean> {
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<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
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<number> {
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<number> {
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<number> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
const rows = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(inArray(fileManagerShortcuts.hostId, hostIds))
.returning({ id: fileManagerShortcuts.id });
return rows.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<HomepageItemRepository> {
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);
});
});
@@ -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<void>,
) {}
async listByUserId(userId: string): Promise<HomepageItemRecord[]> {
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<HomepageItemRecord> {
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<HomepageItemRecord | null> {
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<HomepageItemRecord | null> {
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<boolean> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<HomepageLayoutRepository> {
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);
});
});
@@ -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<void>,
) {}
async findByUserId(userId: string): Promise<HomepageLayoutRecord | null> {
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<HomepageLayoutRecord> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<ReturnType<SqliteDatabaseAdapter["connect"]>>["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);
});
});
@@ -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<void>,
): Promise<{
repository: HostFolderRepository;
sqlite: NonNullable<
Awaited<ReturnType<SqliteDatabaseAdapter["connect"]>>["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);
});
});
@@ -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<void>,
) {}
async renameFolder(
userId: string,
oldName: string,
newName: string,
now = new Date().toISOString(),
): Promise<RenameFolderResult> {
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<HostFolderRecord[]> {
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<HostFolderHostRecord[]> {
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<void> {
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<number> {
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<HostFolderRecord | null> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<HostHealthRepository> {
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);
});
});
@@ -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<void>,
) {}
async findChecksByUserAndHost(
userId: string,
hostId: number,
): Promise<HostHealthCheckRecord | null> {
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<HostHealthCheckRecord> {
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<number> {
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<HostHealthHistoryRecord[]> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<HostMetricsHistoryRepository> {
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);
});
});
@@ -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<void>,
) {}
async create(input: HostMetricsHistoryCreateInput): Promise<void> {
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<HostMetricsHistoryRecord[]> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<HostMetricsPreferenceRepository> {
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);
});
});
@@ -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<void>,
) {}
async findByUserAndHost(
userId: string,
hostId: number,
): Promise<HostMetricsPreferenceRecord | null> {
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<HostMetricsPreferenceRecord> {
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<boolean> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<Omit<NewHostRecord, "id" | "userId">>;
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<void>,
) {}
async create(host: NewHostRecord): Promise<HostRecord> {
const rows = await this.context.drizzle
.insert(hosts)
.values(host)
.returning();
await this.afterWrite();
return rows[0];
}
async createEncryptedForUser(
userId: string,
host: NewHostRecord | Record<string, unknown>,
): Promise<HostRecord> {
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<NewHostRecord>).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<HostRecord | null> {
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<HostRecord | null> {
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<HostRecord[]> {
return this.context.drizzle
.select()
.from(hosts)
.where(eq(hosts.userId, userId));
}
async listDecryptedByUserId(userId: string): Promise<HostRecord[]> {
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<boolean> {
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<HostRecord | null> {
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<HostRecord | null> {
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<HostBulkUpdateState[]> {
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<number> {
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<boolean> {
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<number> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<HostResolutionRepository> {
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();
});
});
@@ -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<void>,
) {}
async findHostById(
hostId: number,
userId: string,
): Promise<HostResolutionHostRecord | null> {
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<HostResolutionHostRecord | null> {
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<HostUpdateStateRecord | null> {
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<HostResolutionHostRecord[]> {
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<HostListRow[]> {
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<string | null> {
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<boolean> {
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<HostResolutionHostRecord[]> {
const rows = await this.context.drizzle.select().from(hosts);
return this.decryptManyByOwner("ssh_data", rows);
}
async listHostsWithTunnelConnections(): Promise<HostResolutionHostRecord[]> {
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<HostResolutionHostRecord[]> {
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<HostKeyVerificationRecord | null> {
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<void> {
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<void> {
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<void> {
await this.context.drizzle
.update(hosts)
.set({ hostKeyLastVerified: now })
.where(eq(hosts.id, hostId));
await this.afterWrite();
}
async findCredentialByIdForUser(
credentialId: number,
userId: string,
): Promise<HostResolutionCredentialRecord | null> {
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<HostResolutionCredentialRecord | null> {
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<number | null> {
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<T extends Record<string, unknown>>(
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<T extends Record<string, unknown>>(
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<string, unknown> & { 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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<NetworkTopologyRepository> {
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();
});
});
@@ -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<void>,
) {}
async findByUserId(userId: string): Promise<NetworkTopologyRecord | null> {
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<void> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<OpenTabRepository> {
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);
});
});
@@ -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<NewOpenTabRecord, "label" | "tabOrder" | "backendSessionId">
>;
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<void>,
) {}
async listRecentForUser(
userId: string,
updatedAfter: string,
): Promise<OpenTabRecord[]> {
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<void> {
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<void> {
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<boolean> {
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<number> {
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<number> {
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<OpenTabRecord | null> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<OpksshTokenRepository> {
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);
});
});
@@ -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<void>,
) {}
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
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<OpksshTokenRecord | null> {
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<boolean> {
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<boolean> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<RbacAccessRepository> {
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);
});
});
@@ -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<RbacAccessListItem, "targetType">;
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<void>,
) {}
async listHostAccess(hostId: number): Promise<RbacAccessListItem[]> {
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<void> {
await this.context.drizzle
.delete(hostAccess)
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
await this.afterWrite();
}
async deleteHostAccessForHost(hostId: number): Promise<number> {
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<number> {
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<number> {
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<typeof hostAccess.$inferSelect | null> {
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<void> {
await this.context.drizzle
.update(hostAccess)
.set({ overrideCredentialId: credentialId })
.where(eq(hostAccess.id, accessId));
await this.afterWrite();
}
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
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<void> {
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<RbacSharedHost[]> {
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<RbacVisibleHostAccessEntry[]> {
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<RbacSharedSnippet[]> {
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<RbacVisibleSharedSnippet[]> {
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<RbacAccessibleSnippet | null> {
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<number> {
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<typeof hostAccess.$inferSelect | null> {
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<void> {
await this.context.drizzle
.update(hostAccess)
.set({ lastAccessedAt })
.where(eq(hostAccess.id, accessId));
await this.afterWrite();
}
async listRoleHostAccessCredentialSources(
roleId: number,
): Promise<RbacRoleHostAccessCredentialSource[]> {
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<typeof sharedCredentials.$inferSelect | null> {
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<string | null> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<{
repository: RecentActivityRepository;
sqlite: NonNullable<
Awaited<ReturnType<SqliteDatabaseAdapter["connect"]>>["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);
});
});
@@ -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<void>,
) {}
async listByUserId(
userId: string,
limit: number,
): Promise<RecentActivityRecord[]> {
return this.context.drizzle
.select()
.from(recentActivity)
.where(eq(recentActivity.userId, userId))
.orderBy(desc(recentActivity.timestamp))
.limit(limit);
}
async create(
activity: NewRecentActivityRecord,
): Promise<RecentActivityRecord> {
const rows = await this.context.drizzle
.insert(recentActivity)
.values(activity)
.returning();
await this.afterWrite();
return rows[0];
}
async trimUserActivity(userId: string, keepCount: number): Promise<number> {
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<number> {
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<number> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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.",
]);
});
});
@@ -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<string, string | undefined>;
const DOMAIN_ALIASES: Record<string, RepositoryRolloutDomain> = {
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,
});
}
}
@@ -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<void>,
): Promise<RoleRepository> {
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);
});
});
@@ -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<NewRoleRecord>,
"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<void>,
) {}
async listRoles(): Promise<RoleRecord[]> {
return this.context.drizzle
.select()
.from(roles)
.orderBy(roles.isSystem, roles.name);
}
async findRoleById(id: number): Promise<RoleRecord | null> {
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<RoleRecord | null> {
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<number> {
const result = await this.context.drizzle.insert(roles).values(role);
await this.afterWrite();
return Number(result.lastInsertRowid);
}
async updateRole(id: number, update: RoleUpdate): Promise<boolean> {
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<typeof userRoles.$inferSelect | null> {
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<void> {
await this.context.drizzle.insert(userRoles).values(input);
await this.afterWrite();
}
async assignRoleNameToUser(input: {
userId: string;
roleName: string;
grantedBy: string;
}): Promise<boolean> {
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<UserRoleNameSwitchResult> {
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<void> {
await this.context.drizzle
.delete(userRoles)
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)));
await this.afterWrite();
}
async removeAllRolesFromUser(userId: string): Promise<number> {
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<number[]> {
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<string[]> {
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<UserRolePermissionRecord[]> {
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<boolean> {
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<UserRoleWithRole[]> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
): Promise<SessionRecordingRepository> {
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);
});
});
@@ -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<void>,
) {}
async create(
input: SessionRecordingCreateInput,
): Promise<SessionRecordingRecord> {
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<void> {
await this.context.drizzle
.update(sessionRecordings)
.set(input)
.where(eq(sessionRecordings.id, id));
await this.afterWrite();
}
async findById(id: number): Promise<SessionRecordingRecord | null> {
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<SessionRecordingListRecord[]> {
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<SessionRecordingRecord | null> {
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<SessionRecordingPathRecord | null> {
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<SessionRecordingPathRecord[]> {
return this.context.drizzle
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(lt(sessionRecordings.startedAt, cutoff));
}
async deleteById(id: number): Promise<boolean> {
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<boolean> {
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<number> {
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<number> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<void>,
) {}
async create(session: NewSessionRecord): Promise<SessionRecord> {
const rows = await this.context.drizzle
.insert(sessions)
.values(session)
.returning();
await this.afterWrite();
return rows[0];
}
async listAll(): Promise<SessionRecord[]> {
return this.context.drizzle.select().from(sessions);
}
async findById(id: string): Promise<SessionRecord | null> {
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<SessionRecord[]> {
return this.context.drizzle
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
}
async listExpired(now = new Date()): Promise<SessionRecord[]> {
return this.context.drizzle
.select()
.from(sessions)
.where(lte(sessions.expiresAt, now.toISOString()));
}
async touch(
id: string,
lastActiveAt = new Date().toISOString(),
): Promise<void> {
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<void> {
await this.context.drizzle
.update(sessions)
.set({ jwtToken, lastActiveAt })
.where(eq(sessions.id, id));
await this.afterWrite();
}
async revoke(id: string): Promise<boolean> {
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<number> {
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<number> {
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<void> {
await this.onWrite?.();
}
}
@@ -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<SettingsRepository> {
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");
});
});

Some files were not shown because too many files have changed in this diff Show More