release-2.6.1 (#1161)

* fix: preserve remote sync references (#1092)

* fix: centralize outbound address validation (#1093)

* fix: preserve architecture in unpacked ASAR path (#1094)

* fix: allow sharing empty folders (#1096)

* fix: preserve WoL broadcast address (#1097)

* fix: deduplicate shared hosts (#1098)

* fix snippet execution result handling (#1099)

* fix SSH login alert delivery (#1100)

* fix outbound DNS lookup callback shape (#1101)

* fix OIDC verification for JWKs without alg (#1102)

* fix file manager navigation after permission errors (#1103)

* fix database persistence during container shutdown (#1104)

* fix: persist host command history setting (#1107)

* fix: recognize Windows terminal Tab events (#1109)

* fix: recognize Windows terminal Tab events

* style: format terminal key event test

* fix: export repository user record (#1111)

* fix: keep localhost database export same-origin (#1112)

* fix: support Tailscale auth in tmux monitor (#1113)

* fix: forward Android hardware keyboard keys (#1114)

* fix: expose jump tunnels to guacd (#1115)

* fix OIDC login with unverifiable ID tokens (#1117)

verifyOIDCToken passed the raw id_token straight to jose's jwtVerify, which
throws JWSInvalid when the token is not a three-segment compact JWS. Authentik
issues an encrypted JWE id_token when the provider has an encryption key set,
so the callback threw and every OIDC login failed with 'Invalid Compact JWS'.

2.5.0 hid this behind a catch-all that decoded the unverified payload; removing
that fallback fixed the trust bug but turned the pre-existing verification
failure into a hard login failure.

Check the segment count before verifying and raise a distinct
OIDCTokenFormatError, which the callback treats as 'no usable claims here' and
falls through to the userinfo endpoint. Signature and claim failures still
reject the login.

Fixes Termix-SSH/Support#1016
Fixes Termix-SSH/Support#1018

* refuse to start with an empty database when data exists elsewhere (#1118)

When the data directory holds no database, startup treats it as a first run and
silently creates an empty one. A deployment that loses DATA_DIR — an .env file
the service no longer loads, a volume that did not mount — lands in exactly that
state, so the user is asked to register an admin account again while the real
database sits untouched one directory over. It is indistinguishable from the
upgrade having deleted everything.

Check the known data locations before creating a new database and refuse to
start when one of them already holds a database, naming both directories.
ALLOW_EMPTY_DATA_DIR=true starts anyway for anyone deliberately starting over.

This matches how a failed decryption already behaves: it throws rather than
falling back to an empty database.

Closes Termix-SSH/Support#1006

* stop read-only shared hosts from being dragged into folders (#1119)

Shared hosts hide their edit, share and delete actions based on the recipient's
permission level, but the sidebar row stays draggable regardless. Dropping one on
a folder issues a bulk folder update the server rejects, so a recipient without
edit rights gets a failure toast for an action the UI offered them.

Gate draggable on canEditHost, and skip hosts the recipient cannot edit in the
move handler so a mixed selection moves what it can instead of failing whole.

Closes Termix-SSH/Support#1011

* apply the configured RDP resolution to the session (#1120)

The host editor stores width and height in guacamoleConfig, and the backend
passes them to guacd in the connection token. The renderer then appends its own
width and height query parameters measured from the container, which take
precedence, so a configured resolution never reached the session — only dpi did,
because that was the one display field GuacamoleApp read back.

Pass the configured width and height alongside dpi, and skip the container-driven
sendSize on connect and on resize when a resolution is pinned. rescaleDisplay
still fits the fixed display into the available space.

Closes Termix-SSH/Support#1039

* honour per-host recording flags and explain a missing recording (#1121)

The session recording section offers a recording path, a filename template and
four content toggles, but the backend overwrote five of the six on every
connection. A host could set none of them and get no indication why.

Location and filename genuinely are not the host's to choose — recordings are
indexed by them for playback and the backend refuses to read outside its
recordings directory — so drop those two inputs rather than keep pretending they
apply. The content flags are a host-level decision, so default them instead of
forcing them.

That still leaves the reported case, where guacd writes the file somewhere the
backend cannot see it. The warning now reports both paths and names the two env
vars that align them, which is otherwise guesswork for a split-container setup.

Closes Termix-SSH/Support#1041

* route desktop guacd calls to the connected remote server (#1122)

resolveConnectionOrigin() pins RDP/VNC/Telnet to "remote" because the embedded
desktop backend does not bundle guacd, and the Guacamole websocket already
follows that. The status check and both token calls did not: they use the shared
authApi, which in Electron is hard-coded to the embedded backend.

So the desktop app asked the backend without guacd whether guacd was available,
got "disconnected", and refused to connect — while the connected server it would
actually have used reports it as connected and serves the same host fine from the
web client.

Send those three calls through a remote-origin instance in Electron, alongside
the existing file-manager, tunnel and stats ones.

Closes Termix-SSH/Support#1043

* move the Homebrew cask to where a tap looks for it (#1123)

A tap discovers casks in a top-level Casks/ directory. The cask sat in
packaging/Casks/, so tapping the repository succeeded and every subsequent
brew install --cask termix reported that no cask with that name exists.

Move it and repoint the five workflow references. The release job still rewrites
the version and checksum in place, and the electron job still copies it into the
generated and submission trees.

Closes Termix-SSH/Support#1044

* stop highlighting inside a split control string (#1124)

A control string (OSC/DCS/APC/PM) carries text that must never be displayed —
an OSC 0 title holds the user, host and path, and PROMPT_COMMAND emits one on
every prompt. Its opener and its terminator routinely land in different
websocket frames, and the continuation frame contains no escape byte at all, so
every guard in the highlighter misses it: TUI_SEQUENCE, CONTROL_STRING_SEQUENCE
and hasIncompleteAnsiSequence all only look at one chunk.

Highlighting that continuation injects an SGR sequence into the middle of the
open string, which aborts it early in xterm.js and prints the remainder as
ordinary text — the stray ~/path glued to the prompt, and the cursor arithmetic
drift behind the duplicate prompts and Ctrl+R corruption.

Track the state across chunks the way alternate-screen mode already is, and skip
any chunk that starts or ends inside a control string. A trailing lone ESC counts
as inside, since its meaning only arrives with the next chunk.

Closes Termix-SSH/Support#1025

* stop session-log route test importing the real repository layer (#1125)

The test mocks db, logger and AuthManager, but the route module also calls
PermissionManager.getInstance() at import time and pulls in the repository
factory, which loads the drizzle schema and the better-sqlite3 native binding.
Importing that costs seconds when the full suite runs its projects
concurrently, and the test times out at 5s. On its own it passes, so it read as
flaky rather than as a missing mock.

Mock both. None of it is under test here, and the file now imports in
milliseconds regardless of load.

* fail the guacamole-lite patch when an anchor is gone (#1126)

Each patch bails out with a console.log and process.exit(0) when its anchor
string is missing. The write-back happens at the end of the file, so an upstream
release that moves any one anchor drops every patch, exits successfully, and
leaves postinstall reporting nothing wrong. Termix then builds and starts
normally and drops VNC/RDP sessions at runtime — with no signal pointing at the
patch.

Every patch here is required for correctness: protocol negotiation, the guacd
1.6.0 name handshake, dynamic argument answering, UTF-8 tokens, read-only joins.
A missing anchor means the patch no longer applies, so exit non-zero and say
which one and what to do.

Unchanged: a missing guacamole-lite still skips quietly, and an already-patched
tree still exits 0.

* fix: clarify desktop local profile (#1095)

* fix: clarify desktop local profile

* cover the AccordionSection hidden branch

The desktop build hides the Security section because the embedded profile signs
in automatically and has no login password, so the controls there would imply a
protection that does not exist. Nothing asserted that hidden actually keeps the
children out of the DOM rather than merely collapsing them.

Export the component and cover both states, including that an expanded hidden
section still renders nothing.

* fix: show remote sync account identity (#1110)

* fix: show remote sync account identity

* cover getRemoteSyncUserInfo and make its null contract hold

Nothing asserted the renderer-side gate: browser builds must not reach for the
IPC bridge, and a missing bridge, an unconfigured server, an expired JWT or a
failed channel all have to degrade to no identity rather than throw.

Writing that turned up a mismatch — with no preload bridge the optional chain
resolved to undefined while the signature promises null. The only caller uses
??, so nothing is broken today, but the type was not telling the truth.

The main-process half (token expiry, /users/me, the roles fallback) stays
uncovered: remote-sync.cjs requires electron at load, so exercising it means
stubbing safeStorage and the filesystem, which is a bigger change than this PR
warrants.

* improve settings navigation and legal disclosure (#1105)

* fix desktop preference synchronization (#1106)

* fix: use jump host SOCKS proxy settings (#1116)

* ci(deps): bump the github-actions group with 2 updates (#1086)

Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder).


Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

Updates `useblacksmith/setup-docker-builder` from 1 to 2
- [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases)
- [Commits](https://github.com/useblacksmith/setup-docker-builder/compare/v1...v2)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: useblacksmith/setup-docker-builder
  dependency-version: '2'
  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 23 updates (#1087)

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

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.4` | `2.5.5` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.17` | `1.2.20` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.20` | `1.1.23` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.8` | `1.3.11` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.20` | `1.1.23` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.21` | `2.1.24` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.12` | `2.1.15` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.20` | `1.1.23` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.13` | `1.1.16` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.15` | `1.2.18` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.4` | `2.3.7` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.12` | `1.1.15` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.4` | `1.4.7` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.3.0` | `1.3.3` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.4` | `1.3.7` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.18` | `1.1.21` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.13` | `1.2.16` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.3` | `6.0.4` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `10.0.3` | `10.0.4` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.3` | `1.6.7` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.7` | `19.2.8` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.7` | `19.2.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.10` | `17.0.11` |


Updates `@biomejs/biome` from 2.5.4 to 2.5.5
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.5/packages/@biomejs/biome)

Updates `@radix-ui/react-accordion` from 1.2.17 to 1.2.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion)

Updates `@radix-ui/react-alert-dialog` from 1.1.20 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog)

Updates `@radix-ui/react-checkbox` from 1.3.8 to 1.3.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-dialog` from 1.1.20 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.21 to 2.1.24
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.12 to 2.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.20 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.13 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-scroll-area` from 1.2.15 to 1.2.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.3.4 to 2.3.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.12 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slider` from 1.4.4 to 1.4.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-slot` from 1.3.0 to 1.3.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.3.4 to 1.3.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.18 to 1.1.21
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-tooltip` from 1.2.13 to 1.2.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.4
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.4/packages/plugin-react)

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

Updates `radix-ui` from 1.6.3 to 1.6.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/1.6.7/packages/react/radix-ui)

Updates `react` from 19.2.7 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)

Updates `react-dom` from 19.2.7 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)

Updates `react-i18next` from 17.0.10 to 17.0.11
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.10...v17.0.11)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-accordion"
  dependency-version: 1.2.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-version: 1.1.23
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.23
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.24
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.23
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.4.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: concurrently
  dependency-version: 10.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: radix-ui
  dependency-version: 1.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react
  dependency-version: 19.2.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-dom
  dependency-version: 19.2.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.11
  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 the prod-patch-updates group with 3 updates (#1088)

Bumps the prod-patch-updates group with 3 updates: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual), [jose](https://github.com/panva/jose) and [js-yaml](https://github.com/nodeca/js-yaml).


Updates `@tanstack/react-virtual` from 3.14.6 to 3.14.8
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.8/packages/react-virtual)

Updates `jose` from 6.2.3 to 6.2.4
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.3...v6.2.4)

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

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: js-yaml
  dependency-version: 5.2.2
  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 undici in the prod-minor-updates group (#1089)

Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici).


Updates `undici` from 8.7.0 to 8.9.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.7.0...v8.9.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-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 4 updates (#1090)

Bumps the major-updates group with 4 updates: [better-sqlite3](https://github.com/WiseLibs/better-sqlite3), [chalk](https://github.com/chalk/chalk), [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) and [typescript](https://github.com/microsoft/TypeScript).


Updates `better-sqlite3` from 12.11.1 to 13.0.1
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.11.1...v13.0.1)

Updates `chalk` from 5.6.2 to 6.0.0
- [Release notes](https://github.com/chalk/chalk/releases)
- [Commits](https://github.com/chalk/chalk/compare/v5.6.2...v6.0.0)

Updates `@testing-library/jest-dom` from 6.9.1 to 7.0.0
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: better-sqlite3
  dependency-version: 13.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: chalk
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 7.0.2
  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>

* restore lint by pinning typescript below 7 (#1131)

#1090 bumped typescript to 7.0.2. typescript-eslint declares
`typescript: >=4.8.4 <6.1.0`, and TypeScript 7 removed `ts.Extension`, which
@typescript-eslint/typescript-estree dereferences at import time:

    node_modules/@typescript-eslint/typescript-estree/dist/create-program/shared.js:59
        ts.Extension.Cjs,
    TypeError: Cannot read properties of undefined (reading 'Cjs')

ESLint hits that while loading eslint.config.mjs, so `npm run lint` fails before
linting anything. Node reports it as ERR_INTERNAL_ASSERTION, which hides the
cause. Every open PR fails this check, not just new ones.

Even the latest typescript-eslint prerelease still caps at <6.1.0, so there is
nothing to upgrade to yet. Pin back to ~6.0.3 and tell dependabot to hold major
typescript bumps until the ecosystem catches up.

Also fixes biome.json pointing vcs.defaultBranch at dev-2.5.0, a branch that no
longer exists.

* make the repository layer engine-agnostic (#1127)

DatabaseContext handed every repository a raw better-sqlite3 handle alongside
drizzle, and three of them used it for retention queries built on datetime('now',
?) — a SQLite-only function. That handle is the one thing standing between the
repository layer and a second engine.

Drop it. The two time-based prunes compute their cutoff in JS against the
CURRENT_TIMESTAMP text format, which every engine writes the same way and which
compares correctly as a string; the health-history prune becomes a select of the
rows to keep followed by a NOT IN delete. All three turn async, so their two
callers await them.

Name the dialect rather than repeating a string literal, so adding an engine is
one edit instead of a search.

Tests built their schema through context.sqlite?.exec(). Optional chaining meant
removing the field type-checked cleanly and then silently created no tables, so
the fixture now owns exec() and a raw handle for direct assertions — schema setup
belongs to the test harness, not to the interface repositories consume.

No behaviour change, and no Postgres yet: this only removes the coupling that
would have to be undone first.

* keep audit trails and recordings when a user is deleted (#1128)

audit_logs and session_recordings both referenced users with ON DELETE CASCADE,
so removing an account erased everything it had ever done. An audit trail that
disappears with the account it recorded cannot answer the question it exists for,
and a recording is evidence about a host as much as about a person.

Both foreign keys become ON DELETE SET NULL. audit_logs already denormalises
username, so an entry still names who acted once the reference is gone.
session_recordings did not, so the column is added and backfilled first —
otherwise relaxing the constraint would only trade deleted evidence for
anonymous evidence.

SQLite cannot alter a foreign key in place, so existing databases are migrated
by copy-and-swap, guarded by a PRAGMA check that makes it idempotent. Fresh
databases are created in the target shape and skip it. Recordings still cascade
from their host.

* audit the remaining remote access paths (#1129)

Only SSH terminal sessions were audited. Opening a file manager session, an RDP,
VNC or Telnet desktop, a Docker session or an SSH tunnel left no audit entry at
all — which covers most of the ways data leaves a host or a foothold is
established.

Each of those four now writes an entry when the session is established, matching
the existing ssh_connect: who, which host, from what address, and for tunnels
the endpoint and local port being forwarded.

Audit writes are fire-and-forget so they cannot delay or fail the connection,
consistent with logAudit already swallowing its own errors.

getAuditUsername was defined identically in two route files and is needed in
four more, so it moves next to logAudit.

* fix: honor lookupOptions.all in custom DNS lookup hook (#1084)

Node's happy-eyeballs autoSelectFamily calls custom dns lookup functions
with all:true and expects the full address array back. Always replying
with a single (address, family) pair corrupted net's internal state,
surfacing as "Invalid IP address: undefined" instead of a real connect
error, breaking outbound notification delivery (webhook/ntfy).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* fix: SSH-login alerts silently dropped (channel load + auth middleware ordering) (#1083)

* fix: load notification channels on mount in AlertsPanel

Channels only loaded when the Channels tab was visited, so opening Edit
Alert Rule before ever switching to that tab showed the channel picker
as empty even when channels existed.

(cherry picked from commit caed913ee91990a853f5a048849c67ed3f7c329e)

* fix: register login-alert route before auth middleware

Global JWT auth middleware ran before this internal
service-to-service route, rejecting it with 401 before
its own IP+token check ever ran — silently dropped every
SSH-login alert. Also surface non-OK responses instead of
swallowing them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: add coverage for alert-notification fixes

Channel-load-on-mount, login-alert non-OK handling, and a
source-order guard for the route/auth-middleware regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* format AlertsPanel test with prettier

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* stop deleting audit trails, and say when they are dropped (#1132)

Two ways audit evidence still disappeared silently.

Deleting an account removed its audit entries and session recordings outright.
#1128 relaxed those foreign keys to ON DELETE SET NULL, but deleteUserAndRelatedData
deletes the rows explicitly, so the schema change had no effect on the path that
actually matters. Both repositories gain anonymizeByUserId, which nulls the
reference and leaves the row; username is already denormalised on both tables, so
entries stay attributable to whoever acted.

Separately, the log pruned itself at a hard-coded 10000 rows with no signal.
Entries well inside any retention window were discarded and nothing recorded it.
Retention is now configurable by age via AUDIT_LOG_RETENTION_DAYS, the row cap
via AUDIT_LOG_MAX_ENTRIES, and the two are reported differently: expiring an old
entry is routine and logged at info, while hitting the cap means the ceiling is
too low for how much this install audits and is logged at warn, naming the range
discarded and how to stop it.

* let the audit log leave the box (#1133)

Retention became configurable in #1132, which only helps if entries can be moved
somewhere before they expire. Until now the only way out was two GET endpoints
built for the UI.

Adds GET /audit-logs/export, taking the same filters as the list endpoint and
streaming the whole matching set as CSV or NDJSON in batches, so an export is not
bounded by the 200-row page cap and does not buffer the result set. Reading the
entire trail is itself recorded as export_audit_logs.

CSV fields starting with =, +, - or @ are prefixed with a quote. Audit rows carry
attacker-influenced values like resource names, and spreadsheet software treats
those as formulas on open.

Adds optional live forwarding to a collector via AUDIT_LOG_FORWARD_URL, with an
optional bearer token. Delivery goes through safeOutboundFetch so a misconfigured
URL cannot be turned into an internal network probe, and it is fire-and-forget:
the local write stays the source of truth and a dead SIEM must never delay or
fail the operation being audited. Repeated failures are reported five times and
then suppressed until delivery recovers, so an outage does not bury the logs it
is supposed to appear in.

* encrypt SSO secrets instead of base64-encoding them (#1135)

The OIDC client secret and LDAP bind password were stored behind an encoded:
prefix that is base64, not encryption. Anyone reading the database read the
secrets. A second path wrote the same thing behind an encrypted: prefix, which
was also base64 — and the reader even documented that it could not decrypt it.

These belong to the installation rather than to a user: sso_providers has no
userId, and the values must be readable during login, before anyone has
authenticated, so the per-user DEK used elsewhere does not apply. They are now
sealed with AES-256-GCM under the system encryption key, which already protects
other installation-level material.

Reading handles both legacy prefixes so an existing install is not locked out of
SSO login, and a legacy value is upgraded the next time the provider is saved.
The three scattered encode/decode sites are replaced by one module.

* remove the unwired field encryption boundary (#1136)

FieldEncryptionBoundary declared a full sensitive/plaintext policy for six
tables and was referenced only by its own test. Nothing in production used it.

Its policy is byte-for-byte the same as FieldCrypto.ENCRYPTED_FIELDS, which is
the copy that actually runs, so nothing is lost by deleting it. Keeping a second
list is the real risk: someone adds a field to this one, sees it classified as
sensitive, and ships something that was never encrypted.

The one apparent improvement it had — requiring an explicit recordId instead of
DataCrypto's temp-${Date.now()} fallback — turns out to guard against nothing.
decryptField derives its context from the recordId stored inside the ciphertext,
not from the argument, so a temporary id at encryption time still decrypts.

* load the database file when encryption is off (#1137)

* Groundwork for Postgres and MySQL backends (#1134)

* groundwork for postgres and mysql backends

#1127 made the repository layer dialect-agnostic. This adds the pieces needed
to actually target a second engine, as a foundation only — nothing is wired up
and sqlite remains the sole runtime path.

- DatabaseDialect covers sqlite, postgres and mysql, resolved from
  DATABASE_DIALECT and defaulting to sqlite so nothing changes for existing
  deployments or the desktop build
- a column kit holding the per-dialect type choices in one file: booleans are
  integers on sqlite and native elsewhere, autoincrement differs three ways, and
  MySQL cannot index unbounded TEXT so key columns need varchar
- settings and users declared for all three dialects as a proof slice, chosen
  because between them they use every construct the real schema does
- pg and mysql2 added as dependencies

The tests build real queries for all three engines without a server, asserting
identifier quoting, placeholder style and boolean storage, so the property the
repositories depend on is verified rather than assumed.

* verify foreign keys and unique constraints port across dialects

The first slice only covered plain columns. The real schema also has 92 foreign
keys (80 cascade, 12 set null) and 14 unique columns, so the approach is only
viable if those survive the port.

Adds audit_logs and ssh_folders to the proof slice: one nullable reference with
ON DELETE SET NULL, one required reference with ON DELETE CASCADE, a unique
column, and an autoincrement surrogate key — which is spelled three different
ways underneath (integer primary key autoincrement, serial, int auto_increment).

All of it holds. Worth noting for whoever picks this up: getTableConfig is
dialect-specific and silently fails on a table from another dialect, so the test
uses each engine's own.

* generate the postgres and mysql schemas instead of hand-writing them

The proof slice showed the constructs port, but left the maintenance question
open. Three hand-written copies of 52 tables is the wrong answer: with foreign
keys the copies cross-reference each other, so a renamed table has to land in
three places consistently or a key silently points at the wrong one.

The mapping is mechanical, so a script does it. schema.ts stays the single
source of truth and schema.pg.ts / schema.mysql.ts are derived, covering all 52
tables — the column kit and the two-table portable slice are gone, since the
generator now holds those decisions.

The transforms are the ones the kit enumerated: integer-backed booleans become
native, autoincrement keys become serial or int auto_increment, real becomes
double precision or double, and any column that is a primary key, is unique, or
sits on either end of a foreign key becomes varchar because MySQL cannot index
unbounded TEXT.

> termix@2.6.0 lint
> node scripts/generate-dialect-schema.cjs --check && eslint .

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-favicon-routes.ts
  99:12  warning  'err' is defined but never used  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-ping-routes.ts
  123:12  warning  'err' is defined but never used  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-rss-routes.ts
  144:12  warning  'err' is defined but never used  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/session-log-routes.ts
  46:16  warning  'canAccessRecording' is defined but never used. Allowed unused vars must match /^_/u  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/hosts/vault-signer-core.ts
  55:12  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  75:13  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/hosts/auth-manager.test.ts
  18:73  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/utils/shared-host-secrets-manager.test.ts
  7:6  warning  'SecretRow' is defined but never used. Allowed unused vars must match /^_/u  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/auth-manager.ts
  510:13  warning  'affectedUsers' is assigned a value but never used. Allowed unused vars must match /^_/u  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/notification-sender.ts
  48:12  warning  'firstErr' is defined but never used  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/api/ssh-file-operations-api.ts
  35:10  warning  'buildFileManagerUrl' is defined but never used. Allowed unused vars must match /^_/u  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/folder-style.tsx
   61:14  warning  Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components  react-refresh/only-export-components
  116:14  warning  Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components  react-refresh/only-export-components
  121:14  warning  Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components  react-refresh/only-export-components
  149:17  warning  Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components  react-refresh/only-export-components

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx
  109:19  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any
  190:19  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/HomepageCanvas.tsx
  345:15  warning  Empty block statement  no-empty
  388:15  warning  Empty block statement  no-empty
  415:15  warning  Empty block statement  no-empty

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/dialogs/SingleHostEditForm.tsx
  24:6  warning  React Hook useEffect has a missing dependency: 'filter'. Either include it or remove the dependency array. If 'setHosts' needs the current value of 'filter', you can also switch to useReducer instead of useState and read 'filter' in the reducer  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/AlertFeedWidget.tsx
  93:6  warning  React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/CustomApiWidget.tsx
  77:6  warning  React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerActivityWidget.tsx
  50:6  warning  React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerWidget.tsx
  16:10  warning  Fast refresh only works when a file has exports. Move your component(s) to a separate file  react-refresh/only-export-components

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/FileManagerWidget.tsx
  16:10  warning  Fast refresh only works when a file has exports. Move your component(s) to a separate file  react-refresh/only-export-components

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/HostGridWidget.tsx
  61:6  warning  React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array                                react-hooks/exhaustive-deps
  61:7  warning  React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/MetricsChartWidget.tsx
  168:6  warning  React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/PingStatusWidget.tsx
  79:6  warning  React Hook useEffect has a missing dependency: 'fetchAll'. Either include it or remove the dependency array                               react-hooks/exhaustive-deps
  79:7  warning  React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/QuickConnectWidget.tsx
  64:10  warning  Fast refresh only works when a file has exports. Move your component(s) to a separate file  react-refresh/only-export-components

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/RecentActivityWidget.tsx
  82:6   warning  React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array                              react-hooks/exhaustive-deps
  82:17  warning  React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx
  67:6   warning  React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array                                react-hooks/exhaustive-deps
  67:7   warning  React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked  react-hooks/exhaustive-deps
  99:17  warning  'online' is assigned a value but never used. Allowed unused vars must match /^_/u                                                           unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshTerminalWidget.tsx
  17:10  warning  Fast refresh only works when a file has exports. Move your component(s) to a separate file  react-refresh/only-export-components

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx
  72:6  warning  React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/TunnelWidget.tsx
  15:10  warning  Fast refresh only works when a file has exports. Move your component(s) to a separate file  react-refresh/only-export-components

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/host-metrics/cards/CpuCard.tsx
  14:10  warning  'computeChartData' is defined but never used. Allowed unused vars must match /^_/u  unused-imports/no-unused-vars

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/FolderPathPicker.tsx
  15:17  warning  Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components  react-refresh/only-export-components
  22:17  warning  Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components  react-refresh/only-export-components

/mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/HostsPanel.tsx
  601:52  warning  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 44 problems (0 errors, 44 warnings) now fails if the generated files are out of date, so editing the
schema without regenerating cannot reach main.

* select durability behaviour per dialect, and document the backends

The onWrite hook every repository receives exists to serialise the in-memory
SQLite database back to its encrypted file. On a client-server engine a
committed write is already durable and there is nothing to flush, so the factory
now installs no hook at all rather than one that does nothing. Repositories call
it as this.onWrite?.(), so none of the 43 of them change.

Also adds docs/database-backends.md, mostly to be explicit about encryption,
which is the part most likely to be misread. Field-level encryption is identical
on all three engines and covers every credential. Whole-file encryption has no
equivalent on Postgres or MySQL, so host names, snippet contents, audit entries
and backups are only as protected as the storage underneath them — that is the
operator's responsibility and the docs should not imply otherwise.

* generate DDL with drizzle-kit, and give settings a synchronous path

Two of the three remaining blockers.

DDL: db/index.ts hand-writes 67 CREATE TABLE statements and 122 ADD COLUMN
migrations, all in SQLite dialect. Rather than port them, drizzle-kit now
generates migrations from the schema modules — 817 lines for Postgres, 869 for
MySQL, with the type mapping already correct because the schemas it reads are
themselves generated.
> termix@2.6.0 schema:migrations
> drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts

Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.pg.ts'
52 tables
alert_firings 11 columns 0 indexes 2 fks
alert_rule_channels 3 columns 0 indexes 2 fks
alert_rules 11 columns 0 indexes 2 fks
api_keys 9 columns 0 indexes 1 fks
audit_logs 13 columns 0 indexes 1 fks
c2s_tunnel_presets 8 columns 0 indexes 1 fks
command_history 5 columns 0 indexes 2 fks
dashboard_service_links 8 columns 0 indexes 1 fks
dismissed_alerts 4 columns 0 indexes 1 fks
file_manager_pinned 6 columns 0 indexes 2 fks
file_manager_recent 6 columns 0 indexes 2 fks
file_manager_shortcuts 6 columns 0 indexes 2 fks
homepage_items 9 columns 0 indexes 1 fks
homepage_layouts 4 columns 0 indexes 1 fks
host_access 11 columns 0 indexes 5 fks
host_health_checks 7 columns 0 indexes 2 fks
host_health_history 8 columns 0 indexes 2 fks
host_metrics_history 8 columns 0 indexes 1 fks
host_metrics_preferences 6 columns 0 indexes 2 fks
ssh_data 94 columns 0 indexes 6 fks
network_topology 5 columns 0 indexes 1 fks
notification_channels 7 columns 0 indexes 1 fks
opkssh_tokens 12 columns 0 indexes 2 fks
recent_activity 6 columns 0 indexes 2 fks
roles 8 columns 0 indexes 0 fks
session_recordings 15 columns 0 indexes 3 fks
session_share_participants 6 columns 0 indexes 2 fks
session_shares 15 columns 0 indexes 3 fks
sessions 11 columns 0 indexes 1 fks
settings 2 columns 0 indexes 0 fks
shared_host_secrets 15 columns 0 indexes 3 fks
snippet_access 8 columns 0 indexes 4 fks
snippet_folders 8 columns 0 indexes 1 fks
snippets 11 columns 0 indexes 1 fks
ssh_credential_usage 5 columns 0 indexes 3 fks
ssh_credentials 21 columns 0 indexes 1 fks
ssh_folders 9 columns 0 indexes 2 fks
sso_providers 8 columns 0 indexes 0 fks
sync_tombstones 5 columns 0 indexes 1 fks
termix_identities 6 columns 0 indexes 1 fks
termix_identity_ca 8 columns 0 indexes 2 fks
termix_identity_keys 12 columns 0 indexes 3 fks
tmux_session_tags 6 columns 0 indexes 2 fks
transfer_recent 7 columns 0 indexes 3 fks
trusted_devices 8 columns 0 indexes 1 fks
user_open_tabs 9 columns 0 indexes 2 fks
user_preferences 23 columns 0 indexes 1 fks
user_roles 5 columns 0 indexes 3 fks
users 20 columns 0 indexes 0 fks
vault_profiles 18 columns 0 indexes 1 fks
vault_tokens 8 columns 0 indexes 2 fks
webauthn_credentials 12 columns 0 indexes 1 fks

No schema changes, nothing to migrate 😴
Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.mysql.ts'
Reading schema files:
/mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/db/schema.mysql.ts

52 tables
alert_firings 11 columns 0 indexes 2 fks
alert_rule_channels 3 columns 0 indexes 2 fks
alert_rules 11 columns 0 indexes 2 fks
api_keys 9 columns 0 indexes 1 fks
audit_logs 13 columns 0 indexes 1 fks
c2s_tunnel_presets 8 columns 0 indexes 1 fks
command_history 5 columns 0 indexes 2 fks
dashboard_service_links 8 columns 0 indexes 1 fks
dismissed_alerts 4 columns 0 indexes 1 fks
file_manager_pinned 6 columns 0 indexes 2 fks
file_manager_recent 6 columns 0 indexes 2 fks
file_manager_shortcuts 6 columns 0 indexes 2 fks
homepage_items 9 columns 0 indexes 1 fks
homepage_layouts 4 columns 0 indexes 1 fks
host_access 11 columns 0 indexes 5 fks
host_health_checks 7 columns 0 indexes 2 fks
host_health_history 8 columns 0 indexes 2 fks
host_metrics_history 8 columns 0 indexes 1 fks
host_metrics_preferences 6 columns 0 indexes 2 fks
ssh_data 94 columns 0 indexes 6 fks
network_topology 5 columns 0 indexes 1 fks
notification_channels 7 columns 0 indexes 1 fks
opkssh_tokens 12 columns 0 indexes 2 fks
recent_activity 6 columns 0 indexes 2 fks
roles 8 columns 0 indexes 0 fks
session_recordings 15 columns 0 indexes 3 fks
session_share_participants 6 columns 0 indexes 2 fks
session_shares 15 columns 0 indexes 3 fks
sessions 11 columns 0 indexes 1 fks
settings 2 columns 0 indexes 0 fks
shared_host_secrets 15 columns 0 indexes 3 fks
snippet_access 8 columns 0 indexes 4 fks
snippet_folders 8 columns 0 indexes 1 fks
snippets 11 columns 0 indexes 1 fks
ssh_credential_usage 5 columns 0 indexes 3 fks
ssh_credentials 21 columns 0 indexes 1 fks
ssh_folders 9 columns 0 indexes 2 fks
sso_providers 8 columns 0 indexes 0 fks
sync_tombstones 5 columns 0 indexes 1 fks
termix_identities 6 columns 0 indexes 1 fks
termix_identity_ca 8 columns 0 indexes 2 fks
termix_identity_keys 12 columns 0 indexes 3 fks
tmux_session_tags 6 columns 0 indexes 2 fks
transfer_recent 7 columns 0 indexes 3 fks
trusted_devices 8 columns 0 indexes 1 fks
user_open_tabs 9 columns 0 indexes 2 fks
user_preferences 23 columns 0 indexes 1 fks
user_roles 5 columns 0 indexes 3 fks
users 20 columns 0 indexes 0 fks
vault_profiles 18 columns 0 indexes 1 fks
vault_tokens 8 columns 0 indexes 2 fks
webauthn_credentials 12 columns 0 indexes 1 fks

No schema changes, nothing to migrate 😴 regenerates both.

Settings: 27 call sites read settings synchronously, during startup and inside
request handlers. better-sqlite3 can do that; Postgres and MySQL cannot, and
making all 27 async would push await through code that has no reason to be
asynchronous. Settings are a handful of rarely-changing rows read constantly, so
they are cached in full — primed at startup, kept in step by SettingsRepository
on every set/delete/deleteLike. SQLite keeps reading the database directly and
stays authoritative; only the other engines use the cache.

Opening a connection is still not done. DatabaseContext.drizzle is typed as
BetterSQLite3Database and 43 repositories depend on that inference; the three
drizzle instance types are not interchangeable, so widening it is a design
decision rather than a mechanical change.

* exclude drizzle-kit output from prettier

The generated migrations and snapshots are tool output; their formatting is
drizzle-kit's to decide, and prettier cannot parse the .sql files at all.

* absorb the RETURNING gap so mysql stays reachable

MySQL has no RETURNING clause and drizzle's mysql-core does not expose the
method, while 156 call sites here read the result of a write. That is the real
blocker for MySQL, not the connection layer.

Classifying those call sites showed the split is favourable: 92 of them only
read .length, which every engine reports — as a returned array on sqlite and
postgres, as affectedRows on MySQL. rowsAffected() reads both shapes, so those
sites need no change in query shape. insertedId() does the same for the
autoincrement key, which MySQL reports as insertId.

What is left is the ~34 sites that genuinely consume the returned rows. Those
cannot be emulated without reading first, which needs a transaction to stay
correct under concurrency, so they will be handled individually rather than
behind a helper that quietly adds a round trip. supportsReturning() is the seam
for that.

Identifying the mysql2 result by its own fields rather than by array shape
matters: it hands back [ResultSetHeader, fields], which is an array, so shape
alone cannot tell it apart from a returning() result.

* name the portable database type, and open remote connections

Two pieces of the connection layer.

drizzle's three Database classes share no base class and their signatures are
incompatible, so there is no honest type that covers all three: a union is not
callable and a generic would have to be threaded through 43 repositories and
every method on them. DatabaseContext.drizzle is now PortableDatabase, still
the SQLite type underneath, but named and documented as the deliberate
approximation it is. What makes it safe is that the equivalence is asserted in
multi-dialect.test.ts rather than assumed, and the one place the surfaces truly
differ — RETURNING — is handled explicitly in mutation-result.ts.

connect.ts opens Postgres and MySQL from DATABASE_URL, with the schema module
and driver imported lazily so neither is loaded on a SQLite deployment. The URL
scheme is checked against the configured dialect first: a postgres:// URL with
DATABASE_DIALECT=mysql otherwise surfaces as a driver error deep in a stack
that never mentions the actual misconfiguration.

* open postgres and mysql at startup

* count writes without RETURNING

* read affected rows without RETURNING on mysql

* insert without RETURNING, and split the sync transactions

* stop pretending the generated schemas are used at runtime

* run the dialect checks in CI

* mysql rejects a bare CURRENT_TIMESTAMP default on text

* make the read-back mismatch loud, and stop the next bare returning()

* run the repository tests on the real schema

* skip the byte-level assertions off sqlite

* move generated ids past the seeded ones

* keep the export order the same on every engine

* stop reading better-sqlite3 fields off every write

* read counts as numbers, not whatever the driver returns

* make the fixture usable against a live server

* upsert on the engine that has no ON CONFLICT

* run the repository suite on all three engines in CI

* mysql cannot index a text column without a length

* document how to actually run on postgres or mysql

* keep the sqlite-era migrations off the other engines

* concat strings in a way mysql agrees with

* run every repository test on every engine

* bound how long replicas can disagree about settings

* generate the sqlite migrations alongside the others

* Bump version from 2.6.0 to 2.6.1

* resolve the dialect in the repository factory instead of assuming sqlite (#1143)

createCurrentRepositoryContext() hardcoded `dialect: "sqlite"` while the
runtime already carried all three engines. That field is not decoration:
returning.ts reads it to decide whether it can ask for RETURNING, and
whether an upsert spells itself onConflictDoUpdate or onDuplicateKeyUpdate.
Reporting sqlite while connected to MySQL means the first upsert calls
onConflictDoUpdate on a mysql2 insert builder, which does not have it --
a TypeError, not a rejected query, as the note in returning.ts warned.

So MySQL never worked outside the tests, and Postgres worked only because
it also supports RETURNING and shares the conflict syntax.

Three things were supposed to catch this and none could. The repository
suite builds its own DatabaseContext in test-support.ts, verify-dialects.mjs
builds its own, and the CI matrix runs both against real Postgres and MySQL
containers -- all of them bypassing the one function the application calls.
Green on three engines, broken on two.

Resolve it from the environment, and test the factory itself rather than a
hand-built context: the default, each configured dialect, the write hook it
installs only for sqlite, and that an unsupported value throws rather than
falling back. Reverting the fix fails two of them.

Fixes Termix-SSH/Support#282

* fix remote sync stalling after the first pass and never propagating deletions (#1140)

The incremental cursor never matched. updated_at/deleted_at are TEXT columns
written by CURRENT_TIMESTAMP ("2026-07-29 10:11:21"), while the client sends
an ISO 8601 since ("2026-07-29T10:06:55.172Z"). Both comparisons are lexical
and ' ' sorts below 'T', so a newer row lost at position 10 and every
?since= query came back empty. Pass 1 syncs everything (since is null) and
persists a cursor; every pass after it returns nothing with lastError: null
and reports success. Normalize since into the stored shape on the way in,
leaving an already-normalized value alone -- parsing that would treat it as
local time and, west of UTC, push the cursor past unsynced rows.

POST /sync/tombstones was unreachable. It was registered after
POST /:entityType, and "tombstones" is a valid :entityType, so the wildcard
answered it with 400 "Unknown entity type" and the handler never ran. The
pass has no per-entity error handling, so that 400 also discarded the state
of every entity type already synced in the same pass. Move it ahead of the
wildcards.

The tombstone guard consulted the incremental window. A row deleted on one
side and untouched on the other -- the shape every ordinary deletion takes
once the two sides converge -- is not in that window, so the tombstone was
skipped, and skipped again on each later pass as it slid out of its own
window. The guard cannot just be dropped: recording a tombstone for a row
that was already gone hands the sender a fresh one to push back, and the two
trade the same deletion forever. So only a delete that removed something
records a tombstone, which makes the endpoint idempotent and lets the client
push every tombstone unconditionally.

Deletions missed while the cursor was broken stay missed -- their tombstones
predate the persisted cursor. Ordinary edits do come through, since the
row's updatedAt is still newer than it.

Fixes Termix-SSH/Support#1050
Fixes Termix-SSH/Support#1051

* report why every JWKS fetch failed instead of swallowing the reason (#1142)

An OIDC login that cannot reach the provider's keys ends in "Failed to
fetch JWKS from any URL" and nothing else. Getting there discards
everything worth knowing: a non-2xx response hit an empty else branch, a
thrown request hit a bare `continue`, and discovery only logged when it
threw -- a 404 or a document without jwks_uri passed in silence. An
administrator cannot tell an issuer URL typo from a proxy, a private CA,
or an outage at the provider, and neither can anyone reading the report.

Collect each attempt with its reason and put them in the thrown error.
It reaches the log through the existing "OIDC callback failed" handler;
the browser still gets the same generic message it did before.

Unwrapping the cause is the part that matters: undici reports every
transport failure as "fetch failed" and hangs the real reason -- ENOTFOUND,
ECONNREFUSED, a certificate that will not verify -- off error.cause. An
attempt list built from the outer messages would be as useless as the
single line it replaces.

Also require jwks_uri to be a string before using it, so a malformed
discovery document is reported as such rather than as a failed fetch of
"[object Object]".

Refs Termix-SSH/Support#1047

* restore the closing quote on the version string (#1147)

"Bump version from 2.6.0 to 2.6.1" (2a66775) wrote

  "version": "2.6.1,

dropping the closing quote, so package.json has not been valid JSON since.
Anything that parses it fails: npm install, npm run build, and every CI run
on this branch -- vitest cannot even load its config, because vite reads
package.json before it gets to the test files.

2.6.1 cannot be built or released until this is fixed, which is why it goes
in on its own rather than riding along with anything else.

* Revert "fix remote sync stalling after the first pass and never propagating deletions (#1140)" (#1146)

This reverts commit ca7abf8426.

Reverted for process, not for content. Both defects were reported by
@kacperpietrzyk in Support#1050 and Support#1051, and he opened #1138 and
#1139 fixing them 4.5 hours before #1140 was filed. Merging #1140 made two
PRs from the person who found and diagnosed the bugs redundant.

#1138 and #1139 stand on their own: the same root-cause analysis, complete
regression tests, and a tombstone guard that only pays for its extra lookup
on a pass that actually carries a deletion. There is no technical reason to
prefer the reverted commit over them.

The sync fixes land through those two PRs instead.

* fix: make sync deletions reach the other side (#1139)

* fix: apply sync tombstones to rows outside the incremental window

Deletions never reached the other side. `syncEntity` decides whether to apply a
tombstone by looking the row up in `localBySyncId` / `remoteBySyncId`, which are
built from `pullSide(..., since)` -- the incremental window. A row deleted on one
side and untouched on the other is by definition absent from that window, which
is the shape every ordinary deletion takes once the two sides have converged, so
the tombstone was silently skipped and never retried.

The guard cannot simply be dropped. `POST /sync/tombstones` records a tombstone
on the receiving side, so an unconditional push would give the other side a fresh
tombstone to push back on the following pass, and the two would trade deletions
forever.

Instead ask the receiving side what it still holds, ignoring the window, and only
when there is a deletion to apply -- so an ordinary pass costs nothing extra, and
a pass carrying a deletion costs one additional list per affected entity type.
Once the row is gone the push stops, so nothing ping-pongs.

Note this only becomes observable together with the cursor fix in
Termix-SSH/Support#1050: while that defect is present the tombstone endpoint
returns nothing at all, so there is no tombstone to apply in the first place.

Refs Termix-SSH/Support#1034

* fix: make the sync tombstone endpoint reachable

`POST /sync/tombstones` was registered after `POST /:entityType`, and Express
matches in registration order, so every deletion push was swallowed by the
wildcard: "tombstones" is a perfectly good value for :entityType, fails
isValidEntityType, and comes back as 400 "Unknown entity type". The handler
below it has never run.

Registering the literal path before the parameterised one restores it. The
regression test reads the router stack rather than the source, so a future
re-order fails the test rather than silently disabling deletions again.

The GET pair is unaffected -- "/:entityType/tombstones" and "/:entityType" have
different segment counts, so they cannot shadow each other.

* feat: add host export dialog with host and field selection (#1108)

* fix: compare sync cursors independently of timestamp layout (#1138)

* fix: compare sync cursors independently of timestamp layout

Incremental sync returned nothing after its first pass. `GET /sync/:entityType`
filters with `gt(table.updatedAt, since)` on a TEXT column, and the tombstone
endpoint does the same through `listSince`, but the two sides of that comparison
are written in different layouts: the columns default to `CURRENT_TIMESTAMP`
("2026-07-29 10:11:21") while the desktop engine sends `new Date().toISOString()`
("2026-07-29T10:06:55.172Z"). Text comparison is decided at position 10, where
' ' (0x20) sorts below 'T' (0x54), so the predicate answers on layout rather than
on time and is false for every CURRENT_TIMESTAMP row however new it is.

The engine only sends a cursor from the second pass onward, so pass 1 synced
everything and passes 2..n pulled zero rows and zero tombstones while reporting
success -- edits and deletions silently stopped propagating in both directions.

This was masked until now: before the reference fix in #1092 the loop threw
before persisting state, so the cursor never advanced past null and every cycle
was a full sync.

Comparing "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace`
and `substr` are used rather than `datetime()` to keep the expression portable
across engines, since the repository layer is deliberately drizzle-only. The
comparison is `>=` because normalising truncates sub-second precision, and a
strict `>` would permanently skip rows written in the cursor's own second; the
re-sent boundary rows are a no-op, as the engine pushes only when one side is
strictly newer.

`updatedAt` is written in both layouts across the codebase (14 sites use
toISOString, 11 use CURRENT_TIMESTAMP), so the tests cover rows of each kind.

Closes Termix-SSH/Support#1050

* test: seed the cursor tests against the migrated schema

#1134 moved schema creation into the repository test harness, so the
hand-written CREATE TABLE blocks here collided with tables that already
existed. Seeding into the real tables instead surfaced two constraints the
local definitions had papered over: the harness enables foreign keys and
both `sync_tombstones.user_id` and `ssh_credentials.user_id` reference
`users`, so the owning row has to be seeded first; and `auth_type` is NOT
NULL with no default, unlike the local copy.

`exec` is awaited, since it only returns synchronously on SQLite.

The assertions are unchanged.

* Make Proxmox guest discovery and import reliable over a jump host (#1144)

* fix: repair unterminated version string in package.json

The version field on dev-2.6.1 reads "2.6.1, (no closing quote), which
makes package.json invalid JSON and breaks every npm invocation on the
branch. Close the string so the branch builds.

* fix(proxmox): reliable guest discovery and import over jump hosts

Importing Proxmox guests from a node reachable only through a jump host
(with the guests behind the same jump) failed in a chain of small ways.

- Discovery timed out intermittently: execCommand capped every pvesh call
  at 8s, but a single call over a jump measured ~8.3s. Raised to 25s for
  core calls and 12s for best-effort agent/interface lookups.
- No IPs were resolved (so nothing imported): resolveIp fanned out 6
  concurrent pvesh calls; on a small node they contend (3 concurrent
  already exceeded the timeout), so every IP came back empty. Lowered
  CONCURRENCY to 2.
- RDP guests aborted the whole sync via NOT NULL on ssh_data.username;
  use "" instead of null (matches the normal create path).
- Guests without a resolvable IP (e.g. QEMU with no guest agent) were
  skipped entirely; they now import with a 0.0.0.0 placeholder, and
  re-sync preserves any manually entered IP (guest.ip || existing.ip).
- Manual import did not inherit the source host's jump chain or
  credential (guests ended up unreachable with authType "none"). The
  discovery result now carries the source jumpHosts, and
  resolveProxmoxImportAuth uses an available credential even under the
  default "password" authType (explicit secretless choices still win).
- Long discoveries had no feedback and fought client/proxy timeouts;
  added an SSE endpoint GET /proxmox/discover/stream (heartbeat + n/N
  progress), keeping POST /discover as a fallback. Also always render the
  IP cell in the discovery table so IP-less rows stay aligned.

Adds a unit test for resolveProxmoxImportAuth covering the credential
inheritance behaviour.

* test(proxmox): lock resolveProxmoxImportAuth matrix on both copies; fix agent secretless drift

- extract the backend decision into src/backend/database/routes/proxmox-import-auth.ts
  (leaf module mirroring the UI copy) so it is unit-testable without pulling the
  whole backend module graph into the test env
- add src/backend/tests/database/routes/proxmox-import-auth.test.ts asserting the
  shared matrix (lifted from #1141, thanks @ZacharyZcR)
- consolidate the UI test into src/ui/tests/components/proxmox/proxmox-import-auth.test.ts
  and drop the duplicate src/ui/tests/proxmox/ copy
- add 'agent' to the UI SECRETLESS_AUTH_TYPES: the one real auth type where the two
  copies still diverged (UI -> credential, backend -> passthrough)

* fix(hosts): parse portKnockSequence JSON in host-resolver (#1149)

host-resolver JSON-parses jumpHosts/tunnelConnections/statsConfig/
terminalConfig/socks5ProxyChain/quickActions but NOT portKnockSequence.
Empty knock is stored as the string "[]" (UI save of empty array); the
terminal code then checks portKnockSequence.length > 0 on the STRING, so
"[]".length === 2 is truthy -> logs 'Loaded 2 port knock(s)' and attempts
a bogus knock. Real knock sequences (JSON string) are likewise never
parsed to the Array<{port,...}> that performPortKnocking expects, so a
genuine knock would never fire. Parse portKnockSequence like the other
JSON columns: '[]' -> [] (length 0, no knock), real seq -> array.

Adds unit tests for both cases.

Co-authored-by: XtraLarge <xtralarge@users.noreply.github.com>

* Feature request map OIDC provider groups to RBAC roles (#1148)

* Bump version from 2.6.0 to 2.6.1 in package-lock.json

* Fix formatting issue in package-lock.json

* Feature request map OIDC provider groups to RBAC roles

Group membership from an OIDC provider currently drives only a single
boolean: OIDC_ADMIN_GROUP toggles isAdmin and switches the user between
the built-in `admin` and `user` roles. There is no way to map a provider
group onto a custom role, so deployments that use host_access grants for
environment-scoped access (e.g. a role that can reach staging hosts and
another that can reach production) have to assign those roles by hand
for every user.

Add OIDC_ROLE_MAP, a comma- or newline-separated list of `group:role`
pairs, reconciled against the user's roles on each OIDC login:

  OIDC_ROLE_MAP=devops-interns:devops-intern,devops-seniors:devops-senior

Only roles named in the map are ever added or removed. Roles assigned by
hand, and the admin/user pair maintained by the existing admin-group
sync, are deliberately left untouched so the two mechanisms don't fight
each other. Group names are matched case-insensitively with leading
slashes stripped, so providers that emit full group paths (Keycloak's
"Full group path" option) work without extra configuration.

Reuses the existing extractOidcGroups claim handling, so custom claim
paths via OIDC_GROUP_CLAIM are supported too, and invalidates the
permission cache when roles change so new grants apply to the session
that triggered the sync. Malformed map entries are skipped and a failed
sync is logged but non-fatal — neither can block a valid login.

Adds unit tests for the parser and resolver covering full group paths,
multi-group membership, colons in group names and malformed input.

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: support for overriding shared host ssh credentials (#1145)

* Bump version from 2.6.0 to 2.6.1 in package-lock.json

* Fix formatting issue in package-lock.json

* feat: support for overriding ssh credentials

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* ci(deps): bump the github-actions group with 2 updates (#1150)

* Bump version from 2.6.0 to 2.6.1 in package-lock.json

* Fix formatting issue in package-lock.json

* Update README to remove Tailscale and add Ginernet

Removed Tailscale logo and link from the README. Added Ginernet logo and link.

* Update README.md

* ci(deps): bump the github-actions group with 2 updates

Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder).


Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

Updates `useblacksmith/setup-docker-builder` from 1 to 2
- [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases)
- [Commits](https://github.com/useblacksmith/setup-docker-builder/compare/v1...v2)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: useblacksmith/setup-docker-builder
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

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

* chore(deps): bump the prod-minor-updates group with 3 updates (#1154)

* Bump version from 2.6.0 to 2.6.1 in package-lock.json

* Fix formatting issue in package-lock.json

* Update README to remove Tailscale and add Ginernet

Removed Tailscale logo and link from the README. Added Ginernet logo and link.

* Update README.md

* chore(deps): bump the prod-minor-updates group with 3 updates

Bumps the prod-minor-updates group with 3 updates: [axios](https://github.com/axios/axios), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici).


Updates `axios` from 1.18.1 to 1.19.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.1...v1.19.0)

Updates `motion` from 12.42.2 to 12.43.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.42.2...v12.43.0)

Updates `undici` from 8.7.0 to 8.9.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.7.0...v8.9.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: motion
  dependency-version: 12.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: undici
  dependency-version: 8.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

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

* fix: data guard test failure

* chore(deps): bump 23 dependencies and fix dialect-unsafe queries

Applies the non-major updates from the open dependabot PRs directly,
since dependabot rebases against main and could not resolve its
lockfiles against this branch. Holds back typescript 7 and jsdom 30;
those majors need their own pass.

Reformats with prettier 3.9.6, which collapses short union types onto
one line. Formatting only: the compiled backend output is byte for byte
identical.

Also fixes two lint errors in the shared host auth override repository,
where onConflictDoUpdate and .returning() are SQLite-only and broke the
Postgres and MySQL builds, and drops unused imports left over from the
shared host auth override merge.

* chore: reversal of legal work

* feat: improve pin side rail button position and added env var for telemetrics

* Add Ctrl+F terminal search (#1156)

* Bump version from 2.6.0 to 2.6.1 in package-lock.json

* Fix formatting issue in package-lock.json

* Update README to remove Tailscale and add Ginernet

Removed Tailscale logo and link from the README. Added Ginernet logo and link.

* Update README.md

* Add Ctrl+F terminal search

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: host export dialog using incorrect widths

* fix: made logger display expanded errors

* feat: added support for multi disk usage in file manager and host metrics

* chore: harden nginx headers and improve static asset caching

* chore: format

* chore: update release notes

* fix: default font size to md instead of lg

* feat: support Tailscale SSH check mode

* chore: sync Crowdin translations for 2.6.1

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Max <50905012+maxiwolleb@users.noreply.github.com>
Co-authored-by: XtraLarge <eMail@WilliWerres.de>
Co-authored-by: XtraLarge <xtralarge@users.noreply.github.com>
Co-authored-by: Devin Dissanayaka <dsdissanayaka2002@gmail.com>
Co-authored-by: Peter Cinibulk <petercinibulk@gmail.com>
Co-authored-by: Med Ali Ezzeddine <47082236+xDaly@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-08-06 14:41:39 -05:00
committed by GitHub
co-authored by LukeGus ZacharyZcR dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Brennan Neoh Claude Sonnet 5 kacperpietrzyk Max XtraLarge XtraLarge Devin Dissanayaka Peter Cinibulk Med Ali Ezzeddine
parent 188380e8e7
commit a64c956c5b
366 changed files with 45570 additions and 7076 deletions
+2 -2
View File
@@ -713,7 +713,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `termix-export-${user[0].username}-${timestamp}.sqlite`;
const filename = `termix-export-${user.username}-${timestamp}.sqlite`;
const tempPath = path.join(tempDir, filename);
apiLogger.info("Creating export database", {
@@ -882,7 +882,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
`);
const userRecord = user[0];
const userRecord = user;
const insertUser = exportDb.prepare(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+71
View File
@@ -0,0 +1,71 @@
import * as sqlite from "drizzle-orm/sqlite-core";
import * as pg from "drizzle-orm/pg-core";
import * as mysql from "drizzle-orm/mysql-core";
/**
* Per-dialect column constructors, so a table can be declared once instead of
* three times.
*
* The existing schema only uses three column types (text, integer, real) plus
* an integer-backed boolean, which is what makes this tractable — the surface
* to abstract is small and closed. Anything a dialect cannot express the same
* way is spelled out here rather than at 52 call sites.
*
* Notable differences this papers over:
* - booleans are integers in SQLite, native in Postgres and tinyint in MySQL
* - autoincrement keys are `integer primary key autoincrement`, `serial`, and
* `int auto_increment` respectively
* - MySQL cannot index an unbounded TEXT, so keyed/indexed strings must be
* varchar; `shortText` exists for columns used as keys or in unique indexes
*/
export interface ColumnKit {
table: typeof sqlite.sqliteTable | typeof pg.pgTable | typeof mysql.mysqlTable;
/** Free-form string; unbounded where the engine allows it. */
text: (name: string) => AnyColumnBuilder;
/** String used as a key, unique or indexed — bounded so MySQL can index it. */
shortText: (name: string, length?: number) => AnyColumnBuilder;
int: (name: string) => AnyColumnBuilder;
/** Auto-incrementing surrogate primary key. */
serial: (name: string) => AnyColumnBuilder;
bool: (name: string) => AnyColumnBuilder;
real: (name: string) => AnyColumnBuilder;
}
// drizzle's builders are heavily generic; the schema modules keep their own
// precise types, so this alias only exists to describe the kit's shape.
type AnyColumnBuilder = ReturnType<typeof sqlite.text>;
const DEFAULT_KEY_LENGTH = 255;
export const sqliteKit = {
table: sqlite.sqliteTable,
text: (name: string) => sqlite.text(name),
shortText: (name: string) => sqlite.text(name),
int: (name: string) => sqlite.integer(name),
serial: (name: string) =>
sqlite.integer(name).primaryKey({ autoIncrement: true }),
bool: (name: string) => sqlite.integer(name, { mode: "boolean" }),
real: (name: string) => sqlite.real(name),
} as const;
export const pgKit = {
table: pg.pgTable,
text: (name: string) => pg.text(name),
shortText: (name: string, length = DEFAULT_KEY_LENGTH) =>
pg.varchar(name, { length }),
int: (name: string) => pg.integer(name),
serial: (name: string) => pg.serial(name).primaryKey(),
bool: (name: string) => pg.boolean(name),
real: (name: string) => pg.doublePrecision(name),
} as const;
export const mysqlKit = {
table: mysql.mysqlTable,
text: (name: string) => mysql.text(name),
shortText: (name: string, length = DEFAULT_KEY_LENGTH) =>
mysql.varchar(name, { length }),
int: (name: string) => mysql.int(name),
serial: (name: string) => mysql.int(name).autoincrement().primaryKey(),
bool: (name: string) => mysql.boolean(name),
real: (name: string) => mysql.double(name),
} as const;
+74
View File
@@ -0,0 +1,74 @@
import type { DatabaseDialect } from "./dialect.js";
import type { PortableDatabase } from "../repositories/database-context.js";
export const DATABASE_URL_ENV = "DATABASE_URL";
/**
* Opens a connection to a client-server engine.
*
* SQLite is not handled here — it has its own lifecycle in db/index.ts, where
* the database is decrypted into memory and serialised back to a file. This
* covers the engines that connect to something already running.
*
* The returned handle is typed as PortableDatabase; see the note there on why
* that is an approximation and what guarantees it.
*/
export function databaseUrl(env: NodeJS.ProcessEnv = process.env): string | null {
const url = env[DATABASE_URL_ENV]?.trim();
return url ? url : null;
}
/**
* Checks the connection string suits the configured engine before trying to
* open it, so a mismatch fails with something readable rather than a driver
* error thirty frames down.
*/
export function assertUrlMatchesDialect(
url: string,
dialect: DatabaseDialect,
): void {
const scheme = url.split("://", 1)[0].toLowerCase();
const expected: Record<string, readonly string[]> = {
postgres: ["postgres", "postgresql"],
mysql: ["mysql", "mariadb"],
};
const allowed = expected[dialect];
if (!allowed) {
throw new Error(`${dialect} does not use ${DATABASE_URL_ENV}`);
}
if (!allowed.includes(scheme)) {
throw new Error(
`${DATABASE_URL_ENV} is a "${scheme}://" URL but DATABASE_DIALECT is "${dialect}". ` +
`Expected one of ${allowed.map((s) => `${s}://`).join(", ")}.`,
);
}
}
export async function connectRemoteDatabase(
dialect: DatabaseDialect,
env: NodeJS.ProcessEnv = process.env,
): Promise<PortableDatabase> {
const url = databaseUrl(env);
if (!url) {
throw new Error(
`${DATABASE_URL_ENV} must be set when DATABASE_DIALECT is "${dialect}".`,
);
}
assertUrlMatchesDialect(url, dialect);
// No `schema` option: it only feeds drizzle's relational query API
// (`db.query.*`), which nothing here uses. The query builder takes its table
// names and value encoders from the table objects the repositories import —
// see the note in schema.pg.ts on why the generated schemas are DDL-only.
if (dialect === "postgres") {
const { drizzle } = await import("drizzle-orm/node-postgres");
return drizzle(url) as unknown as PortableDatabase;
}
const { drizzle } = await import("drizzle-orm/mysql2");
return drizzle(url) as unknown as PortableDatabase;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Which engine the schema and repositories are built against.
*
* SQLite is not going away: the desktop app embeds its backend and cannot ship
* a database server, so it will always run on SQLite. Postgres and MySQL are
* for self-hosted deployments that need more than one process to reach the
* data. This is a multi-backend story, not a migration off SQLite.
*/
export type DatabaseDialect = "sqlite" | "postgres" | "mysql";
export const DATABASE_DIALECT_ENV = "DATABASE_DIALECT";
const SUPPORTED: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"];
export function isDatabaseDialect(value: unknown): value is DatabaseDialect {
return (
typeof value === "string" &&
(SUPPORTED as readonly string[]).includes(value)
);
}
/**
* Resolves the configured dialect, defaulting to SQLite so existing
* deployments and the desktop build are unaffected by this being added.
*/
export function resolveDatabaseDialect(
env: NodeJS.ProcessEnv = process.env,
): DatabaseDialect {
const raw = env[DATABASE_DIALECT_ENV]?.trim().toLowerCase();
if (!raw) return "sqlite";
if (!isDatabaseDialect(raw)) {
throw new Error(
`Unsupported ${DATABASE_DIALECT_ENV}: "${raw}". Expected one of ${SUPPORTED.join(", ")}.`,
);
}
return raw;
}
/**
* Whether a write has to be explicitly persisted after it commits.
*
* SQLite here is an in-memory database serialised back to an encrypted file, so
* every write needs a trigger to flush it. Client-server engines have already
* durably committed by the time the query returns — there is no file to write
* and nothing to schedule.
*/
export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
return dialect === "sqlite";
}
+133 -9
View File
@@ -7,8 +7,21 @@ import { databaseLogger } from "../../utils/logger.js";
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { DatabaseMigration } from "../../utils/database-migration.js";
import {
ensureSharedHostAuthOverrideProtocolSchema,
migrateLegacySharedHostAuthOverrides,
} from "../../utils/shared-host-auth-override-migration.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { migrateAuditRetention } from "../../utils/audit-retention-migration.js";
import {
assertDataDirIsNotMisconfigured,
DataDirMisconfiguredError,
} from "../../utils/data-dir-guard.js";
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
import { connectRemoteDatabase } from "./connect.js";
import { runRemoteMigrations } from "./migrate.js";
import type { PortableDatabase } from "../repositories/database-context.js";
const dataDir = process.env.DATA_DIR || "./db/data";
const dbDir = path.resolve(dataDir);
@@ -104,11 +117,16 @@ async function initializeDatabaseAsync(): Promise<void> {
);
}
} else {
assertDataDirIsNotMisconfigured(dataDir);
memoryDatabase = new Database(":memory:");
isNewDatabase = true;
}
}
} catch (error) {
// Not a decryption problem: the database is fine, we are pointed at the
// wrong directory. Surface that message as-is.
if (error instanceof DataDirMisconfiguredError) throw error;
databaseLogger.error("Failed to initialize memory database", error, {
operation: "db_memory_init_failed",
errorMessage: error instanceof Error ? error.message : "Unknown error",
@@ -145,8 +163,35 @@ async function initializeDatabaseAsync(): Promise<void> {
);
}
} else {
memoryDatabase = new Database(":memory:");
isNewDatabase = true;
assertDataDirIsNotMisconfigured(dataDir);
// The database still lives in memory and is serialised out on every write;
// turning encryption off only changes whether that file is ciphertext. It
// has to be read back, or each restart starts empty and silently discards
// everything the previous run saved.
const existing = readPlainDatabaseFile();
if (existing) {
memoryDatabase = new Database(existing);
databaseLogger.info("Loaded unencrypted database from disk", {
operation: "db_load_plain",
path: dbPath,
bytes: existing.length,
});
} else {
memoryDatabase = new Database(":memory:");
isNewDatabase = true;
}
}
}
/** The plain database file, or null when there is nothing to restore. */
function readPlainDatabaseFile(): Buffer | null {
try {
const contents = fs.readFileSync(dbPath);
return contents.length > 0 ? contents : null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
@@ -472,13 +517,14 @@ async function initializeCompleteDatabase(): Promise<void> {
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
user_id TEXT,
username TEXT,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
@@ -491,7 +537,7 @@ async function initializeCompleteDatabase(): Promise<void> {
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
@@ -1418,6 +1464,28 @@ const migrateSchema = () => {
}
}
try {
ensureSharedHostAuthOverrideProtocolSchema(sqlite);
} catch (schemaError) {
databaseLogger.warn("Failed to prepare shared_host_auth_overrides table", {
operation: "schema_migration",
error: schemaError,
});
}
try {
migrateLegacySharedHostAuthOverrides(
sqlite,
getRawSettingValue,
setRawSettingValue,
);
} catch (migrateError) {
databaseLogger.warn("Failed to migrate shared host auth overrides", {
operation: "schema_migration",
error: migrateError,
});
}
try {
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
} catch {
@@ -1448,6 +1516,7 @@ const migrateSchema = () => {
{ column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" },
{ column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" },
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
{ column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" },
{ column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" },
@@ -1637,7 +1706,7 @@ const migrateSchema = () => {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
user_id TEXT,
username TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
@@ -1649,7 +1718,7 @@ const migrateSchema = () => {
success INTEGER NOT NULL,
error_message TEXT,
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
);
`);
} catch (createError) {
@@ -2497,6 +2566,10 @@ const migrateSchema = () => {
}
// --- sync end ---
// Audit trails and session recordings used to be deleted along with the user
// they referenced, which defeats the point of keeping them.
migrateAuditRetention(sqlite);
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
@@ -2580,10 +2653,54 @@ async function handlePostInitFileEncryption() {
}
async function initializeDatabase(): Promise<void> {
const dialect = resolveDatabaseDialect();
if (dialect !== "sqlite") {
await initializeRemoteDatabase(dialect);
return;
}
await initializeCompleteDatabase();
await handlePostInitFileEncryption();
}
/**
* Startup against Postgres or MySQL.
*
* Shorter than the SQLite path because most of what that one does has no
* counterpart here: there is no file to decrypt, no in-memory copy to keep in
* step with disk, and the schema comes from drizzle-kit migrations instead of
* the inline DDL below.
*
* What does carry over is the settings cache. 27 call sites read settings
* synchronously, which better-sqlite3 allows and no remote driver does, so the
* table is loaded once here before anything asks for it.
*/
async function initializeRemoteDatabase(
dialect: Exclude<DatabaseDialect, "sqlite">,
): Promise<void> {
databaseLogger.info(`Connecting to ${dialect} database`, {
operation: "db_init",
dialect,
});
db = await connectRemoteDatabase(dialect);
await runRemoteMigrations(dialect, db);
// Imported here rather than at the top: factory.ts imports getDb from this
// module, and a static import would close the cycle at module-load time.
const { primeCurrentSettingsCache, startSettingsCacheRefresh } = await import(
"../repositories/factory.js"
);
await primeCurrentSettingsCache();
startSettingsCacheRefresh();
databaseLogger.info(`${dialect} database ready`, {
operation: "db_init_complete",
dialect,
});
}
export { initializeDatabase };
async function cleanupDatabase() {
@@ -2661,9 +2778,9 @@ process.on("SIGTERM", async () => {
process.exit(0);
});
let db: ReturnType<typeof drizzle<typeof schema>>;
let db: PortableDatabase;
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
export function getDb(): PortableDatabase {
if (!db) {
throw new Error(
"Database not initialized. Ensure initializeDatabase() is called before accessing db.",
@@ -2674,6 +2791,13 @@ export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
export function getSqlite(): Database.Database {
if (!sqlite) {
const dialect = resolveDatabaseDialect();
if (dialect !== "sqlite") {
throw new Error(
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
`synchronous query, which only SQLite offers — give it an async path instead.`,
);
}
throw new Error(
"SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
);
+51
View File
@@ -0,0 +1,51 @@
import path from "path";
import type { DatabaseDialect } from "./dialect.js";
import type { PortableDatabase } from "../repositories/database-context.js";
export const MIGRATIONS_DIR_ENV = "DRIZZLE_MIGRATIONS_DIR";
/**
* Where the generated migrations live.
*
* SQLite does not appear here: it builds its schema from the DDL in index.ts
* and patches it forward with migrateSchema(). Only the client-server engines
* use drizzle-kit migrations, and each has its own folder because the
* generated SQL differs per dialect.
*/
export function migrationsFolder(
dialect: DatabaseDialect,
env: NodeJS.ProcessEnv = process.env,
): string {
const override = env[MIGRATIONS_DIR_ENV]?.trim();
const root = override || path.resolve(process.cwd(), "drizzle");
return path.join(root, dialect);
}
/**
* Brings a remote database up to the current schema.
*
* drizzle's migrator records what it has applied in its own table, so this is
* safe to run on every start — including against a database another instance
* already migrated.
*/
export async function runRemoteMigrations(
dialect: DatabaseDialect,
db: PortableDatabase,
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
if (dialect === "sqlite") {
throw new Error("SQLite builds its schema in index.ts, not from drizzle/");
}
const folder = migrationsFolder(dialect, env);
const { migrate } =
dialect === "postgres"
? await import("drizzle-orm/node-postgres/migrator")
: await import("drizzle-orm/mysql2/migrator");
await (migrate as (db: unknown, config: { migrationsFolder: string }) => Promise<void>)(
db,
{ migrationsFolder: folder },
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+186 -100
View File
@@ -1,4 +1,10 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import {
sqliteTable,
text,
integer,
real,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const users = sqliteTable("users", {
@@ -124,6 +130,9 @@ export const hosts = sqliteTable("ssh_data", {
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false),
shareSshAuth: integer("share_ssh_auth", { mode: "boolean" })
.notNull()
.default(false),
forceKeyboardInteractive: text("force_keyboard_interactive"),
password: text("password"),
@@ -560,46 +569,85 @@ export const hostAccess = sqliteTable("host_access", {
.default(sql`CURRENT_TIMESTAMP`),
lastAccessedAt: text("last_accessed_at"),
accessCount: integer("access_count").notNull().default(0),
overrideCredentialId: integer("override_credential_id").references(
() => sshCredentials.id,
{ onDelete: "set null" },
),
});
export const sharedHostSecrets = sqliteTable("shared_host_secrets", {
id: integer("id").primaryKey({ autoIncrement: true }),
export const sharedHostAuthOverrides = sqliteTable(
"shared_host_auth_overrides",
{
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
protocol: text("protocol").notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_host_auth_overrides_host_user_protocol_unique").on(
table.hostId,
table.userId,
table.protocol,
),
],
);
hostAccessId: integer("host_access_id")
.notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }),
export const sharedHostSecrets = sqliteTable(
"shared_host_secrets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostAccessId: integer("host_access_id")
.notNull()
.references(() => hostAccess.id, { onDelete: "cascade" }),
protocol: text("protocol").notNull().default("ssh"),
sourceType: text("source_type").notNull().default("credential"),
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
originalCredentialId: integer("original_credential_id").references(
() => sshCredentials.id,
{ onDelete: "cascade" },
),
protocol: text("protocol").notNull().default("ssh"),
sourceType: text("source_type").notNull().default("credential"),
encryptedUsername: text("encrypted_username"),
encryptedAuthType: text("encrypted_auth_type"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
originalCredentialId: integer("original_credential_id").references(
() => sshCredentials.id,
{ onDelete: "cascade" },
),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
encryptedUsername: text("encrypted_username"),
encryptedAuthType: text("encrypted_auth_type"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
table.targetUserId,
table.protocol,
),
],
);
export const roles = sqliteTable("roles", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -621,29 +669,36 @@ export const roles = sqliteTable("roles", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const userRoles = sqliteTable("user_roles", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id")
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
grantedBy: text("granted_by").references(() => users.id, {
onDelete: "set null",
}),
grantedAt: text("granted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const userRoles = sqliteTable(
"user_roles",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id")
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
grantedBy: text("granted_by").references(() => users.id, {
onDelete: "set null",
}),
grantedAt: text("granted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [uniqueIndex("idx_user_roles_user_role").on(table.userId, table.roleId)],
);
export const auditLogs = sqliteTable("audit_logs", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Nullable on purpose: the trail outlives the account, and username keeps the
// entry attributable once the reference is gone.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
action: text("action").notNull(),
@@ -669,9 +724,10 @@ export const sessionRecordings = sqliteTable("session_recordings", {
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Nullable on purpose: a recording is evidence about the host as much as the
// person, so it outlives the account. username keeps it attributable.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username"),
accessId: integer("access_id").references(() => hostAccess.id, {
onDelete: "set null",
}),
@@ -750,29 +806,36 @@ export const sessionShareParticipants = sqliteTable(
},
);
export const opksshTokens = sqliteTable("opkssh_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
email: text("email"),
sub: text("sub"),
issuer: text("issuer"),
audience: text("audience"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at").notNull(),
lastUsed: text("last_used"),
});
export const opksshTokens = sqliteTable(
"opkssh_tokens",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
email: text("email"),
sub: text("sub"),
issuer: text("issuer"),
audience: text("audience"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at").notNull(),
lastUsed: text("last_used"),
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [uniqueIndex("idx_opkssh_tokens_user_host").on(table.userId, table.hostId)],
);
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
// are intended to be shared across users (shared === true makes a profile
@@ -813,24 +876,31 @@ export const vaultProfiles = sqliteTable("vault_profiles", {
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
// Transient: rows live only until the certificate expires. Secret fields are
// encrypted under the user's data-encryption key (see field-crypto.ts).
export const vaultTokens = sqliteTable("vault_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
profileId: integer("profile_id")
.notNull()
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at").notNull(),
lastUsed: text("last_used"),
});
export const vaultTokens = sqliteTable(
"vault_tokens",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
profileId: integer("profile_id")
.notNull()
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at").notNull(),
lastUsed: text("last_used"),
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
(table) => [uniqueIndex("idx_vault_tokens_user_profile").on(table.userId, table.profileId)],
);
export const apiKeys = sqliteTable("api_keys", {
id: text("id").primaryKey(),
@@ -898,7 +968,9 @@ export const userPreferences = sqliteTable("user_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", {
export const hostMetricsPreferences = sqliteTable(
"host_metrics_preferences",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
@@ -915,9 +987,18 @@ export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", {
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
},
// One layout per user per host. Enforced in production since the inline DDL
// creates it, but it was never declared here, so the generated Postgres and
// MySQL schemas lacked it — and the upsert has nothing to conflict on.
(table) => [
uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthChecks = sqliteTable("host_health_checks", {
export const hostHealthChecks = sqliteTable(
"host_health_checks",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
@@ -934,7 +1015,12 @@ export const hostHealthChecks = sqliteTable("host_health_checks", {
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
},
// Same as above: one set of checks per user per host.
(table) => [
uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthHistory = sqliteTable("host_health_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -1,4 +1,4 @@
import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { and, count, desc, eq, inArray, isNull, lt, or } from "drizzle-orm";
import {
alertFirings,
alertRuleChannels,
@@ -7,6 +7,9 @@ import {
notificationChannels,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type AlertRuleRecord = typeof alertRules.$inferSelect;
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
@@ -117,16 +120,17 @@ export class AlertRepository {
config: string;
enabled: boolean;
}): Promise<NotificationChannelRow> {
const [created] = await this.context.drizzle
.insert(notificationChannels)
.values({
const [created] = await insertReturning(
this.context,
notificationChannels,
{
userId: input.userId,
name: input.name,
type: input.type,
config: input.config,
enabled: input.enabled,
})
.returning();
},
);
await this.afterWrite();
return mapChannelRow(created);
@@ -146,16 +150,15 @@ export class AlertRepository {
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();
const [updated] = await updateReturning(
this.context,
notificationChannels,
input,
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
),
);
if (!updated) return null;
await this.afterWrite();
@@ -166,17 +169,16 @@ export class AlertRepository {
id: number,
userId: string,
): Promise<boolean> {
const deleted = await this.context.drizzle
const result = 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;
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
@@ -210,21 +212,18 @@ export class AlertRepository {
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 [created] = await insertReturning(this.context, alertRules, {
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,
});
const channels = await this.replaceRuleChannels(
created.id,
@@ -263,9 +262,10 @@ export class AlertRepository {
now: string;
},
): Promise<AlertRuleWithChannelsRow | null> {
const [updated] = await this.context.drizzle
.update(alertRules)
.set({
const [updated] = await updateReturning(
this.context,
alertRules,
{
...(input.name !== undefined ? { name: input.name } : {}),
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
@@ -282,9 +282,9 @@ export class AlertRepository {
? { cooldownMinutes: input.cooldownMinutes }
: {}),
updatedAt: input.now,
})
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.returning();
},
and(eq(alertRules.id, id), eq(alertRules.userId, userId)),
);
if (!updated) return null;
@@ -298,12 +298,11 @@ export class AlertRepository {
}
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
const deleted = await this.context.drizzle
const result = await this.context.drizzle
.delete(alertRules)
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
.returning({ id: alertRules.id });
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)));
if (deleted.length === 0) return false;
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
@@ -411,12 +410,15 @@ export class AlertRepository {
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 pruneFiringsOlderThan(userId: string, days: number): Promise<void> {
await this.context.drizzle
.delete(alertFirings)
.where(
and(
eq(alertFirings.userId, userId),
lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)),
),
);
}
async deleteByUserId(userId: string): Promise<{
@@ -438,10 +440,9 @@ export class AlertRepository {
.where(eq(notificationChannels.userId, userId))
).map((row) => row.id);
const firingRows = await this.context.drizzle
const firingResult = await this.context.drizzle
.delete(alertFirings)
.where(eq(alertFirings.userId, userId))
.returning({ id: alertFirings.id });
.where(eq(alertFirings.userId, userId));
const linkFilters = [
...(ruleIds.length > 0
@@ -451,37 +452,34 @@ export class AlertRepository {
? [inArray(alertRuleChannels.channelId, channelIds)]
: []),
];
const linkRows =
const linkResult =
linkFilters.length === 0
? []
? null
: await this.context.drizzle
.delete(alertRuleChannels)
.where(or(...linkFilters))
.returning({ id: alertRuleChannels.id });
.where(or(...linkFilters));
const ruleRows = await this.context.drizzle
const ruleResult = await this.context.drizzle
.delete(alertRules)
.where(eq(alertRules.userId, userId))
.returning({ id: alertRules.id });
const channelRows = await this.context.drizzle
.where(eq(alertRules.userId, userId));
const result = await this.context.drizzle
.delete(notificationChannels)
.where(eq(notificationChannels.userId, userId))
.returning({ id: notificationChannels.id });
.where(eq(notificationChannels.userId, userId));
if (
firingRows.length > 0 ||
linkRows.length > 0 ||
ruleRows.length > 0 ||
channelRows.length > 0
rowsAffected(firingResult) > 0 ||
rowsAffected(linkResult) > 0 ||
rowsAffected(ruleResult) > 0 ||
rowsAffected(result) > 0
) {
await this.afterWrite();
}
return {
firingsDeleted: firingRows.length,
ruleLinksDeleted: linkRows.length,
rulesDeleted: ruleRows.length,
channelsDeleted: channelRows.length,
firingsDeleted: rowsAffected(firingResult),
ruleLinksDeleted: rowsAffected(linkResult),
rulesDeleted: rowsAffected(ruleResult),
channelsDeleted: rowsAffected(result),
};
}
@@ -1,6 +1,8 @@
import { eq, and } from "drizzle-orm";
import { apiKeys, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { deleteReturning, insertReturning } from "./returning.js";
export type ApiKeyRecord = typeof apiKeys.$inferSelect;
export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
@@ -24,10 +26,7 @@ export class ApiKeyRepository {
) {}
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
const rows = await this.context.drizzle
.insert(apiKeys)
.values(apiKey)
.returning();
const rows = await insertReturning(this.context, apiKeys, apiKey);
await this.afterWrite();
return rows[0];
}
@@ -78,23 +77,23 @@ export class ApiKeyRepository {
}
async delete(id: string): Promise<ApiKeyRecord | null> {
const rows = await this.context.drizzle
.delete(apiKeys)
.where(eq(apiKeys.id, id))
.returning();
const rows = await deleteReturning(
this.context,
apiKeys,
eq(apiKeys.id, id),
);
await this.afterWrite();
return rows[0] ?? null;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(apiKeys)
.where(eq(apiKeys.userId, userId))
.returning({ id: apiKeys.id });
.where(eq(apiKeys.userId, userId));
await this.afterWrite();
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,9 @@
import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
import { and, asc, desc, eq, gte, inArray, lt, lte, sql } from "drizzle-orm";
import { auditLogs } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
import { databaseLogger } from "../../utils/logger.js";
import { countValue, rowsAffected } from "./mutation-result.js";
export type AuditLogRecord = typeof auditLogs.$inferSelect;
export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
@@ -19,8 +22,31 @@ export type AuditLogPage = {
total: number;
};
const PRUNE_MAX = 10000;
const PRUNE_TARGET = 9000;
export const AUDIT_RETENTION_DAYS_ENV = "AUDIT_LOG_RETENTION_DAYS";
export const AUDIT_MAX_ENTRIES_ENV = "AUDIT_LOG_MAX_ENTRIES";
const DEFAULT_MAX_ENTRIES = 10000;
const PRUNE_TARGET_RATIO = 0.9;
function positiveIntEnv(key: string, env: NodeJS.ProcessEnv): number | null {
const raw = Number(env[key]);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null;
}
/**
* How long entries are kept. Unset means "no time limit", in which case only
* the row cap applies.
*/
export function auditRetentionDays(
env: NodeJS.ProcessEnv = process.env,
): number | null {
return positiveIntEnv(AUDIT_RETENTION_DAYS_ENV, env);
}
/** Hard ceiling on stored entries, so a busy install cannot fill the disk. */
export function auditMaxEntries(env: NodeJS.ProcessEnv = process.env): number {
return positiveIntEnv(AUDIT_MAX_ENTRIES_ENV, env) ?? DEFAULT_MAX_ENTRIES;
}
export class AuditLogRepository {
constructor(
@@ -57,10 +83,31 @@ export class AuditLogRepository {
return {
logs,
total: totalResult[0]?.count ?? 0,
total: countValue(totalResult[0]?.count),
};
}
/**
* Reads matching entries in ascending time order for export.
*
* Paged rather than fetched whole so an export cannot pull an unbounded
* result set into memory, and ascending so a resumed or appended export
* continues where the previous one stopped.
*/
async listForExport(input: {
filters: AuditLogFilters;
limit: number;
offset: number;
}): Promise<AuditLogRecord[]> {
return this.context.drizzle
.select()
.from(auditLogs)
.where(this.buildWhere(input.filters))
.orderBy(asc(auditLogs.timestamp), asc(auditLogs.id))
.limit(input.limit)
.offset(input.offset);
}
async listDistinctActions(): Promise<string[]> {
const rows = await this.context.drizzle
.selectDistinct({ action: auditLogs.action })
@@ -70,17 +117,38 @@ export class AuditLogRepository {
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 });
/**
* Detaches entries from a user being deleted instead of removing them.
*
* The schema already relaxed this foreign key to ON DELETE SET NULL, but the
* account-deletion path deletes the rows explicitly, which undoes that. An
* audit trail that vanishes with the account it recorded cannot answer the
* question it exists for, and offboarding is exactly when that question gets
* asked. `username` is denormalised, so the entry stays attributable.
*/
async anonymizeByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.update(auditLogs)
.set({ userId: null })
.where(eq(auditLogs.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(auditLogs)
.where(eq(auditLogs.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private buildWhere(filters: AuditLogFilters) {
@@ -105,28 +173,73 @@ export class AuditLogRepository {
}
private async pruneIfNeeded(): Promise<void> {
await this.pruneExpired();
await this.pruneOverflow();
}
/** Drops entries past the configured retention window. */
private async pruneExpired(): Promise<void> {
const days = auditRetentionDays();
if (days === null) return;
const cutoff = sqlTimestampDaysAgo(days);
const result = await this.context.drizzle
.delete(auditLogs)
.where(lt(auditLogs.timestamp, cutoff));
if (rowsAffected(result) > 0) {
databaseLogger.info(
`Pruned ${rowsAffected(result)} audit entries past retention`,
{
operation: "audit_retention_prune",
removed: rowsAffected(result),
retentionDays: days,
cutoff,
},
);
}
}
/**
* Enforces the row cap. Unlike retention this discards entries that are still
* within the window, so it is reported as a warning: it means the ceiling is
* too low for how much this install audits, and evidence is being lost.
*/
private async pruneOverflow(): Promise<void> {
const max = auditMaxEntries();
const countResult = await this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs);
const count = countResult[0]?.count ?? 0;
const count = countValue(countResult[0]?.count);
if (count < PRUNE_MAX) {
return;
}
if (count < max) return;
const deleteCount = count - PRUNE_TARGET;
const deleteCount = count - Math.floor(max * PRUNE_TARGET_RATIO);
const rows = await this.context.drizzle
.select({ id: auditLogs.id })
.select({ id: auditLogs.id, timestamp: auditLogs.timestamp })
.from(auditLogs)
.orderBy(asc(auditLogs.timestamp))
.limit(deleteCount);
const ids = rows.map((row) => row.id);
if (rows.length === 0) return;
if (ids.length > 0) {
await this.context.drizzle
.delete(auditLogs)
.where(inArray(auditLogs.id, ids));
}
await this.context.drizzle.delete(auditLogs).where(
inArray(
auditLogs.id,
rows.map((row) => row.id),
),
);
databaseLogger.warn(
`Audit log hit its ${max}-entry cap; discarded ${rows.length} entries`,
{
operation: "audit_overflow_prune",
removed: rows.length,
maxEntries: max,
oldestRemoved: rows[0]?.timestamp,
newestRemoved: rows[rows.length - 1]?.timestamp,
hint: `Raise ${AUDIT_MAX_ENTRIES_ENV}, or set ${AUDIT_RETENTION_DAYS_ENV} and export older entries before they are dropped.`,
},
);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, asc, eq, sql } from "drizzle-orm";
import { c2sTunnelPresets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
@@ -64,16 +66,13 @@ export class C2sTunnelPresetRepository {
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();
const [created] = await insertReturning(this.context, c2sTunnelPresets, {
userId,
name: input.name,
config: input.config,
platform: input.platform ?? null,
computerName: input.computerName ?? null,
});
await this.afterWrite();
return created;
@@ -84,16 +83,15 @@ export class C2sTunnelPresetRepository {
id: number,
updates: C2sTunnelPresetUpdateInput,
): Promise<C2sTunnelPresetRecord | null> {
const [updated] = await this.context.drizzle
.update(c2sTunnelPresets)
.set({
const [updated] = await updateReturning(
this.context,
c2sTunnelPresets,
{
...updates,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
)
.returning();
},
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (updated) {
await this.afterWrite();
@@ -103,31 +101,29 @@ export class C2sTunnelPresetRepository {
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
)
.returning({ id: c2sTunnelPresets.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.returning({ id: c2sTunnelPresets.id });
.where(eq(c2sTunnelPresets.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { commandHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
@@ -16,10 +18,12 @@ export class CommandHistoryRepository {
command: string,
executedAt = new Date().toISOString(),
): Promise<CommandHistoryRecord> {
const [created] = await this.context.drizzle
.insert(commandHistory)
.values({ userId, hostId, command, executedAt })
.returning();
const [created] = await insertReturning(this.context, commandHistory, {
userId,
hostId,
command,
executedAt,
});
await this.afterWrite();
return created;
}
@@ -76,7 +80,7 @@ export class CommandHistoryRepository {
hostId: number,
command: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
@@ -84,45 +88,42 @@ export class CommandHistoryRepository {
eq(commandHistory.hostId, hostId),
eq(commandHistory.command, command),
),
)
.returning({ id: commandHistory.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(
and(
eq(commandHistory.userId, userId),
eq(commandHistory.hostId, hostId),
),
)
.returning({ id: commandHistory.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.hostId, hostId))
.returning({ id: commandHistory.id });
.where(eq(commandHistory.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -130,29 +131,27 @@ export class CommandHistoryRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(inArray(commandHistory.hostId, hostIds))
.returning({ id: commandHistory.id });
.where(inArray(commandHistory.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(commandHistory)
.where(eq(commandHistory.userId, userId))
.returning({ id: commandHistory.id });
.where(eq(commandHistory.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type CredentialRecord = typeof sshCredentials.$inferSelect;
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
@@ -17,10 +23,10 @@ export class CredentialRepository {
) {}
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
const rows = await this.context.drizzle
.insert(sshCredentials)
.values({ syncId: randomUUID(), ...credential })
.returning();
const rows = await insertReturning(this.context, sshCredentials, {
syncId: randomUUID(),
...credential,
});
await this.afterWrite();
return rows[0];
}
@@ -46,10 +52,11 @@ export class CredentialRepository {
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
}
const rows = await this.context.drizzle
.insert(sshCredentials)
.values(encryptedCredential as NewCredentialRecord)
.returning();
const rows = await insertReturning(
this.context,
sshCredentials,
encryptedCredential as NewCredentialRecord,
);
await this.afterWrite();
return DataCrypto.decryptRecord(
@@ -143,7 +150,7 @@ export class CredentialRepository {
oldName: string,
newName: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(sshCredentials)
.set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
@@ -151,14 +158,13 @@ export class CredentialRepository {
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
),
)
.returning({ id: sshCredentials.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async updateForUser(
@@ -166,16 +172,15 @@ export class CredentialRepository {
credentialId: number,
update: CredentialUpdate,
): Promise<CredentialRecord | null> {
const rows = await this.context.drizzle
.update(sshCredentials)
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning();
const rows = await updateReturning(
this.context,
sshCredentials,
{ ...update, updatedAt: sql`CURRENT_TIMESTAMP` },
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await this.afterWrite();
return rows[0] ?? null;
@@ -193,16 +198,15 @@ export class CredentialRepository {
userDataKey,
);
const rows = await this.context.drizzle
.update(sshCredentials)
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning();
const rows = await updateReturning(
this.context,
sshCredentials,
{ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` },
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await this.afterWrite();
return this.decryptOne(rows[0] ?? null, userId);
@@ -212,31 +216,29 @@ export class CredentialRepository {
userId: string,
credentialId: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
)
.returning({ syncId: sshCredentials.syncId });
const rows = await deleteReturning(
this.context,
sshCredentials,
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId),
),
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? { syncId: rows[0].syncId } : null;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.returning({ id: sshCredentials.id });
.where(eq(sshCredentials.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async recordUsage(
@@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { dashboardServiceLinks } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type DashboardServiceLinkRecord =
typeof dashboardServiceLinks.$inferSelect;
@@ -38,9 +44,10 @@ export class DashboardServiceLinkRepository {
const nextOrder =
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
const [created] = await this.context.drizzle
.insert(dashboardServiceLinks)
.values({
const [created] = await insertReturning(
this.context,
dashboardServiceLinks,
{
syncId: randomUUID(),
userId,
label: input.label,
@@ -48,8 +55,8 @@ export class DashboardServiceLinkRepository {
order: nextOrder,
createdAt,
updatedAt: createdAt,
})
.returning();
},
);
await this.afterWrite();
return created;
}
@@ -77,16 +84,15 @@ export class DashboardServiceLinkRepository {
id: number,
updates: DashboardServiceLinkUpdate,
): Promise<DashboardServiceLinkRecord | null> {
const [updated] = await this.context.drizzle
.update(dashboardServiceLinks)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
)
.returning();
const [updated] = await updateReturning(
this.context,
dashboardServiceLinks,
{ ...updates, updatedAt: new Date().toISOString() },
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (updated) {
await this.afterWrite();
@@ -99,32 +105,30 @@ export class DashboardServiceLinkRepository {
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(dashboardServiceLinks)
.where(
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
)
.returning({ syncId: dashboardServiceLinks.syncId });
const rows = await deleteReturning(
this.context,
dashboardServiceLinks,
and(
eq(dashboardServiceLinks.id, id),
eq(dashboardServiceLinks.userId, userId),
),
);
if (rows.length === 0) return null;
await this.afterWrite();
return rows[0];
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(dashboardServiceLinks)
.where(eq(dashboardServiceLinks.userId, userId))
.returning({ id: dashboardServiceLinks.id });
.where(eq(dashboardServiceLinks.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,9 +1,41 @@
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type { Database as BetterSqliteDatabase } from "better-sqlite3";
import type * as schema from "../db/schema.js";
// Re-exported so repositories can keep importing it from here, but defined in
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and
// typed every context as SQLite-only while the runtime already carried all
// three, which silently made the dialect branches unreachable to the checker.
export type { DatabaseDialect } from "../db/dialect.js";
import type { DatabaseDialect } from "../db/dialect.js";
/**
* The database handle repositories work against.
*
* Typed as the SQLite instance on purpose. drizzle's three Database classes
* share no base class and their signatures are incompatible: a union is not
* callable, and a generic would have to be threaded through all 43
* repositories and every method on them.
*
* This is a deliberate approximation, not an accident. The query-builder
* surface the repositories actually use is the same on all three engines, and
* that equivalence is asserted in multi-dialect.test.ts rather than assumed —
* identifier quoting, placeholder style and value coercion are all covered
* there. At runtime this may hold a Postgres or MySQL instance.
*
* The one place the surfaces genuinely differ is RETURNING, which MySQL lacks;
* see mutation-result.ts for how that is absorbed.
*/
export type PortableDatabase = BetterSQLite3Database<typeof schema>;
/**
* What a repository is allowed to touch.
*
* Deliberately drizzle-only: with no raw driver handle here, no repository can
* reach for engine-specific SQL. Retention queries that previously needed
* `datetime('now', ?)` compute their cutoff in JS instead — see
* ./sql-timestamp.ts.
*/
export interface DatabaseContext {
dialect: "sqlite";
drizzle: BetterSQLite3Database<typeof schema>;
sqlite?: BetterSqliteDatabase;
dialect: DatabaseDialect;
drizzle: PortableDatabase;
}
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { dismissedAlerts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
@@ -72,34 +73,32 @@ export class DismissedAlertRepository {
}
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
)
.returning({ id: dismissedAlerts.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId))
.returning({ id: dismissedAlerts.id });
.where(eq(dismissedAlerts.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
+118 -3
View File
@@ -1,5 +1,7 @@
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { getDb, getSqlite } from "../db/index.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AlertRepository } from "./alert-repository.js";
@@ -29,6 +31,7 @@ import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js";
import { SessionShareRepository } from "./session-share-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
@@ -45,25 +48,62 @@ import { UserRepository } from "./user-repository.js";
import { VaultProfileRepository } from "./vault-profile-repository.js";
import { VaultTokenRepository } from "./vault-token-repository.js";
/**
* The context every repository runs against.
*
* The dialect has to be resolved, not assumed: it is what `returning.ts` reads
* to decide whether it can ask for RETURNING, and whether an upsert spells
* itself `onConflictDoUpdate` or `onDuplicateKeyUpdate`. Reporting "sqlite"
* while connected to MySQL makes the second of those a TypeError on the first
* write.
*
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither
* exercises this function — see tests/database/repositories/factory-context.
*/
export function createCurrentRepositoryContext(): DatabaseContext {
return {
dialect: "sqlite",
dialect: resolveDatabaseDialect(),
drizzle: getDb(),
sqlite: getSqlite(),
};
}
/**
* Post-write hook handed to every repository.
*
* Only meaningful for SQLite, where the database lives in memory and has to be
* serialised back to its encrypted file. On Postgres and MySQL the write is
* already durable, so no hook is installed at all rather than one that does
* nothing — repositories call it as `this.onWrite?.()`.
*/
export function createCurrentRepositoryWriteHook(
reason: string,
): () => Promise<void> {
): (() => Promise<void>) | undefined {
if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined;
return () => DatabaseSaveTrigger.forceSave(reason);
}
/**
* Raw driver handle for the few synchronous call sites that cannot await —
* getCurrentSettingValue below, and settings reads during startup. Repositories
* must not use this: they take a DatabaseContext, which is drizzle-only.
* Porting to another engine means giving these callers an async path first.
*/
export function getCurrentRepositorySqlite() {
return getSqlite();
}
/**
* Synchronous settings read.
*
* SQLite can be queried synchronously, so it is read directly and stays
* authoritative. Other engines have no synchronous query, so the value comes
* from the cache primed at startup and kept current by SettingsRepository.
*/
export function getCurrentSettingValue(key: string): string | null {
if (!needsExplicitPersist(resolveDatabaseDialect())) {
return readCachedSetting(key);
}
const row = getCurrentRepositorySqlite()
.prepare("SELECT value FROM settings WHERE key = ?")
.get(key) as { value?: string } | undefined;
@@ -283,6 +323,15 @@ export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRep
);
}
export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthOverrideRepository {
return new SharedHostAuthOverrideRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_host_auth_override_repository_write",
),
);
}
export function createCurrentSnippetRepository(): SnippetRepository {
return new SnippetRepository(
createCurrentRepositoryContext(),
@@ -370,3 +419,69 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository {
createCurrentRepositoryWriteHook("vault_token_repository_write"),
);
}
/**
* Loads the settings cache. Must run during startup on engines without a
* synchronous read, before anything calls getCurrentSettingValue.
*/
export async function primeCurrentSettingsCache(): Promise<void> {
const rows = await createCurrentSettingsRepository().listAll();
primeSettingsCache(rows);
}
/**
* How often a replica re-reads the settings table.
*
* Override with SETTINGS_CACHE_REFRESH_SECONDS; 0 disables the refresh.
*/
const REFRESH_SECONDS_ENV = "SETTINGS_CACHE_REFRESH_SECONDS";
const DEFAULT_REFRESH_SECONDS = 30;
let refreshTimer: NodeJS.Timeout | null = null;
/**
* Keeps the settings cache from drifting on a multi-replica deployment.
*
* The cache is per-process and updated in the process that writes. That is
* enough for SQLite, where there is only ever one process. On Postgres and
* MySQL — which exist here precisely so more than one instance can share the
* data — a setting changed on one replica would otherwise never reach the
* others, because the synchronous read has no way to go back to the database.
*
* Periodic re-priming does not make the value immediately consistent. It bounds
* how long it can be wrong, which is the difference between a setting that
* takes effect on the next tick and one that takes effect at the next restart.
*/
export function startSettingsCacheRefresh(
env = process.env,
refresh: () => Promise<void> = primeCurrentSettingsCache,
): void {
if (refreshTimer) return;
const seconds = refreshIntervalSeconds(env);
if (seconds === null) return;
refreshTimer = setInterval(() => {
void refresh().catch(() => {
// A failed refresh leaves the previous values in place, which is the
// right outcome: a transient database blip should not blank the cache.
// Every caller reads a missing setting as "use the default", so an empty
// cache would silently revert configuration across the deployment.
});
}, seconds * 1000);
refreshTimer.unref();
}
/** The configured interval, or null when refreshing is switched off. */
export function refreshIntervalSeconds(env = process.env): number | null {
const seconds = Number(env[REFRESH_SECONDS_ENV] ?? DEFAULT_REFRESH_SECONDS);
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
/** Test seam. */
export function stopSettingsCacheRefresh(): void {
if (!refreshTimer) return;
clearInterval(refreshTimer);
refreshTimer = null;
}
@@ -1,158 +0,0 @@
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",
]),
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);
}
}
@@ -5,6 +5,7 @@ import {
fileManagerShortcuts,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
@@ -112,7 +113,7 @@ export class FileManagerBookmarkRepository {
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(
and(
@@ -120,14 +121,13 @@ export class FileManagerBookmarkRepository {
eq(fileManagerRecent.hostId, input.hostId),
eq(fileManagerRecent.path, input.path),
),
)
.returning({ id: fileManagerRecent.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async listPinnedForHost(
@@ -199,7 +199,7 @@ export class FileManagerBookmarkRepository {
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(
and(
@@ -207,14 +207,13 @@ export class FileManagerBookmarkRepository {
eq(fileManagerPinned.hostId, input.hostId),
eq(fileManagerPinned.path, input.path),
),
)
.returning({ id: fileManagerPinned.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async listShortcutsForHost(
@@ -288,7 +287,7 @@ export class FileManagerBookmarkRepository {
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(
and(
@@ -296,14 +295,13 @@ export class FileManagerBookmarkRepository {
eq(fileManagerShortcuts.hostId, input.hostId),
eq(fileManagerShortcuts.path, input.path),
),
)
.returning({ id: fileManagerShortcuts.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
@@ -456,75 +454,66 @@ export class FileManagerBookmarkRepository {
}
private async deleteRecentByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId))
.returning({ id: fileManagerRecent.id });
return rows.length;
.where(eq(fileManagerRecent.userId, userId));
return rowsAffected(result);
}
private async deletePinnedByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId))
.returning({ id: fileManagerPinned.id });
return rows.length;
.where(eq(fileManagerPinned.userId, userId));
return rowsAffected(result);
}
private async deleteShortcutsByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId))
.returning({ id: fileManagerShortcuts.id });
return rows.length;
.where(eq(fileManagerShortcuts.userId, userId));
return rowsAffected(result);
}
private async deleteRecentByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, hostId))
.returning({ id: fileManagerRecent.id });
return rows.length;
.where(eq(fileManagerRecent.hostId, hostId));
return rowsAffected(result);
}
private async deletePinnedByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.hostId, hostId))
.returning({ id: fileManagerPinned.id });
return rows.length;
.where(eq(fileManagerPinned.hostId, hostId));
return rowsAffected(result);
}
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, hostId))
.returning({ id: fileManagerShortcuts.id });
return rows.length;
.where(eq(fileManagerShortcuts.hostId, hostId));
return rowsAffected(result);
}
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(inArray(fileManagerRecent.hostId, hostIds))
.returning({ id: fileManagerRecent.id });
return rows.length;
.where(inArray(fileManagerRecent.hostId, hostIds));
return rowsAffected(result);
}
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(inArray(fileManagerPinned.hostId, hostIds))
.returning({ id: fileManagerPinned.id });
return rows.length;
.where(inArray(fileManagerPinned.hostId, hostIds));
return rowsAffected(result);
}
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(inArray(fileManagerShortcuts.hostId, hostIds))
.returning({ id: fileManagerShortcuts.id });
return rows.length;
.where(inArray(fileManagerShortcuts.hostId, hostIds));
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -2,6 +2,12 @@ import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { homepageItems } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HomepageItemRecord = typeof homepageItems.$inferSelect;
@@ -35,18 +41,15 @@ export class HomepageItemRepository {
input: HomepageItemCreateInput,
now = new Date().toISOString(),
): Promise<HomepageItemRecord> {
const [created] = await this.context.drizzle
.insert(homepageItems)
.values({
syncId: randomUUID(),
userId,
typeId: input.typeId,
title: input.title,
config: input.config,
createdAt: now,
updatedAt: now,
})
.returning();
const [created] = await insertReturning(this.context, homepageItems, {
syncId: randomUUID(),
userId,
typeId: input.typeId,
title: input.title,
config: input.config,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
@@ -71,11 +74,12 @@ export class HomepageItemRepository {
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();
const [updated] = await updateReturning(
this.context,
homepageItems,
{ ...updates, updatedAt },
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
);
if (updated) {
await this.afterWrite();
@@ -88,27 +92,27 @@ export class HomepageItemRepository {
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
.returning({ syncId: homepageItems.syncId });
const rows = await deleteReturning(
this.context,
homepageItems,
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
);
if (rows.length === 0) return null;
await this.afterWrite();
return rows[0];
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(homepageItems)
.where(eq(homepageItems.userId, userId))
.returning({ id: homepageItems.id });
.where(eq(homepageItems.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { eq } from "drizzle-orm";
import { homepageLayouts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
@@ -28,34 +30,35 @@ export class HomepageLayoutRepository {
const existing = await this.findByUserId(userId);
if (!existing) {
const [created] = await this.context.drizzle
.insert(homepageLayouts)
.values({ userId, layout, updatedAt })
.returning();
const [created] = await insertReturning(this.context, homepageLayouts, {
userId,
layout,
updatedAt,
});
await this.afterWrite();
return created;
}
const [updated] = await this.context.drizzle
.update(homepageLayouts)
.set({ layout, updatedAt })
.where(eq(homepageLayouts.userId, userId))
.returning();
const [updated] = await updateReturning(
this.context,
homepageLayouts,
{ layout, updatedAt },
eq(homepageLayouts.userId, userId),
);
await this.afterWrite();
return updated;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(homepageLayouts)
.where(eq(homepageLayouts.userId, userId))
.returning({ id: homepageLayouts.id });
.where(eq(homepageLayouts.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HostFolderRecord = typeof sshFolders.$inferSelect;
export type HostFolderHostRecord = typeof hosts.$inferSelect;
@@ -24,19 +30,31 @@ export class HostFolderRepository {
newName: string,
now = new Date().toISOString(),
): Promise<RenameFolderResult> {
// CAST target: every engine spells the text type differently enough to
// matter here — MySQL has no `text` cast and wants `char`.
const textType = this.context.dialect === "mysql" ? "char" : "text";
const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `;
const childLike = `${oldPrefix}%`;
// CONCAT, not `||`: MySQL reads `||` as logical OR unless the server runs
// with PIPES_AS_CONCAT, so the child paths would have been rewritten to 0.
// No error, just wrong folder names. CONCAT and SUBSTR mean the same thing
// on all three engines.
//
// The prefix is inlined rather than bound: CONCAT is variadic, so Postgres
// cannot infer a parameter's type from its position and rejects the
// statement with 42P18 before it runs. The value is a folder name the
// caller supplied, so it goes through a bound placeholder in a plain
// concatenation instead of sql.raw.
const renameExpr = (col: SQLiteColumn) =>
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`;
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE CONCAT(CAST(${newPrefix} AS ${sql.raw(textType)}), SUBSTR(${col}, ${sql.raw(String(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 });
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
const updatedCredentials = await this.context.drizzle
.update(sshCredentials)
@@ -46,8 +64,7 @@ export class HostFolderRepository {
eq(sshCredentials.userId, userId),
folderMatch(sshCredentials.folder),
),
)
.returning({ id: sshCredentials.id });
);
await this.context.drizzle
.update(sshFolders)
@@ -56,8 +73,8 @@ export class HostFolderRepository {
await this.afterWrite();
return {
updatedHosts: updatedHosts.length,
updatedCredentials: updatedCredentials.length,
updatedHosts: rowsAffected(updatedHosts),
updatedCredentials: rowsAffected(updatedCredentials),
};
}
@@ -78,35 +95,33 @@ export class HostFolderRepository {
): Promise<{ folder: HostFolderRecord; created: boolean }> {
const existing = await this.findFolder(userId, name);
if (existing) {
const [updated] = await this.context.drizzle
.update(sshFolders)
.set({
const [updated] = await updateReturning(
this.context,
sshFolders,
{
color,
icon,
credentialId:
credentialId === undefined ? existing.credentialId : credentialId,
updatedAt: now,
})
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.returning();
},
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
await this.afterWrite();
return { folder: updated, created: false };
}
const [created] = await this.context.drizzle
.insert(sshFolders)
.values({
syncId: randomUUID(),
userId,
name,
color,
icon,
credentialId: credentialId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
const [created] = await insertReturning(this.context, sshFolders, {
syncId: randomUUID(),
userId,
name,
color,
icon,
credentialId: credentialId ?? null,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return { folder: created, created: true };
@@ -139,10 +154,11 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
const deletedFolders = await this.context.drizzle
.delete(sshFolders)
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
.returning({ syncId: sshFolders.syncId });
const deletedFolders = await deleteReturning(
this.context,
sshFolders,
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
);
await this.afterWrite();
@@ -157,16 +173,15 @@ export class HostFolderRepository {
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshFolders)
.where(eq(sshFolders.userId, userId))
.returning({ id: sshFolders.id });
.where(eq(sshFolders.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async findFolder(
@@ -1,6 +1,8 @@
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, notInArray } from "drizzle-orm";
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
@@ -45,27 +47,25 @@ export class HostHealthRepository {
): 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();
const [updated] = await updateReturning(
this.context,
hostHealthChecks,
{ checks, intervalSeconds, updatedAt: now },
eq(hostHealthChecks.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await this.context.drizzle
.insert(hostHealthChecks)
.values({
userId,
hostId,
checks,
intervalSeconds,
createdAt: now,
updatedAt: now,
})
.returning();
const [created] = await insertReturning(this.context, hostHealthChecks, {
userId,
hostId,
checks,
intervalSeconds,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
@@ -94,7 +94,7 @@ export class HostHealthRepository {
})),
);
this.pruneHistory(userId, hostId, keep);
await this.pruneHistory(userId, hostId, keep);
await this.afterWrite();
return results.length;
}
@@ -121,41 +121,55 @@ export class HostHealthRepository {
checksDeleted: number;
historyDeleted: number;
}> {
const historyRows = await this.context.drizzle
const historyResult = await this.context.drizzle
.delete(hostHealthHistory)
.where(eq(hostHealthHistory.userId, userId))
.returning({ id: hostHealthHistory.id });
.where(eq(hostHealthHistory.userId, userId));
const checkRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostHealthChecks)
.where(eq(hostHealthChecks.userId, userId))
.returning({ id: hostHealthChecks.id });
.where(eq(hostHealthChecks.userId, userId));
if (historyRows.length > 0 || checkRows.length > 0) {
if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
checksDeleted: checkRows.length,
historyDeleted: historyRows.length,
checksDeleted: rowsAffected(result),
historyDeleted: rowsAffected(historyResult),
};
}
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);
/** Keeps the newest `keep` rows for the host and drops the rest. */
private async pruneHistory(
userId: string,
hostId: number,
keep: number,
): Promise<void> {
const scope = and(
eq(hostHealthHistory.userId, userId),
eq(hostHealthHistory.hostId, hostId),
);
const retained = await this.context.drizzle
.select({ id: hostHealthHistory.id })
.from(hostHealthHistory)
.where(scope)
.orderBy(desc(hostHealthHistory.ts))
.limit(keep);
// Nothing retained means nothing to keep back, so the scope alone is the
// delete condition.
await this.context.drizzle.delete(hostHealthHistory).where(
retained.length
? and(
scope,
notInArray(
hostHealthHistory.id,
retained.map((row) => row.id),
),
)
: scope,
);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,7 @@
import { and, asc, eq, gte, lte } from "drizzle-orm";
import { and, asc, eq, gte, lt, lte } from "drizzle-orm";
import { hostMetricsHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect;
@@ -32,12 +33,15 @@ export class HostMetricsHistoryRepository {
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 pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
await this.context.drizzle
.delete(hostMetricsHistory)
.where(
and(
eq(hostMetricsHistory.hostId, hostId),
lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)),
),
);
}
async listRange(
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { hostMetricsPreferences, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostMetricsPreferenceRecord =
typeof hostMetricsPreferences.$inferSelect;
@@ -37,26 +39,28 @@ export class HostMetricsPreferenceRepository {
): 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();
const [updated] = await updateReturning(
this.context,
hostMetricsPreferences,
{ layout, updatedAt: now },
eq(hostMetricsPreferences.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await this.context.drizzle
.insert(hostMetricsPreferences)
.values({
const [created] = await insertReturning(
this.context,
hostMetricsPreferences,
{
userId,
hostId,
layout,
createdAt: now,
updatedAt: now,
})
.returning();
},
);
await this.afterWrite();
return created;
@@ -67,28 +71,26 @@ export class HostMetricsPreferenceRepository {
hostId: number,
statsConfig: string,
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(hosts)
.set({ statsConfig })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning({ id: hosts.id });
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
if (rows.length === 0) return false;
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostMetricsPreferences)
.where(eq(hostMetricsPreferences.userId, userId))
.returning({ id: hostMetricsPreferences.id });
.where(eq(hostMetricsPreferences.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -3,6 +3,12 @@ import { randomUUID } from "crypto";
import { hostAccess, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type HostRecord = typeof hosts.$inferSelect;
export type NewHostRecord = typeof hosts.$inferInsert;
@@ -21,10 +27,10 @@ export class HostRepository {
) {}
async create(host: NewHostRecord): Promise<HostRecord> {
const rows = await this.context.drizzle
.insert(hosts)
.values({ syncId: randomUUID(), ...host })
.returning();
const rows = await insertReturning(this.context, hosts, {
syncId: randomUUID(),
...host,
});
await this.afterWrite();
return rows[0];
}
@@ -51,10 +57,11 @@ export class HostRepository {
delete (encryptedHost as Partial<NewHostRecord>).id;
}
const rows = await this.context.drizzle
.insert(hosts)
.values(encryptedHost as NewHostRecord)
.returning();
const rows = await insertReturning(
this.context,
hosts,
encryptedHost as NewHostRecord,
);
await this.afterWrite();
return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey);
@@ -150,11 +157,12 @@ export class HostRepository {
hostId: number,
update: HostUpdate,
): Promise<HostRecord | null> {
const rows = await this.context.drizzle
.update(hosts)
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning();
const rows = await updateReturning(
this.context,
hosts,
{ ...update, updatedAt: sql`CURRENT_TIMESTAMP` },
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
);
await this.afterWrite();
return rows[0] ?? null;
@@ -173,11 +181,12 @@ export class HostRepository {
userDataKey,
);
const rows = await this.context.drizzle
.update(hosts)
.set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning();
const rows = await updateReturning(
this.context,
hosts,
{ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` },
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
);
await this.afterWrite();
return rows[0]
@@ -213,17 +222,16 @@ export class HostRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(hosts)
.set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
.returning({ id: hosts.id });
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteForUser(
@@ -232,39 +240,38 @@ export class HostRepository {
): Promise<{ syncId: string | null } | null> {
await this.deleteAccessForHost(hostId);
const rows = await this.context.drizzle
.delete(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning({ syncId: hosts.syncId });
const rows = await deleteReturning(
this.context,
hosts,
and(eq(hosts.id, hostId), eq(hosts.userId, userId)),
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? { syncId: rows[0].syncId } : null;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hosts)
.where(eq(hosts.userId, userId))
.returning({ id: hosts.id });
.where(eq(hosts.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteAccessForHost(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.hostId, hostId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,5 +1,5 @@
import { and, eq, inArray, isNotNull } from "drizzle-orm";
import { hostAccess, hosts, sshCredentials, sshFolders } from "../db/schema.js";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -26,6 +26,28 @@ export interface HostListAccessEntry {
permissionLevel: string;
expiresAt: string | null;
}
const HOST_PERMISSION_RANK: Record<string, number> = {
connect: 1,
view: 2,
edit: 3,
manage: 4,
};
function preferHostAccess(
current: HostListAccessEntry,
candidate: HostListAccessEntry,
): HostListAccessEntry {
const currentRank = HOST_PERMISSION_RANK[current.permissionLevel] ?? 0;
const candidateRank = HOST_PERMISSION_RANK[candidate.permissionLevel] ?? 0;
if (candidateRank !== currentRank) {
return candidateRank > currentRank ? candidate : current;
}
if (current.expiresAt === null) return current;
if (candidate.expiresAt === null) return candidate;
return candidate.expiresAt > current.expiresAt ? candidate : current;
}
export type HostListRow = HostResolutionHostRecord & {
ownerId: string;
isShared: boolean;
@@ -103,9 +125,15 @@ export class HostResolutionRepository {
.from(hosts)
.where(eq(hosts.userId, userId));
const sharedHostIds = Array.from(
new Set(accessEntries.map((access) => access.hostId)),
);
const accessByHostId = new Map<number, HostListAccessEntry>();
for (const access of accessEntries) {
const current = accessByHostId.get(access.hostId);
accessByHostId.set(
access.hostId,
current ? preferHostAccess(current, access) : access,
);
}
const sharedHostIds = Array.from(accessByHostId.keys());
const sharedHostRows =
sharedHostIds.length > 0
? await this.context.drizzle
@@ -125,7 +153,7 @@ export class HostResolutionRepository {
permissionLevel: undefined,
expiresAt: undefined,
})),
...accessEntries.flatMap((access) => {
...Array.from(accessByHostId.values()).flatMap((access) => {
const host = sharedHostsById.get(access.hostId);
if (!host || host.userId === userId) {
return [];
@@ -302,19 +330,6 @@ export class HostResolutionRepository {
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;
}
/**
* Resolve the nearest assigned credential for a folder path, walking up
* through parent folders (e.g. "Switches / Floor1" falls back to
@@ -0,0 +1,157 @@
import type { DatabaseDialect } from "../db/dialect.js";
/**
* Reading the outcome of a write without depending on RETURNING.
*
* SQLite and Postgres can attach `.returning()` to a delete or update and get
* the affected rows back. **MySQL cannot** — it has no RETURNING clause, and
* drizzle's mysql-core does not expose the method at all, so the call is a
* TypeError rather than a bad query. 175 call sites here read a write's result,
* so the difference has to be absorbed somewhere.
*
* The split that matters is what the caller actually needs:
*
* - **How many rows changed** — the majority, and none of them need the rows.
* They used to ask for them anyway, via `.returning().length`. Dropping the
* `.returning()` and reading the driver's own count is both portable and one
* less thing for the database to send back.
* - **The rows themselves** — cannot be emulated on MySQL without reading
* first, which needs a transaction to stay correct under concurrency. Those
* call sites are handled individually rather than behind a helper that hides
* an extra round trip.
*/
/**
* The count each driver reports for a write, under its own name.
*
* Every engine says how many rows a write touched. None of them agree on what
* to call it:
*
* | driver | shape |
* |----------------|----------------------------------------|
* | better-sqlite3 | `{ changes, lastInsertRowid }` |
* | node-postgres | `{ rowCount, rows, command }` |
* | mysql2 | `[{ affectedRows, insertId }, fields]` |
*
* These are the shapes returned when NO `.returning()` is attached — which is
* the portable way to write, since MySQL has no RETURNING clause at all.
*/
interface WriteHeader {
changes?: number;
rowCount?: number;
affectedRows?: number;
lastInsertRowid?: number | bigint;
insertId?: number;
}
const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const;
/**
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so
* "is it an array" cannot distinguish a write header from a returning() result.
* The header is identified by carrying one of the fields above instead.
*/
function asWriteHeader(result: unknown): WriteHeader | null {
const candidate =
Array.isArray(result) && result.length > 0 ? result[0] : result;
if (!candidate || typeof candidate !== "object") return null;
const header = candidate as WriteHeader;
const known =
COUNT_FIELDS.some((field) => typeof header[field] === "number") ||
typeof header.insertId === "number" ||
typeof header.lastInsertRowid === "number" ||
typeof header.lastInsertRowid === "bigint";
return known ? header : null;
}
/**
* Number of rows a write touched.
*
* Pass the result of the write itself — every driver's header is understood, so
* the caller neither branches on the dialect nor attaches `.returning()` just to
* count what came back.
*
* A `.returning()` array is still accepted, for the call sites that need the
* rows for their own reasons and would rather not count them twice.
*/
export function rowsAffected(result: unknown): number {
const header = asWriteHeader(result);
if (header) {
for (const field of COUNT_FIELDS) {
const count = header[field];
if (typeof count === "number") return count;
}
// A header with only insertId: one row went in.
return 0;
}
if (Array.isArray(result)) return result.length;
return 0;
}
/**
* Id assigned by an insert.
*
* **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and
* MySQL's `insertId` are connection-level values that survive the statement that
* set them — after a delete, SQLite still reports whatever the last insert
* produced. Passing an update or delete result here gets a stale id, not null.
*
* Returns null when the table has no autoincrement key.
*/
export function insertedId(result: unknown): number | null {
const header = asWriteHeader(result);
if (header) {
// MySQL and SQLite both use 0 for "no autoincrement column".
if (typeof header.insertId === "number") {
return header.insertId > 0 ? header.insertId : null;
}
if (typeof header.lastInsertRowid === "bigint") {
return header.lastInsertRowid > 0n
? Number(header.lastInsertRowid)
: null;
}
if (typeof header.lastInsertRowid === "number") {
return header.lastInsertRowid > 0 ? header.lastInsertRowid : null;
}
return null;
}
if (Array.isArray(result)) {
const first = result[0] as { id?: unknown } | undefined;
return typeof first?.id === "number" ? first.id : null;
}
return null;
}
/**
* Whether `.returning()` can be attached to a write on this engine.
*
* Call sites that genuinely need the affected rows use this to choose between
* one statement and a read-then-write inside a transaction.
*/
export function supportsReturning(dialect: DatabaseDialect): boolean {
return dialect !== "mysql";
}
/**
* Reads an aggregate count as a number.
*
* `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT()
* as bigint, which node-postgres hands back as a **string** so that values past
* 2^53 survive — so the annotation is a lie there and comparisons like
* `count < max` compare a string to a number.
*/
export function countValue(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "bigint") return Number(value);
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { networkTopology } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
@@ -45,16 +46,15 @@ export class NetworkTopologyRepository {
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(networkTopology)
.where(eq(networkTopology.userId, userId))
.returning({ id: networkTopology.id });
.where(eq(networkTopology.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,7 @@
import { and, eq, gt } from "drizzle-orm";
import { userOpenTabs } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
@@ -111,43 +112,40 @@ export class OpenTabRepository {
update: OpenTabUpdate,
updatedAt = new Date().toISOString(),
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(userOpenTabs)
.set({ ...update, updatedAt })
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.returning({ id: userOpenTabs.id });
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteForUser(userId: string, id: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userOpenTabs)
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
.returning({ id: userOpenTabs.id });
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userOpenTabs)
.where(eq(userOpenTabs.userId, userId))
.returning({ id: userOpenTabs.id });
.where(eq(userOpenTabs.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async findByIdForUser(
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { opksshTokens } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { upsert } from "./returning.js";
export type OpksshTokenRecord = typeof opksshTokens.$inferSelect;
@@ -26,9 +28,10 @@ export class OpksshTokenRepository {
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
const createdAt = input.createdAt ?? new Date().toISOString();
await this.context.drizzle
.insert(opksshTokens)
.values({
await upsert(
this.context,
opksshTokens,
{
userId: input.userId,
hostId: input.hostId,
sshCert: input.sshCert,
@@ -38,8 +41,8 @@ export class OpksshTokenRepository {
issuer: input.issuer,
audience: input.audience,
expiresAt: input.expiresAt,
})
.onConflictDoUpdate({
},
{
target: [opksshTokens.userId, opksshTokens.hostId],
set: {
sshCert: input.sshCert,
@@ -51,7 +54,8 @@ export class OpksshTokenRepository {
expiresAt: input.expiresAt,
createdAt,
},
});
},
);
await this.afterWrite();
}
@@ -76,47 +80,44 @@ export class OpksshTokenRepository {
hostId: number,
lastUsed = new Date().toISOString(),
): Promise<boolean> {
const rows = await this.context.drizzle
const result = 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) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(opksshTokens)
.where(
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
)
.returning({ id: opksshTokens.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(opksshTokens)
.where(eq(opksshTokens.userId, userId))
.returning({ id: opksshTokens.id });
.where(eq(opksshTokens.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -9,6 +9,8 @@ import {
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type RbacAccessTargetType = "user" | "role";
@@ -156,7 +158,7 @@ export class RbacAccessRepository {
return { id: existing.id, created: false };
}
const result = await this.context.drizzle.insert(hostAccess).values({
const [created] = await insertReturning(this.context, hostAccess, {
hostId: input.hostId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
@@ -166,7 +168,7 @@ export class RbacAccessRepository {
});
await this.afterWrite();
return { id: Number(result.lastInsertRowid), created: true };
return { id: created.id, created: true };
}
async revokeHostAccess(accessId: number, hostId: number): Promise<void> {
@@ -177,16 +179,15 @@ export class RbacAccessRepository {
}
async deleteHostAccessForHost(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.hostId, hostId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteHostAccessForHosts(hostIds: number[]): Promise<number> {
@@ -194,30 +195,27 @@ export class RbacAccessRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(inArray(hostAccess.hostId, hostIds))
.returning({ id: hostAccess.id });
.where(inArray(hostAccess.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
const directRows = await this.context.drizzle
const directResult = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.userId, userId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.userId, userId));
const grantedRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(hostAccess)
.where(eq(hostAccess.grantedBy, userId))
.returning({ id: hostAccess.id });
.where(eq(hostAccess.grantedBy, userId));
const deletedCount = directRows.length + grantedRows.length;
const deletedCount = rowsAffected(directResult) + rowsAffected(result);
if (deletedCount > 0) {
await this.afterWrite();
}
@@ -238,17 +236,6 @@ export class RbacAccessRepository {
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({
@@ -291,7 +278,7 @@ export class RbacAccessRepository {
return { id: existing.id, created: false };
}
const result = await this.context.drizzle.insert(snippetAccess).values({
const [created] = await insertReturning(this.context, snippetAccess, {
snippetId: input.snippetId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
@@ -301,7 +288,7 @@ export class RbacAccessRepository {
});
await this.afterWrite();
return { id: Number(result.lastInsertRowid), created: true };
return { id: created.id, created: true };
}
async revokeSnippetAccess(
@@ -512,21 +499,20 @@ export class RbacAccessRepository {
async deleteExpiredHostAccess(
now = new Date().toISOString(),
): Promise<number> {
const rows = await this.context.drizzle
const result = 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) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async findActiveHostAccess(
@@ -635,17 +621,16 @@ export class RbacAccessRepository {
hostId: number,
update: { permissionLevel?: string; expiresAt?: string | null },
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(hostAccess)
.set(update)
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)))
.returning({ id: hostAccess.id });
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
@@ -1,6 +1,8 @@
import { desc, eq, inArray } from "drizzle-orm";
import { recentActivity } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
@@ -26,10 +28,7 @@ export class RecentActivityRepository {
async create(
activity: NewRecentActivityRecord,
): Promise<RecentActivityRecord> {
const rows = await this.context.drizzle
.insert(recentActivity)
.values(activity)
.returning();
const rows = await insertReturning(this.context, recentActivity, activity);
await this.afterWrite();
return rows[0];
@@ -51,42 +50,39 @@ export class RecentActivityRepository {
return 0;
}
const deletedRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.id, idsToDelete))
.returning({ id: recentActivity.id });
.where(inArray(recentActivity.id, idsToDelete));
if (deletedRows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return deletedRows.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.userId, userId))
.returning({ id: recentActivity.id });
.where(eq(recentActivity.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(eq(recentActivity.hostId, hostId))
.returning({ id: recentActivity.id });
.where(eq(recentActivity.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -94,16 +90,15 @@ export class RecentActivityRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(recentActivity)
.where(inArray(recentActivity.hostId, hostIds))
.returning({ id: recentActivity.id });
.where(inArray(recentActivity.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -0,0 +1,227 @@
import { eq, type SQL } from "drizzle-orm";
import type { SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
import type { DatabaseContext } from "./database-context.js";
import {
insertedId,
rowsAffected,
supportsReturning,
} from "./mutation-result.js";
/**
* Writes that need the affected rows back.
*
* `mutation-result.ts` covers the call sites that only wanted a count. These are
* the ones that genuinely read the rows — an updated record to return to the
* caller, a deleted row's fields to clean up alongside it.
*
* SQLite and Postgres do this in one statement with RETURNING. MySQL has no
* such clause, so the read is a second statement, and the pair has to be atomic:
*
* - **insert** — write, then read the row back by its key.
* - **update** — write, then read. Reading first would return the old values.
* - **delete** — read, then write. Reading after would return nothing.
*
* Both run in a transaction. Without one, a concurrent write between the two
* statements makes the returned rows describe a state that never existed, and
* with a connection pool the second statement might not even reach the same
* connection.
*
* ## The trap, and why it cannot bite silently
*
* On MySQL the update path re-reads using the same `where`. If the update
* changes a column that `where` tests, the read finds nothing — SQLite would
* have returned the row. Every current caller filters on an id it does not
* modify, but that is a convention, not a guarantee, so the mismatch is
* detected and thrown rather than returned as an empty array. Same for an
* insert whose row cannot be read back.
*
* Row types come from the table, so call sites keep the typing they had with
* `.returning()` and nothing has to be annotated by hand.
*/
/**
* What `.set()` accepts: a column's own type, or a SQL expression in its place —
* `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here.
*/
type UpdateValues<T extends SQLiteTable> = {
[K in keyof T["$inferInsert"]]?: T["$inferInsert"][K] | SQL;
};
export async function updateReturning<T extends SQLiteTable>(
context: DatabaseContext,
table: T,
values: UpdateValues<T>,
where: SQL,
): Promise<T["$inferSelect"][]> {
const db = context.drizzle;
if (supportsReturning(context.dialect)) {
// The cast resolves a conditional in drizzle's return type that TypeScript
// cannot narrow while T is still generic. The runtime shape is the rows.
return db.update(table).set(values).where(where).returning() as Promise<
T["$inferSelect"][]
>;
}
return db.transaction(async (tx) => {
const written = await tx.update(table).set(values).where(where);
const rows = await tx.select().from(table).where(where);
// The trap this catches: if the update changed a column that `where` tests,
// the read finds nothing and the caller gets [] — on MySQL only, with no
// error, where SQLite would have returned the row. Rows changed but none
// readable back is exactly that case, so make it loud instead.
if (rows.length === 0 && rowsAffected(written) > 0) {
throw new Error(
`updateReturning wrote ${rowsAffected(written)} row(s) but could not read ` +
`them back: the update changed a column the where clause filters on. ` +
`Read the rows first, or filter on a column the update leaves alone.`,
);
}
return rows;
});
}
export async function deleteReturning<T extends SQLiteTable>(
context: DatabaseContext,
table: T,
where: SQL,
): Promise<T["$inferSelect"][]> {
const db = context.drizzle;
if (supportsReturning(context.dialect)) {
return db.delete(table).where(where).returning() as Promise<
T["$inferSelect"][]
>;
}
return db.transaction(async (tx) => {
const rows = await tx.select().from(table).where(where);
await tx.delete(table).where(where);
return rows;
});
}
/** A table this can read a single row back from. */
type Keyed = SQLiteTable & { id: SQLiteColumn };
/**
* Inserts one row and returns it as stored, including whatever the database
* filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP.
*
* This is the one case Postgres cannot shortcut either: without RETURNING there
* is no id to read back by. Hence the split is genuinely three-way — except
* that sqlite and pg both have RETURNING, so it collapses to two again.
*
* On MySQL the key comes from one of two places:
*
* - the caller supplied it (tables keyed by a text id, like `users`)
* - the engine assigned it, reported as `insertId`
*
* Restricted to tables with an `id` column, so a table keyed some other way is
* a compile error here rather than a row that silently fails to come back.
*/
export async function insertReturning<T extends Keyed>(
context: DatabaseContext,
table: T,
values: T["$inferInsert"],
): Promise<T["$inferSelect"][]> {
const db = context.drizzle;
if (supportsReturning(context.dialect)) {
return db.insert(table).values(values).returning() as Promise<
T["$inferSelect"][]
>;
}
return db.transaction(async (tx) => {
const result = await tx.insert(table).values(values);
const supplied = (values as { id?: string | number }).id;
const key = supplied ?? insertedId(result);
if (key === null || key === undefined) {
throw new Error(
`Insert into ${String(table)} returned no id to read the row back by.`,
);
}
const rows = await tx.select().from(table).where(eq(table.id, key));
if (rows.length === 0) {
throw new Error(
`Inserted into ${String(table)} but could not read the row back by id ${key}.`,
);
}
return rows;
});
}
/**
* Inserts one row into a table keyed by something other than `id`, reading it
* back by an explicit condition.
*
* `user_preferences` is keyed by `userId` and has no `id` column at all, so
* there is no insertId to read back by — the caller has to say what identifies
* the row it just wrote.
*/
export async function insertReturningWhere<T extends SQLiteTable>(
context: DatabaseContext,
table: T,
values: T["$inferInsert"],
where: SQL,
): Promise<T["$inferSelect"][]> {
const db = context.drizzle;
if (supportsReturning(context.dialect)) {
return db.insert(table).values(values).returning() as Promise<
T["$inferSelect"][]
>;
}
return db.transaction(async (tx) => {
await tx.insert(table).values(values);
const rows = await tx.select().from(table).where(where);
if (rows.length === 0) {
throw new Error(
`Inserted into ${String(table)} but the read-back condition matched nothing.`,
);
}
return rows;
});
}
/**
* Insert, or update the row that collides with it.
*
* The clause has three spellings. SQLite and Postgres take
* `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and
* names no columns** — it uses whichever unique key was violated. drizzle
* follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and
* calling it is a TypeError rather than a rejected query.
*
* The conflict target still has to be passed: it is what SQLite and Postgres
* need, and stating it keeps the caller honest about which unique constraint it
* is relying on — four of those were missing from the schema entirely until the
* cross-dialect tests went looking.
*/
export async function upsert<T extends SQLiteTable>(
context: DatabaseContext,
table: T,
values: T["$inferInsert"],
conflict: { target: SQLiteColumn[]; set: UpdateValues<T> },
): Promise<void> {
const db = context.drizzle;
if (context.dialect === "mysql") {
const insert = db.insert(table).values(values) as unknown as {
onDuplicateKeyUpdate: (config: { set: UpdateValues<T> }) => Promise<void>;
};
await insert.onDuplicateKeyUpdate({ set: conflict.set });
return;
}
await db
.insert(table)
.values(values)
.onConflictDoUpdate({ target: conflict.target, set: conflict.set });
}
@@ -1,6 +1,8 @@
import { and, eq, inArray } from "drizzle-orm";
import { hostAccess, roles, userRoles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { deleteReturning, insertReturning } from "./returning.js";
export type RoleRecord = typeof roles.$inferSelect;
export type NewRoleRecord = typeof roles.$inferInsert;
@@ -62,27 +64,27 @@ export class RoleRepository {
}
async createRole(role: NewRoleRecord): Promise<number> {
const result = await this.context.drizzle.insert(roles).values(role);
const [created] = await insertReturning(this.context, roles, role);
await this.afterWrite();
return Number(result.lastInsertRowid);
return created.id;
}
async updateRole(id: number, update: RoleUpdate): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(roles)
.set(update)
.where(eq(roles.id, id))
.returning({ id: roles.id });
.where(eq(roles.id, id));
await this.afterWrite();
return rows.length > 0;
return rowsAffected(result) > 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 });
const deletedUserRoles = await deleteReturning(
this.context,
userRoles,
eq(userRoles.roleId, id),
);
await this.context.drizzle
.delete(hostAccess)
@@ -169,16 +171,15 @@ export class RoleRepository {
}
if (removeRole) {
const rows = await this.context.drizzle
const result = 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;
);
removed = rowsAffected(result) > 0;
}
if (added || removed) {
@@ -196,16 +197,15 @@ export class RoleRepository {
}
async removeAllRolesFromUser(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userRoles)
.where(eq(userRoles.userId, userId))
.returning({ id: userRoles.id });
.where(eq(userRoles.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async listUserRoleIds(userId: string): Promise<number[]> {
@@ -1,6 +1,8 @@
import { and, desc, eq, inArray, lt } from "drizzle-orm";
import { hosts, sessionRecordings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
@@ -47,10 +49,11 @@ export class SessionRecordingRepository {
async create(
input: SessionRecordingCreateInput,
): Promise<SessionRecordingRecord> {
const [created] = await this.context.drizzle
.insert(sessionRecordings)
.values(input)
.returning();
const [created] = await insertReturning(
this.context,
sessionRecordings,
input,
);
await this.afterWrite();
return created;
@@ -170,57 +173,71 @@ export class SessionRecordingRepository {
}
async deleteById(id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.returning({ id: sessionRecordings.id });
.where(eq(sessionRecordings.id, id));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteForUser(userId: string, id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.returning({ id: sessionRecordings.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
/**
* Detaches recordings from a user being deleted instead of removing them.
* A recording is evidence about the host as much as about the person, and the
* file stays on disk regardless — deleting only the row would orphan it.
*/
async anonymizeByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.update(sessionRecordings)
.set({ userId: null })
.where(eq(sessionRecordings.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId))
.returning({ id: sessionRecordings.id });
.where(eq(sessionRecordings.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, hostId))
.returning({ id: sessionRecordings.id });
.where(eq(sessionRecordings.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -228,16 +245,15 @@ export class SessionRecordingRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionRecordings)
.where(inArray(sessionRecordings.hostId, hostIds))
.returning({ id: sessionRecordings.id });
.where(inArray(sessionRecordings.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, eq, lte, ne } from "drizzle-orm";
import { sessions } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionRecord = typeof sessions.$inferSelect;
export type NewSessionRecord = typeof sessions.$inferInsert;
@@ -12,10 +14,7 @@ export class SessionRepository {
) {}
async create(session: NewSessionRecord): Promise<SessionRecord> {
const rows = await this.context.drizzle
.insert(sessions)
.values(session)
.returning();
const rows = await insertReturning(this.context, sessions, session);
await this.afterWrite();
return rows[0];
}
@@ -72,13 +71,12 @@ export class SessionRepository {
}
async revoke(id: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessions)
.where(eq(sessions.id, id))
.returning({ id: sessions.id });
.where(eq(sessions.id, id));
await this.afterWrite();
return rows.length > 0;
return rowsAffected(result) > 0;
}
async revokeAllForUser(
@@ -89,23 +87,19 @@ export class SessionRepository {
? 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 });
const result = await this.context.drizzle.delete(sessions).where(where);
await this.afterWrite();
return rows.length;
return rowsAffected(result);
}
async deleteExpired(now = new Date()): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessions)
.where(lte(sessions.expiresAt, now.toISOString()))
.returning({ id: sessions.id });
.where(lte(sessions.expiresAt, now.toISOString()));
await this.afterWrite();
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -6,6 +6,8 @@ import {
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SessionShareRecord = typeof sessionShares.$inferSelect;
export type SessionShareParticipantRecord =
@@ -49,22 +51,19 @@ export class SessionShareRepository {
) {}
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
const [created] = await this.context.drizzle
.insert(sessionShares)
.values({
id: input.id,
hostId: input.hostId,
ownerUserId: input.ownerUserId,
protocol: input.protocol,
sessionId: input.sessionId,
tabInstanceId: input.tabInstanceId ?? null,
shareType: input.shareType,
targetUserId: input.targetUserId ?? null,
linkToken: input.linkToken ?? null,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
})
.returning();
const [created] = await insertReturning(this.context, sessionShares, {
id: input.id,
hostId: input.hostId,
ownerUserId: input.ownerUserId,
protocol: input.protocol,
sessionId: input.sessionId,
tabInstanceId: input.tabInstanceId ?? null,
shareType: input.shareType,
targetUserId: input.targetUserId ?? null,
linkToken: input.linkToken ?? null,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
});
await this.afterWrite();
return created;
@@ -151,7 +150,7 @@ export class SessionShareRepository {
}
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(sessionShares)
.set({ revokedAt: new Date().toISOString() })
.where(
@@ -159,38 +158,35 @@ export class SessionShareRepository {
eq(sessionShares.id, shareId),
eq(sessionShares.ownerUserId, requestingUserId),
),
)
.returning({ id: sessionShares.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async revokeAsAdmin(shareId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(sessionShares)
.set({ revokedAt: new Date().toISOString() })
.where(eq(sessionShares.id, shareId))
.returning({ id: sessionShares.id });
.where(eq(sessionShares.id, shareId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionShares)
.where(lt(sessionShares.expiresAt, now))
.returning({ id: sessionShares.id });
.where(lt(sessionShares.expiresAt, now));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async touchShareUsage(
@@ -213,10 +209,11 @@ export class SessionShareRepository {
userId: string | null,
guestLabel: string | null,
): Promise<SessionShareParticipantRecord> {
const [created] = await this.context.drizzle
.insert(sessionShareParticipants)
.values({ shareId, userId, guestLabel })
.returning();
const [created] = await insertReturning(
this.context,
sessionShareParticipants,
{ shareId, userId, guestLabel },
);
await this.afterWrite();
return created;
}
@@ -230,15 +227,14 @@ export class SessionShareRepository {
}
async deleteSharesForHost(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sessionShares)
.where(eq(sessionShares.hostId, hostId))
.returning({ id: sessionShares.id });
.where(eq(sessionShares.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -0,0 +1,52 @@
/**
* Synchronous read-through cache for the settings table.
*
* 27 call sites read settings synchronously — during startup, inside request
* handlers, and from the guacd server bootstrap. On SQLite that works because
* better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous
* query at all, and making all 27 async would push `await` through code paths
* that have no business being asynchronous.
*
* Settings are a handful of low-cardinality configuration rows that change
* rarely and are read constantly, so they are cached in full. Writes go through
* SettingsRepository, which updates the cache in the same call, and the cache is
* primed once at startup.
*/
let cache: Map<string, string> | null = null;
export function isSettingsCachePrimed(): boolean {
return cache !== null;
}
/** Loads the full settings table. Called once during startup. */
export function primeSettingsCache(
rows: { key: string; value: string }[],
): void {
cache = new Map(rows.map((row) => [row.key, row.value]));
}
/**
* Reads a cached setting.
*
* Returns null both for "not set" and "cache not primed yet" — every caller
* already treats a missing setting as "use the default", and startup ordering
* means a read before priming should behave the same way rather than throw.
*/
export function readCachedSetting(key: string): string | null {
return cache?.get(key) ?? null;
}
/** Keeps the cache in step with a write. */
export function updateCachedSetting(key: string, value: string): void {
cache?.set(key, value);
}
export function forgetCachedSetting(key: string): void {
cache?.delete(key);
}
/** Test seam. */
export function resetSettingsCache(): void {
cache = null;
}
@@ -1,6 +1,8 @@
import { eq, like } from "drizzle-orm";
import { settings } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { forgetCachedSetting, updateCachedSetting } from "./settings-cache.js";
import { deleteReturning } from "./returning.js";
export class SettingsRepository {
constructor(
@@ -34,6 +36,9 @@ export class SettingsRepository {
const existing = await this.get(key);
if (existing === null) {
await this.context.drizzle.insert(settings).values({ key, value });
// Kept in step here so the synchronous readers cannot observe a stale
// value after a write in the same process.
updateCachedSetting(key, value);
await this.afterWrite();
return;
}
@@ -42,6 +47,7 @@ export class SettingsRepository {
.update(settings)
.set({ value })
.where(eq(settings.key, key));
updateCachedSetting(key, value);
await this.afterWrite();
}
@@ -51,14 +57,17 @@ export class SettingsRepository {
async delete(key: string): Promise<void> {
await this.context.drizzle.delete(settings).where(eq(settings.key, key));
forgetCachedSetting(key);
await this.afterWrite();
}
async deleteLike(pattern: string): Promise<number> {
const rows = await this.context.drizzle
.delete(settings)
.where(like(settings.key, pattern))
.returning({ key: settings.key });
const rows = await deleteReturning(
this.context,
settings,
like(settings.key, pattern),
);
for (const row of rows) forgetCachedSetting(row.key);
await this.afterWrite();
return rows.length;
}
@@ -0,0 +1,102 @@
import { and, eq } from "drizzle-orm";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { sharedHostAuthOverrides } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { deleteReturning, upsert } from "./returning.js";
export type SharedHostAuthOverrideRecord =
typeof sharedHostAuthOverrides.$inferSelect;
export class SharedHostAuthOverrideRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findForHostUser(
hostId: number,
userId: string,
protocol: AuthOverrideProtocol,
): Promise<SharedHostAuthOverrideRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sharedHostAuthOverrides)
.where(
and(
eq(sharedHostAuthOverrides.hostId, hostId),
eq(sharedHostAuthOverrides.userId, userId),
eq(sharedHostAuthOverrides.protocol, protocol),
),
)
.limit(1);
return rows[0] ?? null;
}
async findCredentialId(
hostId: number,
userId: string,
protocol: AuthOverrideProtocol,
): Promise<number | null> {
return (
(await this.findForHostUser(hostId, userId, protocol))?.credentialId ??
null
);
}
async setCredential(
hostId: number,
userId: string,
protocol: AuthOverrideProtocol,
credentialId: number,
): Promise<void> {
await upsert(
this.context,
sharedHostAuthOverrides,
{
hostId,
userId,
protocol,
credentialId,
},
{
target: [
sharedHostAuthOverrides.hostId,
sharedHostAuthOverrides.userId,
sharedHostAuthOverrides.protocol,
],
set: {
credentialId,
updatedAt: new Date().toISOString(),
},
},
);
await this.afterWrite();
}
async clearCredential(
hostId: number,
userId: string,
protocol: AuthOverrideProtocol,
): Promise<boolean> {
const rows = await deleteReturning(
this.context,
sharedHostAuthOverrides,
and(
eq(sharedHostAuthOverrides.hostId, hostId),
eq(sharedHostAuthOverrides.userId, userId),
eq(sharedHostAuthOverrides.protocol, protocol),
),
);
if (rows.length > 0) {
await this.afterWrite();
}
return rows.length > 0;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,6 +1,7 @@
import { and, eq, inArray, or } from "drizzle-orm";
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
@@ -108,16 +109,15 @@ export class SharedHostSecretsRepository {
}
async deleteByHostAccessId(hostAccessId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sharedHostSecrets)
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId))
.returning({ id: sharedHostSecrets.id });
.where(eq(sharedHostSecrets.hostAccessId, hostAccessId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteForRoleMember(
@@ -148,29 +148,27 @@ export class SharedHostSecretsRepository {
}
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sharedHostSecrets)
.where(eq(sharedHostSecrets.originalCredentialId, credentialId))
.returning({ id: sharedHostSecrets.id });
.where(eq(sharedHostSecrets.originalCredentialId, credentialId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByTargetUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sharedHostSecrets)
.where(eq(sharedHostSecrets.targetUserId, userId))
.returning({ id: sharedHostSecrets.id });
.where(eq(sharedHostSecrets.targetUserId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async findHostIdsReferencingCredential(
@@ -2,6 +2,12 @@ import { and, asc, eq, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type SnippetRecord = typeof snippets.$inferSelect;
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
@@ -84,11 +90,16 @@ export class SnippetRepository {
}
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(asc(snippets.folder), asc(snippets.order));
return (
this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
// coalesce, not asc(folder): folder is nullable, and NULLs sort first on
// SQLite and MySQL but last on Postgres. An export whose row order depends
// on the engine is not much of an export.
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order))
);
}
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
@@ -149,19 +160,16 @@ export class SnippetRepository {
? await this.nextOrderForFolder(userId, folderValue)
: input.order;
const rows = await this.context.drizzle
.insert(snippets)
.values({
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
})
.returning();
const rows = await insertReturning(this.context, snippets, {
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
});
await this.afterWrite();
return rows[0];
@@ -200,11 +208,12 @@ export class SnippetRepository {
? JSON.stringify(input.hostFilter)
: null;
const rows = await this.context.drizzle
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.returning();
const rows = await updateReturning(
this.context,
snippets,
updateFields,
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
);
await this.afterWrite();
return { existing, updated: rows[0] };
@@ -229,23 +238,21 @@ export class SnippetRepository {
snippetsDeleted: number;
foldersDeleted: number;
}> {
const deletedSnippets = await this.context.drizzle
const snippetResult = await this.context.drizzle
.delete(snippets)
.where(eq(snippets.userId, userId))
.returning({ id: snippets.id });
.where(eq(snippets.userId, userId));
const deletedFolders = await this.context.drizzle
const result = await this.context.drizzle
.delete(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.returning({ id: snippetFolders.id });
.where(eq(snippetFolders.userId, userId));
if (deletedSnippets.length > 0 || deletedFolders.length > 0) {
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
snippetsDeleted: deletedSnippets.length,
foldersDeleted: deletedFolders.length,
snippetsDeleted: rowsAffected(snippetResult),
foldersDeleted: rowsAffected(result),
};
}
@@ -377,16 +384,13 @@ export class SnippetRepository {
const existing = await this.findFolderByName(userId, name);
if (existing) return null;
const rows = await this.context.drizzle
.insert(snippetFolders)
.values({
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
})
.returning();
const rows = await insertReturning(this.context, snippetFolders, {
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
});
if (triggerSave) {
await this.afterWrite();
@@ -414,13 +418,12 @@ export class SnippetRepository {
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
const rows = await this.context.drizzle
.update(snippetFolders)
.set(updateFields)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
)
.returning();
const rows = await updateReturning(
this.context,
snippetFolders,
updateFields,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ?? null;
@@ -465,15 +468,14 @@ export class SnippetRepository {
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
const rows = await this.context.drizzle
.delete(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
)
.returning({ syncId: snippetFolders.syncId });
const rows = await deleteReturning(
this.context,
snippetFolders,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? { syncId: rows[0].syncId } : null;
}
private async findFolderByName(
@@ -0,0 +1,20 @@
/**
* Timestamp columns are stored as text defaulting to `CURRENT_TIMESTAMP`, which
* every supported engine writes as `YYYY-MM-DD HH:MM:SS` in UTC. That format
* sorts lexicographically in time order, so retention cutoffs can be plain
* string comparisons.
*
* Computing the cutoff here rather than with `datetime('now', ?)` keeps the
* queries free of engine-specific date functions.
*/
export function sqlTimestampDaysAgo(
days: number,
now: Date = new Date(),
): string {
const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
return formatSqlTimestamp(cutoff);
}
export function formatSqlTimestamp(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
@@ -1,4 +1,5 @@
import { getCurrentRepositorySqlite } from "./factory.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
export interface SqliteForeignKeyClient {
exec(sql: string): unknown;
@@ -16,8 +17,28 @@ export async function withSqliteForeignKeysDisabled<T>(
}
}
/**
* Runs a bulk import with foreign keys relaxed.
*
* Backup restore writes tables in an order that is not dependency-safe, so the
* constraints have to stand down for the duration.
*
* **This has no equivalent on Postgres or MySQL here.** Postgres needs
* superuser to disable triggers, and MySQL's `SET FOREIGN_KEY_CHECKS = 0` is
* per-connection, which a pool does not guarantee. Rather than run the import
* with constraints enforced and have it fail partway through — leaving a
* half-restored database — it refuses with a message that says why.
*/
export async function withCurrentSqliteForeignKeysDisabled<T>(
operation: () => Promise<T>,
): Promise<T> {
const dialect = resolveDatabaseDialect();
if (!needsExplicitPersist(dialect)) {
throw new Error(
`Importing a backup is only supported on SQLite; this deployment uses ${dialect}. ` +
`Restore into the database directly with its own tooling instead.`,
);
}
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
}
@@ -1,6 +1,8 @@
import { eq, inArray } from "drizzle-orm";
import { sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type SshCredentialUsageRecord = typeof sshCredentialUsage.$inferSelect;
@@ -22,38 +24,37 @@ export class SshCredentialUsageRepository {
hostId: number,
userId: string,
): Promise<SshCredentialUsageRecord> {
const [created] = await this.context.drizzle
.insert(sshCredentialUsage)
.values({ credentialId, hostId, userId })
.returning();
const [created] = await insertReturning(this.context, sshCredentialUsage, {
credentialId,
hostId,
userId,
});
await this.afterWrite();
return created;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId))
.returning({ id: sshCredentialUsage.id });
.where(eq(sshCredentialUsage.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.hostId, hostId))
.returning({ id: sshCredentialUsage.id });
.where(eq(sshCredentialUsage.hostId, hostId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -61,16 +62,15 @@ export class SshCredentialUsageRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(sshCredentialUsage)
.where(inArray(sshCredentialUsage.hostId, hostIds))
.returning({ id: sshCredentialUsage.id });
.where(inArray(sshCredentialUsage.hostId, hostIds));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { asc, eq } from "drizzle-orm";
import { ssoProviders, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type SsoProviderRecord = typeof ssoProviders.$inferSelect;
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
@@ -76,10 +78,7 @@ export class SsoProviderRepository {
}
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
const rows = await this.context.drizzle
.insert(ssoProviders)
.values(provider)
.returning();
const rows = await insertReturning(this.context, ssoProviders, provider);
await this.afterWrite();
return rows[0];
@@ -89,27 +88,27 @@ export class SsoProviderRepository {
id: number,
update: SsoProviderUpdate,
): Promise<SsoProviderRecord | null> {
const rows = await this.context.drizzle
.update(ssoProviders)
.set(update)
.where(eq(ssoProviders.id, id))
.returning();
const rows = await updateReturning(
this.context,
ssoProviders,
update,
eq(ssoProviders.id, id),
);
await this.afterWrite();
return rows[0] ?? null;
}
async delete(id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(ssoProviders)
.where(eq(ssoProviders.id, id))
.returning({ id: ssoProviders.id });
.where(eq(ssoProviders.id, id));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async countUsersByProviderId(providerId: number): Promise<number> {
@@ -1,5 +1,6 @@
import { and, eq, gt } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { syncTombstones } from "../db/schema.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
import type { DatabaseContext } from "./database-context.js";
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
@@ -12,7 +13,8 @@ export type SyncEntityType =
| "snippetFolders"
| "vaultProfiles"
| "dashboardServiceLinks"
| "homepageItems";
| "homepageItems"
| "userPreferences";
export class SyncTombstoneRepository {
constructor(
@@ -56,7 +58,8 @@ export class SyncTombstoneRepository {
eq(syncTombstones.userId, userId),
eq(syncTombstones.entityType, entityType),
];
if (since) conditions.push(gt(syncTombstones.deletedAt, since));
if (since)
conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since));
return this.context.drizzle
.select()
@@ -2,6 +2,12 @@ import { eq } from "drizzle-orm";
import { termixIdentityCa } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
insertedId,
rowsAffected,
supportsReturning,
} from "./mutation-result.js";
import { updateReturning } from "./returning.js";
export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect;
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
@@ -54,27 +60,7 @@ export class TermixIdentityCaRepository {
ca: NewTermixIdentityCaRecord,
): Promise<TermixIdentityCaRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const result = this.context.drizzle.transaction((tx) => {
const inserted = tx
.insert(termixIdentityCa)
.values({ ...ca, privateKey: "" })
.returning()
.all();
const row = inserted[0];
const encrypted = DataCrypto.encryptRecord(
"termix_identity_ca",
{ id: row.id, privateKey: ca.privateKey },
userId,
userDataKey,
);
return tx
.update(termixIdentityCa)
.set({ privateKey: encrypted.privateKey })
.where(eq(termixIdentityCa.id, row.id))
.returning()
.all()[0];
});
const result = await this.insertThenEncrypt(userId, ca, userDataKey);
await this.afterWrite();
return DataCrypto.decryptRecord(
@@ -85,6 +71,81 @@ export class TermixIdentityCaRepository {
);
}
/**
* Writes a CA in two steps, because the ciphertext depends on the id.
*
* The private key is encrypted with the row's own id as context, which does
* not exist until the row does. So: insert with an empty key, encrypt, update.
* The empty key must never be observable, hence the transaction.
*
* Two branches because better-sqlite3 rejects an async transaction callback
* see the same note in UserRepository.
*/
private async insertThenEncrypt(
userId: string,
ca: NewTermixIdentityCaRecord,
userDataKey: Buffer,
): Promise<TermixIdentityCaRecord> {
const draft = { ...ca, privateKey: "" };
const seal = (id: number) =>
DataCrypto.encryptRecord(
"termix_identity_ca",
{ id, privateKey: ca.privateKey },
userId,
userDataKey,
).privateKey;
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 needs the synchronous
.all() form, which has no async equivalent. */
return this.context.drizzle.transaction((tx) => {
const row = tx
.insert(termixIdentityCa)
.values(draft)
.returning()
.all()[0];
return tx
.update(termixIdentityCa)
.set({ privateKey: seal(row.id) })
.where(eq(termixIdentityCa.id, row.id))
.returning()
.all()[0];
});
/* eslint-enable no-restricted-syntax */
}
return this.context.drizzle.transaction(async (tx) => {
let id: number | null;
if (supportsReturning(this.context.dialect)) {
// eslint-disable-next-line no-restricted-syntax -- guarded by the check above
const rows = await tx
.insert(termixIdentityCa)
.values(draft)
.returning();
id = rows[0]?.id ?? null;
} else {
id = insertedId(await tx.insert(termixIdentityCa).values(draft));
}
if (id === null) {
throw new Error("Insert into termix_identity_ca returned no id.");
}
await tx
.update(termixIdentityCa)
.set({ privateKey: seal(id) })
.where(eq(termixIdentityCa.id, id));
const [row] = await tx
.select()
.from(termixIdentityCa)
.where(eq(termixIdentityCa.id, id));
return row;
});
}
async updateEncryptedForIdentity(
userId: string,
identityId: number,
@@ -103,43 +164,42 @@ export class TermixIdentityCaRepository {
).privateKey
: undefined;
const rows = await this.context.drizzle
.update(termixIdentityCa)
.set({
const rows = await updateReturning(
this.context,
termixIdentityCa,
{
...update,
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
})
.where(eq(termixIdentityCa.identityId, identityId))
.returning();
},
eq(termixIdentityCa.identityId, identityId),
);
await this.afterWrite();
return this.decryptOne(rows[0] ?? null, userId);
}
async deleteByIdentityId(identityId: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.identityId, identityId))
.returning({ id: termixIdentityCa.id });
.where(eq(termixIdentityCa.identityId, identityId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentityCa)
.where(eq(termixIdentityCa.userId, userId))
.returning({ id: termixIdentityCa.id });
.where(eq(termixIdentityCa.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private decryptOne<T extends Record<string, unknown>>(
@@ -1,6 +1,8 @@
import { and, asc, eq } from "drizzle-orm";
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type TermixIdentityRecord = typeof termixIdentities.$inferSelect;
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
@@ -57,10 +59,11 @@ export class TermixIdentityRepository {
async createIdentity(
identity: NewTermixIdentityRecord,
): Promise<TermixIdentityRecord> {
const rows = await this.context.drizzle
.insert(termixIdentities)
.values(identity)
.returning();
const rows = await insertReturning(
this.context,
termixIdentities,
identity,
);
await this.afterWrite();
return rows[0];
@@ -70,11 +73,12 @@ export class TermixIdentityRepository {
userId: string,
update: TermixIdentityUpdate,
): Promise<TermixIdentityRecord | null> {
const rows = await this.context.drizzle
.update(termixIdentities)
.set(update)
.where(eq(termixIdentities.userId, userId))
.returning();
const rows = await updateReturning(
this.context,
termixIdentities,
update,
eq(termixIdentities.userId, userId),
);
if (rows.length > 0) {
await this.afterWrite();
@@ -84,39 +88,36 @@ export class TermixIdentityRepository {
}
async deleteIdentityForUser(userId: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId))
.returning({ id: termixIdentities.id });
.where(eq(termixIdentities.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<{
identitiesDeleted: number;
keysDeleted: number;
}> {
const keyRows = await this.context.drizzle
const keyResult = await this.context.drizzle
.delete(termixIdentityKeys)
.where(eq(termixIdentityKeys.userId, userId))
.returning({ id: termixIdentityKeys.id });
.where(eq(termixIdentityKeys.userId, userId));
const identityRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentities)
.where(eq(termixIdentities.userId, userId))
.returning({ id: termixIdentities.id });
.where(eq(termixIdentities.userId, userId));
if (keyRows.length > 0 || identityRows.length > 0) {
if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
identitiesDeleted: identityRows.length,
keysDeleted: keyRows.length,
identitiesDeleted: rowsAffected(result),
keysDeleted: rowsAffected(keyResult),
};
}
@@ -170,10 +171,7 @@ export class TermixIdentityRepository {
async createKey(
key: NewTermixIdentityKeyRecord,
): Promise<TermixIdentityKeyRecord> {
const rows = await this.context.drizzle
.insert(termixIdentityKeys)
.values(key)
.returning();
const rows = await insertReturning(this.context, termixIdentityKeys, key);
await this.afterWrite();
return rows[0];
@@ -184,16 +182,12 @@ export class TermixIdentityRepository {
id: number,
update: TermixIdentityKeyUpdate,
): Promise<TermixIdentityKeyRecord | null> {
const rows = await this.context.drizzle
.update(termixIdentityKeys)
.set(update)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.returning();
const rows = await updateReturning(
this.context,
termixIdentityKeys,
update,
and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)),
);
if (rows.length > 0) {
await this.afterWrite();
@@ -203,21 +197,20 @@ export class TermixIdentityRepository {
}
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(termixIdentityKeys)
.where(
and(
eq(termixIdentityKeys.id, id),
eq(termixIdentityKeys.userId, userId),
),
)
.returning({ id: termixIdentityKeys.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async findKeyForUser(
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { tmuxSessionTags } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
@@ -45,7 +46,7 @@ export class TmuxSessionTagRepository {
sessionName: string,
newSessionName: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(tmuxSessionTags)
.set({ sessionName: newSessionName })
.where(
@@ -53,35 +54,33 @@ export class TmuxSessionTagRepository {
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
)
.returning({ id: tmuxSessionTags.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteSessionForHost(
hostId: number,
sessionName: string,
): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
)
.returning({ id: tmuxSessionTags.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async replaceForUserHostSession(
@@ -90,7 +89,7 @@ export class TmuxSessionTagRepository {
sessionName: string,
tags: string[],
): Promise<number> {
const deletedRows = await this.context.drizzle
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(
and(
@@ -98,8 +97,7 @@ export class TmuxSessionTagRepository {
eq(tmuxSessionTags.hostId, hostId),
eq(tmuxSessionTags.sessionName, sessionName),
),
)
.returning({ id: tmuxSessionTags.id });
);
if (tags.length > 0) {
await this.context.drizzle.insert(tmuxSessionTags).values(
@@ -112,7 +110,7 @@ export class TmuxSessionTagRepository {
);
}
const changedRows = deletedRows.length + tags.length;
const changedRows = rowsAffected(result) + tags.length;
if (changedRows > 0) {
await this.afterWrite();
}
@@ -121,16 +119,15 @@ export class TmuxSessionTagRepository {
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(tmuxSessionTags)
.where(eq(tmuxSessionTags.userId, userId))
.returning({ id: tmuxSessionTags.id });
.where(eq(tmuxSessionTags.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,7 @@
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { transferRecent } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
@@ -100,47 +101,44 @@ export class TransferRecentRepository {
return 0;
}
const deleted = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(inArray(transferRecent.id, idsToDelete))
.returning({ id: transferRecent.id });
.where(inArray(transferRecent.id, idsToDelete));
if (deleted.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return deleted.length;
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(eq(transferRecent.userId, userId))
.returning({ id: transferRecent.id });
.where(eq(transferRecent.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
eq(transferRecent.sourceHostId, hostId),
eq(transferRecent.destHostId, hostId),
),
)
.returning({ id: transferRecent.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
@@ -148,21 +146,20 @@ export class TransferRecentRepository {
return 0;
}
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
inArray(transferRecent.sourceHostId, hostIds),
inArray(transferRecent.destHostId, hostIds),
),
)
.returning({ id: transferRecent.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { eq } from "drizzle-orm";
import { userPreferences } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturningWhere, updateReturning } from "./returning.js";
export type UserPreferenceRecord = typeof userPreferences.$inferSelect;
export type NewUserPreferenceRecord = typeof userPreferences.$inferInsert;
@@ -31,34 +33,36 @@ export class UserPreferenceRepository {
const existing = await this.findByUserId(userId);
if (!existing) {
const rows = await this.context.drizzle
.insert(userPreferences)
.values({ userId, ...update })
.returning();
const rows = await insertReturningWhere(
this.context,
userPreferences,
{ userId, ...update },
eq(userPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
const rows = await this.context.drizzle
.update(userPreferences)
.set(update)
.where(eq(userPreferences.userId, userId))
.returning();
const rows = await updateReturning(
this.context,
userPreferences,
update,
eq(userPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(userPreferences)
.where(eq(userPreferences.userId, userId))
.returning({ userId: userPreferences.userId });
.where(eq(userPreferences.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { eq, inArray } from "drizzle-orm";
import { users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected, supportsReturning } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type UserRecord = typeof users.$inferSelect;
export type NewUserRecord = typeof users.$inferInsert;
@@ -62,10 +64,7 @@ export class UserRepository {
}
async create(user: NewUserRecord): Promise<UserRecord> {
const rows = await this.context.drizzle
.insert(users)
.values(user)
.returning();
const rows = await insertReturning(this.context, users, user);
await this.afterWrite();
return rows[0];
}
@@ -73,17 +72,10 @@ export class UserRepository {
async createFirstLocalUser(
user: NewFirstLocalUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
const result = this.context.drizzle.transaction((tx) => {
const existingUsers = tx.select({ id: users.id }).from(users).all();
const isFirstUser = existingUsers.length === 0;
const rows = tx
.insert(users)
.values({ ...user, isAdmin: isFirstUser })
.returning()
.all();
return { user: rows[0], isFirstUser };
});
const result = await this.createCheckingIfFirst((isFirstUser) => ({
...user,
isAdmin: isFirstUser,
}));
await this.afterWrite();
return result;
@@ -92,41 +84,87 @@ export class UserRepository {
async createFirstSsoUser(
user: NewUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
const result = this.context.drizzle.transaction((tx) => {
const existingUsers = tx.select({ id: users.id }).from(users).all();
const isFirstUser = existingUsers.length === 0;
const rows = tx
.insert(users)
.values({ ...user, isAdmin: isFirstUser || Boolean(user.isAdmin) })
.returning()
.all();
return { user: rows[0], isFirstUser };
});
const result = await this.createCheckingIfFirst((isFirstUser) => ({
...user,
isAdmin: isFirstUser || Boolean(user.isAdmin),
}));
await this.afterWrite();
return result;
}
/**
* Creates a user, making them an admin if the table was empty.
*
* The check and the insert have to be one transaction: two people signing up
* at once would otherwise both see an empty table and both become admin.
*
* The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright "Transaction function
* cannot return a promise" so a single body cannot serve both. It fails
* loudly rather than silently skipping the write, which is the one mercy here.
*/
private async createCheckingIfFirst(
build: (isFirstUser: boolean) => NewUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 rejects an async
transaction callback, so this cannot use the shared helpers. */
return this.context.drizzle.transaction((tx) => {
const isFirstUser =
tx.select({ id: users.id }).from(users).all().length === 0;
const rows = tx
.insert(users)
.values(build(isFirstUser))
.returning()
.all();
return { user: rows[0], isFirstUser };
});
/* eslint-enable no-restricted-syntax */
}
return this.context.drizzle.transaction(async (tx) => {
const existing = await tx.select({ id: users.id }).from(users);
const isFirstUser = existing.length === 0;
const values = build(isFirstUser);
if (supportsReturning(this.context.dialect)) {
// eslint-disable-next-line no-restricted-syntax -- guarded by the check on this line
const rows = await tx.insert(users).values(values).returning();
return { user: rows[0], isFirstUser };
}
// users is keyed by a text id the caller supplies, so there is something
// to read back by even without RETURNING.
await tx.insert(users).values(values);
const [user] = await tx
.select()
.from(users)
.where(eq(users.id, values.id));
return { user, isFirstUser };
});
}
async update(id: string, update: UserUpdate): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.update(users)
.set(update)
.where(eq(users.id, id))
.returning();
const rows = await updateReturning(
this.context,
users,
update,
eq(users.id, id),
);
await this.afterWrite();
return rows[0] ?? null;
}
async delete(id: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(users)
.where(eq(users.id, id))
.returning({ id: users.id });
.where(eq(users.id, id));
await this.afterWrite();
return rows.length > 0;
return rowsAffected(result) > 0;
}
async countAdmins(): Promise<number> {
@@ -2,6 +2,12 @@ import { desc, eq, or } from "drizzle-orm";
import { randomUUID } from "crypto";
import { vaultProfiles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type VaultProfileRecord = typeof vaultProfiles.$inferSelect;
@@ -45,26 +51,23 @@ export class VaultProfileRepository {
}
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
const [created] = await this.context.drizzle
.insert(vaultProfiles)
.values({
syncId: randomUUID(),
userId: input.userId,
name: input.name,
description: input.description,
folder: input.folder,
tags: input.tags,
vaultAddr: input.vaultAddr,
vaultNamespace: input.vaultNamespace,
oidcMount: input.oidcMount,
oidcRole: input.oidcRole,
sshMount: input.sshMount,
sshRole: input.sshRole,
validPrincipals: input.validPrincipals,
keyType: input.keyType,
shared: input.shared ?? false,
})
.returning();
const [created] = await insertReturning(this.context, vaultProfiles, {
syncId: randomUUID(),
userId: input.userId,
name: input.name,
description: input.description,
folder: input.folder,
tags: input.tags,
vaultAddr: input.vaultAddr,
vaultNamespace: input.vaultNamespace,
oidcMount: input.oidcMount,
oidcRole: input.oidcRole,
sshMount: input.sshMount,
sshRole: input.sshRole,
validPrincipals: input.validPrincipals,
keyType: input.keyType,
shared: input.shared ?? false,
});
await this.afterWrite();
return created;
@@ -84,14 +87,15 @@ export class VaultProfileRepository {
id: number,
input: VaultProfileUpdateInput,
): Promise<VaultProfileRecord | null> {
const [updated] = await this.context.drizzle
.update(vaultProfiles)
.set({
const [updated] = await updateReturning(
this.context,
vaultProfiles,
{
...input,
updatedAt: input.updatedAt ?? new Date().toISOString(),
})
.where(eq(vaultProfiles.id, id))
.returning();
},
eq(vaultProfiles.id, id),
);
if (updated) {
await this.afterWrite();
@@ -101,27 +105,27 @@ export class VaultProfileRepository {
}
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(vaultProfiles)
.where(eq(vaultProfiles.id, id))
.returning({ syncId: vaultProfiles.syncId });
const rows = await deleteReturning(
this.context,
vaultProfiles,
eq(vaultProfiles.id, id),
);
if (rows.length === 0) return null;
await this.afterWrite();
return rows[0];
return { syncId: rows[0].syncId };
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(vaultProfiles)
.where(eq(vaultProfiles.userId, userId))
.returning({ id: vaultProfiles.id });
.where(eq(vaultProfiles.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { vaultTokens } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { upsert } from "./returning.js";
export type VaultTokenRecord = typeof vaultTokens.$inferSelect;
@@ -22,16 +24,17 @@ export class VaultTokenRepository {
async upsert(input: VaultTokenUpsertInput): Promise<void> {
const createdAt = input.createdAt ?? new Date().toISOString();
await this.context.drizzle
.insert(vaultTokens)
.values({
await upsert(
this.context,
vaultTokens,
{
userId: input.userId,
profileId: input.profileId,
sshCert: input.sshCert,
privateKey: input.privateKey,
expiresAt: input.expiresAt,
})
.onConflictDoUpdate({
},
{
target: [vaultTokens.userId, vaultTokens.profileId],
set: {
sshCert: input.sshCert,
@@ -39,7 +42,8 @@ export class VaultTokenRepository {
expiresAt: input.expiresAt,
createdAt,
},
});
},
);
await this.afterWrite();
}
@@ -67,7 +71,7 @@ export class VaultTokenRepository {
profileId: number,
lastUsed = new Date().toISOString(),
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.update(vaultTokens)
.set({ lastUsed })
.where(
@@ -75,48 +79,45 @@ export class VaultTokenRepository {
eq(vaultTokens.userId, userId),
eq(vaultTokens.profileId, profileId),
),
)
.returning({ id: vaultTokens.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserAndProfile(
userId: string,
profileId: number,
): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(vaultTokens)
.where(
and(
eq(vaultTokens.userId, userId),
eq(vaultTokens.profileId, profileId),
),
)
.returning({ id: vaultTokens.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
async deleteByUserId(userId: string): Promise<number> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(vaultTokens)
.where(eq(vaultTokens.userId, userId))
.returning({ id: vaultTokens.id });
.where(eq(vaultTokens.userId, userId));
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length;
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
@@ -1,6 +1,8 @@
import { and, eq } from "drizzle-orm";
import { webauthnCredentials } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect;
export type NewWebauthnCredentialRecord =
@@ -41,10 +43,11 @@ export class WebauthnCredentialRepository {
async create(
record: NewWebauthnCredentialRecord,
): Promise<WebauthnCredentialRecord> {
const rows = await this.context.drizzle
.insert(webauthnCredentials)
.values(record)
.returning();
const rows = await insertReturning(
this.context,
webauthnCredentials,
record,
);
await this.afterWrite();
return rows[0];
@@ -63,21 +66,20 @@ export class WebauthnCredentialRepository {
}
async deleteForUser(userId: string, id: string): Promise<boolean> {
const rows = await this.context.drizzle
const result = await this.context.drizzle
.delete(webauthnCredentials)
.where(
and(
eq(webauthnCredentials.id, id),
eq(webauthnCredentials.userId, userId),
),
)
.returning({ id: webauthnCredentials.id });
);
if (rows.length > 0) {
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rows.length > 0;
return rowsAffected(result) > 0;
}
private async afterWrite(): Promise<void> {
@@ -5,6 +5,12 @@ import {
createCurrentUserRepository,
} from "../repositories/factory.js";
import { apiLogger } from "../../utils/logger.js";
import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
async function isAdminUser(userId: string | undefined): Promise<boolean> {
if (!userId) return false;
@@ -132,4 +138,125 @@ export function registerAuditLogRoutes(
.json({ error: "Failed to fetch audit log actions" });
}
});
/**
* @openapi
* /audit-logs/export:
* get:
* summary: Export audit logs
* description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited.
* tags:
* - Audit
* parameters:
* - in: query
* name: format
* schema: { type: string, enum: [csv, ndjson], default: csv }
* - in: query
* name: userId
* schema: { type: string }
* - in: query
* name: action
* schema: { type: string }
* - in: query
* name: resourceType
* schema: { type: string }
* - in: query
* name: success
* schema: { type: string, enum: [true, false] }
* - in: query
* name: startDate
* schema: { type: string, format: date-time }
* - in: query
* name: endDate
* schema: { type: string, format: date-time }
* responses:
* 200:
* description: Audit log file.
* 403:
* description: Not authorized.
* 500:
* description: Failed to export audit logs.
*/
router.get("/audit-logs/export", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
try {
if (!(await isAdminUser(authReq.userId))) {
return res.status(403).json({ error: "Not authorized" });
}
const format = req.query.format === "ndjson" ? "ndjson" : "csv";
const { userId, action, resourceType, success, startDate, endDate } =
req.query as Record<string, string | undefined>;
const filters = {
userId,
action,
resourceType,
success:
success !== undefined && success !== ""
? success === "true"
: undefined,
startDate,
endDate,
};
res.setHeader(
"Content-Type",
format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson",
);
res.setHeader(
"Content-Disposition",
`attachment; filename="${exportFilename(format, new Date())}"`,
);
// Streamed in batches: an export is unbounded by definition, and the
// whole point is to move data out before retention drops it.
const BATCH = 500;
let offset = 0;
let exported = 0;
for (;;) {
const rows = await createCurrentAuditLogRepository().listForExport({
filters,
limit: BATCH,
offset,
});
if (rows.length === 0) break;
if (format === "csv") {
// Header only on the first batch.
const chunk = toCsv(rows);
res.write(
offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1),
);
} else {
res.write(toNdjson(rows));
}
exported += rows.length;
offset += rows.length;
if (rows.length < BATCH) break;
}
res.end();
// Reading the whole trail is itself worth recording.
const { ipAddress, userAgent } = getRequestMeta(req);
void logAudit({
userId: authReq.userId!,
username: await getAuditUsername(authReq.userId!),
action: "export_audit_logs",
resourceType: "audit_log",
details: JSON.stringify({ format, exported, filters }),
ipAddress,
userAgent,
success: true,
});
} catch (err) {
apiLogger.error("Failed to export audit logs", err);
if (!res.headersSent) {
return res.status(500).json({ error: "Failed to export audit logs" });
}
res.end();
}
});
}
+5 -7
View File
@@ -6,12 +6,15 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import {
createCurrentCredentialRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
@@ -25,11 +28,6 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
async function getAuditUsername(userId: string): Promise<string> {
const actor = await createCurrentUserRepository().findById(userId);
return actor?.username ?? userId;
}
/**
* @openapi
* /credentials:
@@ -44,7 +44,9 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
userId,
);
await createCurrentSessionRecordingRepository().deleteByUserId(userId);
// Retained rather than deleted: these outlive the account by design.
// See anonymizeByUserId on each repository.
await createCurrentSessionRecordingRepository().anonymizeByUserId(userId);
await createCurrentRbacAccessRepository().deleteHostAccessForUserReferences(
userId,
@@ -56,7 +58,7 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAlertRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
@@ -3,8 +3,9 @@ import express from "express";
import https from "https";
import http from "http";
import { lookup } from "dns/promises";
import { BlockList, isIP } from "net";
import { isIP } from "net";
import { homepageLogger } from "../../utils/logger.js";
import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js";
export const homepageProxyRouter = express.Router();
@@ -17,40 +18,6 @@ const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const blockedAddresses = new BlockList();
for (const [network, prefix] of [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10],
["127.0.0.0", 8],
["169.254.0.0", 16],
["172.16.0.0", 12],
["192.168.0.0", 16],
["198.18.0.0", 15],
["224.0.0.0", 4],
["240.0.0.0", 4],
] as const) {
blockedAddresses.addSubnet(network, prefix, "ipv4");
}
for (const [network, prefix] of [
["::", 128],
["::1", 128],
["::ffff:0:0", 96],
["fc00::", 7],
["fe80::", 10],
["ff00::", 8],
] as const) {
blockedAddresses.addSubnet(network, prefix, "ipv6");
}
function isBlockedAddress(address: string): boolean {
const family = isIP(address);
return (
family === 0 ||
blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6")
);
}
async function resolvePublicUrl(rawUrl: string): Promise<{
url: URL;
address: string;
@@ -1,3 +1,5 @@
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -6,6 +8,56 @@ export function isValidPort(port: unknown): port is number {
return typeof port === "number" && port > 0 && port <= 65535;
}
export function isOptionalBoolean(
value: unknown,
): value is boolean | undefined {
return value === undefined || typeof value === "boolean";
}
export const OWNER_PRIVATE_AUTH_FIELDS = {
ssh: [
"authType",
"authMethod",
"credentialId",
"vaultProfileId",
"overrideCredentialUsername",
"shareSshAuth",
"password",
"key",
"keyPassword",
"keyType",
"sudoPassword",
],
rdp: [
"rdpAuthType",
"rdpCredentialId",
"rdpUser",
"rdpPassword",
"rdpDomain",
],
vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"],
telnet: [
"telnetAuthType",
"telnetCredentialId",
"telnetUser",
"telnetPassword",
],
} as const satisfies Record<AuthOverrideProtocol, readonly string[]>;
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"sudoPassword",
"agentSocketPath",
] as const;
export function containsOwnerPrivateAuthUpdate(
hostData: Record<string, unknown>,
protocol: AuthOverrideProtocol,
): boolean {
return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) =>
Object.prototype.hasOwnProperty.call(hostData, field),
);
}
export const FOLDER_PATH_SEPARATOR = " / ";
/**
@@ -231,6 +283,17 @@ export function stripSensitiveFields(
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
if (
result.terminalConfig &&
typeof result.terminalConfig === "object" &&
!Array.isArray(result.terminalConfig)
) {
const terminalConfig = {
...(result.terminalConfig as Record<string, unknown>),
};
delete terminalConfig.sudoPassword;
result.terminalConfig = terminalConfig;
}
return result;
}
@@ -251,8 +314,9 @@ const CONNECT_LEVEL_FIELDS = new Set([
"tags",
"pin",
"authType",
"shareSshAuth",
"authOverrides",
"connectionType",
"credentialId",
"enableTerminal",
"enableTunnel",
"enableFileManager",
@@ -290,6 +354,37 @@ export function sanitizeHostForRecipient(
permissionLevel: string | undefined,
): Record<string, unknown> {
const stripped = stripSensitiveFields(host);
delete stripped.credentialId;
delete stripped.overrideCredentialUsername;
if (
stripped.terminalConfig &&
typeof stripped.terminalConfig === "object" &&
!Array.isArray(stripped.terminalConfig)
) {
const terminalConfig = {
...(stripped.terminalConfig as Record<string, unknown>),
};
delete terminalConfig.agentSocketPath;
stripped.terminalConfig = terminalConfig;
}
const authOverrides =
stripped.authOverrides &&
typeof stripped.authOverrides === "object" &&
!Array.isArray(stripped.authOverrides)
? (stripped.authOverrides as Record<string, unknown>)
: undefined;
const sshOverride =
authOverrides?.ssh &&
typeof authOverrides.ssh === "object" &&
!Array.isArray(authOverrides.ssh)
? (authOverrides.ssh as Record<string, unknown>)
: undefined;
if (!sshOverride?.credentialId) {
stripped.hasPassword = false;
stripped.hasKey = false;
stripped.hasKeyPassword = false;
stripped.hasSudoPassword = false;
}
if (permissionLevel !== "connect") {
return stripped;
@@ -316,6 +411,7 @@ export function transformHostResponse(
: []
: [],
pin: !!host.pin,
shareSshAuth: !!host.shareSshAuth,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: host.enableFileManager !== false,
+240 -74
View File
@@ -29,8 +29,12 @@ import {
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import {
containsOwnerPrivateAuthUpdate,
isNonEmptyString,
isOptionalBoolean,
isValidPort,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
sanitizeHostForRecipient,
stripSensitiveFields,
transformHostResponse,
@@ -47,7 +51,16 @@ import {
applyHostEnrollmentDefaults,
requireHostEnrollmentAccessForPath,
} from "./host-enrollment-auth.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import type { HostResolutionHostRecord } from "../repositories/host-resolution-repository.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
} from "../../utils/shared-host-auth-resolver.js";
const router = express.Router();
@@ -55,11 +68,6 @@ const upload = multer({ storage: multer.memoryStorage() });
const STATS_SERVER_URL = "http://localhost:30005";
async function getAuditUsername(userId: string): Promise<string> {
const actor = await createCurrentUserRepository().findById(userId);
return actor?.username ?? userId;
}
function notifyStatsHostUpdated(
hostId: number,
headers: Pick<Request["headers"], "authorization" | "cookie">,
@@ -162,6 +170,7 @@ router.post(
authMethod,
authType,
useWarpgate,
shareSshAuth,
credentialId,
vaultProfileId,
key,
@@ -170,6 +179,7 @@ router.post(
sudoPassword,
pin,
enableTerminal,
enableCommandHistory,
enableTunnel,
enableFileManager,
scpLegacy,
@@ -241,7 +251,8 @@ router.post(
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(ip) ||
!isValidPort(port)
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth)
) {
sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create",
@@ -273,11 +284,13 @@ router.post(
username: effectiveUsername,
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId: vaultProfileId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableCommandHistory: enableCommandHistory ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections)
? JSON.stringify(tunnelConnections)
@@ -666,8 +679,7 @@ router.post(
}
resolvedPassword = pickResolvedPassword(password, cred.password) as
| string
| undefined;
string | undefined;
resolvedKey = cred.privateKey as string | undefined;
resolvedKeyPassword = cred.keyPassword as string | undefined;
resolvedKeyType = cred.keyType as string | undefined;
@@ -809,6 +821,7 @@ router.put(
authMethod,
authType,
useWarpgate,
shareSshAuth,
credentialId,
vaultProfileId,
key,
@@ -817,6 +830,7 @@ router.put(
sudoPassword,
pin,
enableTerminal,
enableCommandHistory,
enableTunnel,
enableFileManager,
scpLegacy,
@@ -889,6 +903,7 @@ router.put(
!isNonEmptyString(userId) ||
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!hostId
) {
sshLogger.warn("Invalid SSH data input validation failed for update", {
@@ -917,11 +932,13 @@ router.put(
username: effectiveUsername,
authType: effectiveAuthType,
useWarpgate: useWarpgate ? 1 : 0,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId: vaultProfileId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableCommandHistory: enableCommandHistory ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections)
? JSON.stringify(tunnelConnections)
@@ -1116,31 +1133,104 @@ router.put(
const ownerId = hostRecord.userId;
if (!accessInfo.isOwner) {
// Shared editors work on the owner's real record but may never
// repoint it at credential/vault references (those live in the
// owner's personal vault) or switch the authentication type.
// Shared editors work on the owner's real record, but the owner's SSH
// authentication is private and can only be changed by that owner.
if (containsOwnerPrivateAuthUpdate(hostData, "ssh")) {
return res.status(403).json({
error:
"Only the host owner can change the host's SSH authentication",
});
}
const parseTerminalConfig = (
value: unknown,
): Record<string, unknown> | null => {
if (!value) return null;
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
return { ...(value as Record<string, unknown>) };
}
if (typeof value === "string") {
const parsed = JSON.parse(value) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
return { ...(parsed as Record<string, unknown>) };
}
}
return null;
};
if (hostData.terminalConfig === undefined) {
delete sshDataObj.terminalConfig;
} else {
let incomingTerminalConfig: Record<string, unknown> | null;
try {
incomingTerminalConfig = parseTerminalConfig(
hostData.terminalConfig,
);
} catch {
return res.status(400).json({ error: "Invalid terminal config" });
}
if (!incomingTerminalConfig) {
return res.status(400).json({ error: "Invalid terminal config" });
}
const protectedTerminalConfigField =
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS.find((field) =>
Object.prototype.hasOwnProperty.call(
incomingTerminalConfig,
field,
),
);
if (protectedTerminalConfigField) {
return res.status(403).json({
error:
"Only the host owner can change private SSH authentication settings",
});
}
const ownerHost =
await createCurrentHostResolutionRepository().findHostById(
Number(hostId),
ownerId,
);
const ownerTerminalConfig = parseTerminalConfig(
ownerHost?.terminalConfig,
);
if (ownerTerminalConfig) {
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
if (
Object.prototype.hasOwnProperty.call(ownerTerminalConfig, field)
) {
incomingTerminalConfig[field] = ownerTerminalConfig[field];
}
}
}
sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig);
}
const referenceViolations: Array<[unknown, number | null, string]> = [
[sshDataObj.credentialId, hostRecord.credentialId, "credential"],
[
sshDataObj.rdpCredentialId,
hostData.rdpCredentialId,
hostRecord.rdpCredentialId,
"RDP credential",
],
[
sshDataObj.vncCredentialId,
hostData.vncCredentialId,
hostRecord.vncCredentialId,
"VNC credential",
],
[
sshDataObj.telnetCredentialId,
hostData.telnetCredentialId,
hostRecord.telnetCredentialId,
"Telnet credential",
],
[
sshDataObj.vaultProfileId,
hostRecord.vaultProfileId,
"Vault profile",
],
];
for (const [incoming, current, label] of referenceViolations) {
@@ -1151,13 +1241,8 @@ router.put(
}
}
if (
sshDataObj.authType !== undefined &&
sshDataObj.authType !== hostRecord.authType
) {
return res.status(403).json({
error: "Only the host owner can change the authentication type",
});
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
delete sshDataObj[field];
}
}
@@ -1465,9 +1550,14 @@ router.get(
sharedExpiresAt: accessInfo.expiresAt || undefined,
ownerUsername,
};
const resolvedSharedResult =
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
res.json(
sanitizeHostForRecipient(sharedResult, accessInfo.permissionLevel),
sanitizeHostForRecipient(
resolvedSharedResult,
accessInfo.permissionLevel,
),
);
} catch (err) {
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
@@ -1529,10 +1619,11 @@ router.get(
}
try {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
const host =
await createCurrentHostResolutionRepository().findHostByIdForUser(
hostId,
userId,
);
if (!host) {
return res.status(404).json({ error: "Host not found" });
@@ -2206,61 +2297,136 @@ async function resolveHostCredentials(
requestingUserId?: string,
): Promise<Record<string, unknown>> {
try {
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const ownerId = (host.ownerId || host.userId) as string;
const ownerId = (host.ownerId || host.userId) as string | undefined;
if (
requestingUserId &&
ownerId &&
requestingUserId !== ownerId &&
typeof host.id === "number"
) {
const authHost = host as unknown as HostResolutionHostRecord;
const needsPersonalCredential = requiresPersonalHostAuthentication(
authHost,
"ssh",
);
const baseSshOverrideState = {
required: needsPersonalCredential,
ownerAuthShared: !!host.shareSshAuth,
};
const recipientHost: Record<string, unknown> = {
...host,
credentialId: null,
password: null,
key: null,
keyPassword: null,
keyType: null,
authOverrides: {
ssh: baseSshOverrideState,
},
};
if (requestingUserId && requestingUserId !== ownerId) {
try {
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number,
requestingUserId,
"ssh",
);
try {
const resolution = await resolveRecipientSharedHostAuthentication(
authHost,
host.id,
requestingUserId,
"ssh",
);
if (sharedCred) {
const resolvedHost: Record<string, unknown> = {
...host,
password: sharedCred.password,
key: sharedCred.key,
keyPassword: sharedCred.keyPassword,
keyType: sharedCred.keyType,
if (resolution.source === "personal-override") {
const credential = resolution.credential;
return {
...recipientHost,
authOverrides: {
ssh: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
authType:
credential.key || credential.privateKey
? "key"
: credential.password
? "password"
: "none",
username: credential.username || recipientHost.username,
password: credential.password,
key: credential.privateKey || credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
};
}
if (resolution.source === "owner-shared") {
if (resolution.authType === "agent") {
return {
...recipientHost,
authOverrides: {
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: "agent",
};
}
const sharedAuth = resolution.secret;
if (sharedAuth) {
const resolvedUsername = pickResolvedUsername(
host.username,
sharedCred.username,
recipientHost.username,
sharedAuth.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
}
return resolvedHost;
return {
...recipientHost,
authOverrides: {
ssh: {
required: false,
ownerAuthShared: true,
},
},
authType: sharedAuth.key
? "key"
: sharedAuth.password
? "password"
: "none",
username: resolvedUsername,
password: sharedAuth.password,
key: sharedAuth.key,
keyPassword: sharedAuth.keyPassword,
keyType: sharedAuth.keyType,
};
}
} catch (sharedCredError) {
sshLogger.warn(
"Failed to get shared credential, falling back to owner credential",
{
operation: "resolve_shared_credential_fallback",
hostId: host.id as number,
requestingUserId,
error:
sharedCredError instanceof Error
? sharedCredError.message
: "Unknown error",
},
);
}
if (resolution.source === "secretless") {
return {
...recipientHost,
authOverrides: {
ssh: {
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
};
}
} catch {
// A missing/deleted override or snapshot behaves like unavailable auth.
}
return recipientHost;
}
if (host.credentialId && (host.userId || host.ownerId)) {
const credentialId = host.credentialId as number;
const credentialOwnerId = (host.ownerId || host.userId) as string;
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
ownerId,
credentialOwnerId,
);
if (credential) {
@@ -0,0 +1,44 @@
// Pure decision: which auth settings an imported Proxmox guest inherits.
//
// The frontend carries a parallel copy in
// src/ui/components/proxmox/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported import bug, so both are kept behaviourally
// identical and each is unit-tested against the same matrix.
export function resolveProxmoxImportAuth(
defaultAuthType: string | undefined,
credentialId: number | null | undefined,
): {
authType: string;
credentialId: number | null;
overrideCredentialUsername: number;
} {
// An explicit special auth type (none/opkssh/tailscale/vault/…) wins.
if (
defaultAuthType &&
defaultAuthType !== "credential" &&
!["password", "key"].includes(defaultAuthType)
) {
return {
authType: defaultAuthType,
credentialId: null,
overrideCredentialUsername: 0,
};
}
// A credential (configured default OR inherited from the source host) is a
// concrete auth source -> use it, even when defaultAuthType is the
// "password"/"key" default.
if (credentialId) {
return {
authType: "credential",
credentialId,
overrideCredentialUsername: 1,
};
}
return {
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
};
}
+166 -114
View File
@@ -2,14 +2,14 @@ import express from "express";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../utils/logger.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
} from "../repositories/factory.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { SSHHost } from "../../../types/index.js";
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { createJumpHostChain } from "../../hosts/jump-host-chain.js";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
const router = express.Router();
const proxmoxLogger = logger;
@@ -35,7 +35,7 @@ function isSafeNodeName(name: string): boolean {
function execCommand(
client: SSHClient,
command: string,
timeoutMs = 8000,
timeoutMs = 25000,
): Promise<string> {
return new Promise((resolve, reject) => {
let settled = false;
@@ -187,6 +187,20 @@ type ProxmoxSyncResult = {
errors: string[];
};
function parseJumpHostsField(raw: unknown): unknown[] | null {
if (!raw) return null;
if (Array.isArray(raw)) return raw;
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
return null;
}
function parseJsonObject(value: unknown): Record<string, unknown> {
if (!value) return {};
if (typeof value === "object") return value as Record<string, unknown>;
@@ -248,43 +262,16 @@ function mergeTags(
.join(",");
}
function resolveProxmoxImportAuth(
defaultAuthType: string | undefined,
credentialId: number | null | undefined,
): {
authType: string;
credentialId: number | null;
overrideCredentialUsername: number;
} {
if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) {
return credentialId
? { authType: "credential", credentialId, overrideCredentialUsername: 1 }
: { authType: "none", credentialId: null, overrideCredentialUsername: 0 };
}
if (defaultAuthType && !["password", "key"].includes(defaultAuthType)) {
return {
authType: defaultAuthType,
credentialId: null,
overrideCredentialUsername: 0,
};
}
return {
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
};
}
async function discoverProxmoxGuestsForHost(
userId: string,
parsedHostId: number,
onProgress?: (done: number, total: number) => void,
): Promise<{
host: SSHHost;
guests: ProxmoxGuest[];
credentialId: number | null;
defaultCredentialId: number | null;
jumpHosts: unknown[] | null;
config: ReturnType<typeof parseProxmoxConfig>;
}> {
if (!DataCrypto.canUserAccessData(userId)) {
@@ -293,34 +280,18 @@ async function discoverProxmoxGuestsForHost(
throw error;
}
const hostRecord = await createCurrentHostRepository().findDecryptedByIdAs(
userId,
parsedHostId,
);
if (!hostRecord) {
const resolvedHost = await resolveHostById(parsedHostId, userId);
if (!resolvedHost) {
const error = new Error("Host not found");
(error as Error & { status?: number }).status = 404;
throw error;
}
const host = hostRecord as unknown as SSHHost;
const host = resolvedHost as SSHHost;
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
const config = parseProxmoxConfig(proxmoxCfgRaw);
if (host.userId !== userId) {
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const pm = PermissionManager.getInstance();
const access = await pm.canAccessHost(userId, parsedHostId, "connect");
if (!access.hasAccess) {
const error = new Error("Access denied");
(error as Error & { status?: number }).status = 403;
throw error;
}
}
let resolvedCredentials: {
const resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
@@ -334,50 +305,6 @@ async function discoverProxmoxGuestsForHost(
const hostCredentialId = host.credentialId ?? null;
if (host.credentialId) {
if (userId !== host.userId) {
try {
const { SharedHostSecretsManager } =
await import("../../utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id,
userId,
"ssh",
);
if (sharedCred) {
resolvedCredentials = {
password: sharedCred.password,
sshKey: sharedCred.key,
keyPassword: sharedCred.keyPassword,
authType: sharedCred.authType,
};
}
} catch (err) {
proxmoxLogger.error("Failed to resolve shared credential", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
}
} else {
const cred =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
host.credentialId as number,
);
if (cred) {
const c = cred;
resolvedCredentials = {
password: c.password as string | undefined,
sshKey: (c.key || c.privateKey) as string | undefined,
keyPassword: c.keyPassword as string | undefined,
authType: c.authType as string | undefined,
};
}
}
}
const sshConfig: Record<string, unknown> = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port || 22,
@@ -420,7 +347,51 @@ async function discoverProxmoxGuestsForHost(
await new Promise<void>((resolve, reject) => {
client.on("ready", resolve);
client.on("error", reject);
client.connect(sshConfig as import("ssh2").ConnectConfig);
// Reuse the shared jump-host chain (same path terminal/metrics use)
// so Proxmox hosts that are only reachable via a jump host work too
// (otherwise the direct connect fails with EHOSTUNREACH). jumpHosts is
// stored as a JSON string on the decrypted record, so parse it first.
let parsedJumpHosts: Array<{ hostId: number }> = [];
try {
const rawJumpHosts = (host as { jumpHosts?: unknown }).jumpHosts;
const parsed =
typeof rawJumpHosts === "string"
? JSON.parse(rawJumpHosts)
: rawJumpHosts;
if (Array.isArray(parsed)) parsedJumpHosts = parsed;
} catch {
parsedJumpHosts = [];
}
if (parsedJumpHosts.length > 0) {
createJumpHostChain(parsedJumpHosts, userId)
.then((jumpClient) => {
if (!jumpClient) {
reject(new Error("Jump host chain could not be established"));
return;
}
jumpClient.forwardOut(
"127.0.0.1",
0,
sshConfig.host as string,
sshConfig.port as number,
(err, stream) => {
if (err || !stream) {
reject(err || new Error("Jump host forward failed"));
return;
}
sshConfig.sock = stream;
delete sshConfig.host;
delete sshConfig.port;
client.connect(sshConfig as import("ssh2").ConnectConfig);
},
);
})
.catch(reject);
} else {
client.connect(sshConfig as import("ssh2").ConnectConfig);
}
});
proxmoxLogger.info("Proxmox discovery SSH connection established", {
@@ -493,7 +464,7 @@ async function discoverProxmoxGuestsForHost(
const cfgJson = await execCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
8000,
25000,
);
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
} catch {
@@ -507,7 +478,7 @@ async function discoverProxmoxGuestsForHost(
const ifRaw = await execCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
5000,
12000,
);
const data = JSON.parse(ifRaw);
const entries: Array<Record<string, unknown>> = Array.isArray(data)
@@ -539,7 +510,7 @@ async function discoverProxmoxGuestsForHost(
const ifJson = await execCommand(
client,
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
5000,
12000,
);
const data = JSON.parse(ifJson);
const ifaces: Array<Record<string, unknown>> = Array.isArray(
@@ -577,13 +548,21 @@ async function discoverProxmoxGuestsForHost(
return null;
}
const CONCURRENCY = 6;
// Low concurrency on purpose: pvesh is heavy and small Proxmox nodes
// (especially reached over a high-latency jump chain) suffer severe
// contention when many run at once — calls then exceed execCommand's
// timeout and IPs come back empty. 2 keeps each call well under budget.
const CONCURRENCY = 2;
const ips: (string | null)[] = new Array(guestBases.length).fill(null);
let cursor = 0;
let completed = 0;
onProgress?.(0, guestBases.length);
async function ipWorker() {
while (cursor < guestBases.length) {
const i = cursor++;
ips[i] = await resolveIp(guestBases[i]);
completed++;
onProgress?.(completed, guestBases.length);
}
}
await Promise.all(
@@ -613,6 +592,9 @@ async function discoverProxmoxGuestsForHost(
guests,
credentialId: hostCredentialId,
defaultCredentialId: config.defaultCredentialId,
jumpHosts: parseJumpHostsField(
(host as unknown as { jumpHosts?: unknown }).jumpHosts,
),
config,
};
} finally {
@@ -688,14 +670,6 @@ async function syncProxmoxHost(
missingSince: null,
};
if (!existing && !guest.ip) {
result.skipped++;
result.errors.push(
`${guest.name}: skipped because no IP address was discovered`,
);
continue;
}
const baseConfig = existing
? parseJsonObject(existing.proxmoxConfig)
: {};
@@ -718,11 +692,11 @@ async function syncProxmoxHost(
typeof existing?.username === "string" && existing.username
? existing.username
: connectionType === "rdp"
? null
? ""
: "root";
const update: Record<string, unknown> = {
name: guest.name,
ip: guest.ip || existing?.ip,
ip: guest.ip || existing?.ip || "0.0.0.0",
port,
username,
connectionType,
@@ -781,7 +755,9 @@ async function syncProxmoxHost(
telnetPort: null,
defaultPath: "/",
tunnelConnections: "[]",
jumpHosts: null,
jumpHosts:
(discovery.host as unknown as { jumpHosts?: string | null })
.jumpHosts ?? null,
quickActions: null,
statsConfig: null,
dockerConfig: null,
@@ -1010,6 +986,81 @@ proxmoxAutoSyncStartupTimer.unref?.();
* 500:
* description: Discovery failed.
*/
router.get(
"/discover/stream",
authenticateJWT,
requireDataAccess,
async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
let closed = false;
const send = (event: string, data: unknown) => {
if (closed) return;
try {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
closed = true;
}
};
const heartbeat = setInterval(() => {
if (closed) return;
try {
res.write(": keepalive\n\n");
} catch {
closed = true;
clearInterval(heartbeat);
}
}, 15000);
req.on("close", () => {
closed = true;
clearInterval(heartbeat);
});
try {
const discovery = await discoverProxmoxGuestsForHost(
userId,
parsedHostId,
(done, total) => send("progress", { done, total }),
);
send("result", {
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
proxmoxLogger.error("Proxmox discovery (stream) failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
send("fail", { message });
} finally {
clearInterval(heartbeat);
if (!closed) {
try {
res.end();
} catch {
// ignore end errors
}
}
}
},
);
router.post(
"/discover",
authenticateJWT,
@@ -1032,6 +1083,7 @@ router.post(
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
+91 -32
View File
@@ -3,6 +3,12 @@ import express from "express";
import type { Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getRequestMeta } from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js";
import {
SharedHostAuthOverrideService,
SharedHostAuthOverrideServiceError,
} from "../../utils/shared-host-auth-override-service.js";
import {
PermissionManager,
SHARE_PERMISSION_LEVELS,
@@ -13,7 +19,6 @@ import {
isValidPermission,
} from "../../utils/permission-catalog.js";
import {
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
@@ -28,6 +33,9 @@ const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const sharedHostAuthOverrideService =
SharedHostAuthOverrideService.getInstance();
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -95,7 +103,7 @@ function parseShareTargets(
* /rbac/host/{id}/share:
* post:
* summary: Share a host
* description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). Allowed for the host owner or recipients holding the manage level. Every auth type is shareable; per-recipient secret snapshots are created automatically.
* description: Shares a host with one or more users and/or roles at a permission level (connect, view, edit, manage). SSH authentication remains private to the owner; recipients may select one of their own saved SSH credentials.
* tags:
* - RBAC
* parameters:
@@ -352,8 +360,6 @@ router.post(
* description: Folder shared successfully.
* 400:
* description: Invalid request body.
* 404:
* description: Folder has no hosts.
* 500:
* description: Failed to share folder.
*/
@@ -415,9 +421,6 @@ router.post(
userId,
folder,
);
if (hostsInFolder.length === 0) {
return res.status(404).json({ error: "Folder has no hosts" });
}
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
@@ -1765,50 +1768,106 @@ router.get(
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
* put:
* summary: Set personal authentication for a shared host protocol
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. Only SSH is currently supported.
* tags: [RBAC]
* security:
* - bearerAuth: []
*/
router.put(
"/host-access/:hostId/credential",
"/host-access/:hostId/auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
const protocol = req.params.protocol;
const { credentialId } = req.body;
if (!hostId || isNaN(hostId)) {
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
const access =
await createCurrentRbacAccessRepository().findDirectHostAccess(
hostId,
userId,
);
if (!access) {
return res.status(403).json({ error: "No access to this host" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
if (credentialId) {
const cred = await createCurrentCredentialRepository().findByIdForUser(
userId,
credentialId,
);
if (!cred) {
return res.status(404).json({ error: "Credential not found" });
}
if (
credentialId !== null &&
(!Number.isInteger(credentialId) || credentialId <= 0)
) {
return res.status(400).json({
error: "credentialId must be a positive integer or null",
});
}
await createCurrentRbacAccessRepository().updateHostAccessOverrideCredential(
access.id,
credentialId || null,
const { ipAddress, userAgent } = getRequestMeta(req);
await sharedHostAuthOverrideService.setCredentialId(
hostId,
userId,
protocol,
credentialId,
{ ipAddress, userAgent },
);
res.json({ success: true });
res.json({ success: true, protocol, credentialId });
} catch (error) {
if (error instanceof SharedHostAuthOverrideServiceError) {
return res.status(error.statusCode).json({ error: error.message });
}
databaseLogger.error("Failed to set override credential", error);
res.status(500).json({ error: "Failed to update credential" });
}
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
* get:
* summary: Get the current recipient's shared-host protocol authentication override
* tags: [RBAC]
* security:
* - bearerAuth: []
*/
router.get(
"/host-access/:hostId/auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
const protocol = req.params.protocol;
if (!Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
const credentialId = await sharedHostAuthOverrideService.getCredentialId(
hostId,
userId,
protocol,
);
res.json({ protocol, credentialId });
} catch (error) {
if (error instanceof SharedHostAuthOverrideServiceError) {
return res.status(error.statusCode).json({ error: error.message });
}
databaseLogger.error("Failed to get override credential", error);
res.status(500).json({ error: "Failed to fetch credential" });
}
},
);
export default router;
@@ -0,0 +1,29 @@
export interface SnippetExecutionResult {
success: boolean;
output: string;
error?: string;
}
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
): number | undefined {
if (value === undefined || value.trim() === "") return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
return seconds * 1000;
}
export function createSnippetExecutionResult(
exitCode: number | null,
output: string,
errorOutput: string,
): SnippetExecutionResult {
const success = exitCode === 0 || (exitCode === null && !errorOutput);
return {
success,
output,
...(errorOutput ? { error: errorOutput } : {}),
};
}
+20 -12
View File
@@ -5,6 +5,10 @@ import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
import {
createSnippetExecutionResult,
getSnippetExecutionTimeoutMs,
} from "./snippets-execution.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
createCurrentHostResolutionRepository,
@@ -594,8 +598,7 @@ router.post(
authType = (cred.authType || authType) as string;
password = (cred.password || undefined) as string | undefined;
privateKey = (cred.privateKey || cred.key || undefined) as
| string
| undefined;
string | undefined;
passphrase = (cred.keyPassword || undefined) as string | undefined;
}
}
@@ -609,10 +612,8 @@ router.post(
output: string;
error?: string;
}>((resolve, reject) => {
const timeout = setTimeout(() => {
conn.end();
reject(new Error("Command execution timeout (30s)"));
}, 30000);
const timeoutMs = getSnippetExecutionTimeoutMs();
let timeout: NodeJS.Timeout | undefined;
conn.on("ready", () => {
conn.exec(snippet.content, (err, stream) => {
@@ -622,14 +623,21 @@ router.post(
return reject(err);
}
stream.on("close", () => {
if (timeoutMs) {
timeout = setTimeout(() => {
conn.end();
reject(
new Error(`Command execution timeout (${timeoutMs / 1000}s)`),
);
}, timeoutMs);
}
stream.on("close", (exitCode: number | null) => {
clearTimeout(timeout);
conn.end();
if (errorOutput) {
resolve({ success: false, output, error: errorOutput });
} else {
resolve({ success: true, output });
}
resolve(
createSnippetExecutionResult(exitCode, output, errorOutput),
);
});
stream.on("data", (data: Buffer) => {
@@ -8,54 +8,39 @@ import { AuthManager } from "../../utils/auth-manager.js";
import type { SSOProviderType } from "../../../types/index.js";
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
import {
decryptSsoConfigSecrets,
encryptSsoConfigSecrets,
} from "../../utils/system-secret-crypto.js";
const authManager = AuthManager.getInstance();
function decryptProviderConfig(
/**
* SSO secrets belong to the installation, not to a user: `sso_providers` has no
* userId and the values must be readable during login, before anyone is
* authenticated. They are encrypted with the system key rather than a user DEK.
* Values written by the previous base64 scheme still decode, and are upgraded
* the next time the provider is saved.
*/
async function decryptProviderConfig(
configJson: string,
_userId: string,
): Record<string, unknown> {
): Promise<Record<string, unknown>> {
let config: Record<string, unknown>;
try {
config = JSON.parse(configJson);
} catch {
return {};
}
for (const field of ["client_secret", "bindPassword"] as const) {
const val = config[field] as string | undefined;
if (val?.startsWith("encoded:")) {
try {
config[field] = Buffer.from(val.substring(8), "base64").toString(
"utf8",
);
} catch {
config[field] = "[ENCODING ERROR]";
}
}
}
return config;
return decryptSsoConfigSecrets(config);
}
function encryptProviderConfig(
async function encryptProviderConfig(
config: Record<string, unknown>,
_userId: string,
_providerId: string,
): string {
const encoded: Record<string, unknown> = { ...config };
if (
typeof config.client_secret === "string" &&
!config.client_secret.startsWith("encoded:")
) {
encoded.client_secret = `encoded:${Buffer.from(config.client_secret).toString("base64")}`;
}
if (
typeof config.bindPassword === "string" &&
!config.bindPassword.startsWith("encoded:")
) {
encoded.bindPassword = `encoded:${Buffer.from(config.bindPassword).toString("base64")}`;
}
return JSON.stringify(encoded);
): Promise<string> {
return JSON.stringify(await encryptSsoConfigSecrets(config));
}
function applyProviderDefaults(
@@ -141,10 +126,12 @@ export function registerSSOProviderRoutes(router: Router): void {
try {
const rows = await createCurrentSsoProviderRepository().listAll();
const result = rows.map((row) => ({
...row,
config: decryptProviderConfig(row.config, userId),
}));
const result = await Promise.all(
rows.map(async (row) => ({
...row,
config: await decryptProviderConfig(row.config, userId),
})),
);
res.json(result);
} catch (err) {
authLogger.error("Failed to list SSO providers (admin)", err);
@@ -253,7 +240,7 @@ export function registerSSOProviderRoutes(router: Router): void {
}
const tempId = `new-${Date.now()}`;
const encryptedConfig = encryptProviderConfig(
const encryptedConfig = await encryptProviderConfig(
configWithDefaults as Record<string, unknown>,
userId,
tempId,
@@ -275,7 +262,7 @@ export function registerSSOProviderRoutes(router: Router): void {
});
res.status(201).json({
...inserted,
config: decryptProviderConfig(inserted.config, userId),
config: await decryptProviderConfig(inserted.config, userId),
});
} catch (err) {
authLogger.error("Failed to create SSO provider", err);
@@ -332,7 +319,7 @@ export function registerSSOProviderRoutes(router: Router): void {
let encryptedConfig = existing.config;
if (rawConfig !== undefined) {
const existingDecrypted = decryptProviderConfig(
const existingDecrypted = await decryptProviderConfig(
existing.config,
userId,
);
@@ -342,7 +329,7 @@ export function registerSSOProviderRoutes(router: Router): void {
),
...rawConfig,
};
encryptedConfig = encryptProviderConfig(
encryptedConfig = await encryptProviderConfig(
mergedConfig,
userId,
String(providerId),
@@ -369,7 +356,7 @@ export function registerSSOProviderRoutes(router: Router): void {
});
res.json({
...updated,
config: decryptProviderConfig(updated.config, userId),
config: await decryptProviderConfig(updated.config, userId),
});
} catch (err) {
authLogger.error("Failed to update SSO provider", err);
@@ -0,0 +1,95 @@
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
export type SyncReferenceEntity = "sshCredentials" | "vaultProfiles";
interface SyncReference {
field: string;
syncField: string;
entityType: SyncReferenceEntity;
}
const HOST_REFERENCES: SyncReference[] = [
{
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
},
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vaultProfileId",
syncField: "vaultProfileSyncId",
entityType: "vaultProfiles",
},
];
const REFERENCES: Partial<Record<SyncEntityType, SyncReference[]>> = {
hosts: HOST_REFERENCES,
sshFolders: [HOST_REFERENCES[0]],
};
export async function serializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveSyncId: (
entityType: SyncReferenceEntity,
id: number,
) => Promise<string | null>,
): Promise<Record<string, unknown>> {
const serialized = { ...row };
for (const reference of REFERENCES[entityType] ?? []) {
const id = serialized[reference.field];
serialized[reference.syncField] =
typeof id === "number"
? await resolveSyncId(reference.entityType, id)
: null;
delete serialized[reference.field];
}
return serialized;
}
export async function deserializeSyncReferences(
entityType: SyncEntityType,
row: Record<string, unknown>,
resolveId: (
entityType: SyncReferenceEntity,
syncId: string,
) => Promise<number | null>,
): Promise<Record<string, unknown>> {
const deserialized = { ...row };
for (const reference of REFERENCES[entityType] ?? []) {
const syncId = deserialized[reference.syncField];
delete deserialized[reference.syncField];
delete deserialized[reference.field];
if (syncId == null) {
deserialized[reference.field] = null;
continue;
}
if (typeof syncId !== "string") {
throw new Error(`Invalid ${reference.syncField}`);
}
const id = await resolveId(reference.entityType, syncId);
if (id === null) {
throw new Error(
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
);
}
deserialized[reference.field] = id;
}
return deserialized;
}
+174 -80
View File
@@ -1,6 +1,6 @@
import type { Request, Response } from "express";
import express from "express";
import { and, eq, gt } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
hosts,
sshCredentials,
@@ -10,6 +10,7 @@ import {
vaultProfiles,
dashboardServiceLinks,
homepageItems,
userPreferences,
} from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -21,6 +22,12 @@ import {
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
deserializeSyncReferences,
serializeSyncReferences,
type SyncReferenceEntity,
} from "./sync-references.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -43,11 +50,13 @@ interface EntityConfig {
| typeof snippetFolders
| typeof vaultProfiles
| typeof dashboardServiceLinks
| typeof homepageItems;
| typeof homepageItems
| typeof userPreferences;
// Fields that only make sense on the device that created the row, or
// that are managed elsewhere and must never be overwritten by a sync
// payload from the other side.
readOnlyFields: string[];
singleton?: boolean;
}
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
@@ -62,14 +71,73 @@ const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
homepageItems: { table: homepageItems, readOnlyFields: [] },
userPreferences: {
table: userPreferences,
readOnlyFields: ["storageMode"],
singleton: true,
},
};
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
export function isValidEntityType(value: unknown): value is SyncEntityType {
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
}
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
id: number,
userId: string,
): Promise<string | null> {
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ syncId: sshCredentials.syncId })
.from(sshCredentials)
.where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
const [row] = await context.drizzle
.select({ syncId: vaultProfiles.syncId })
.from(vaultProfiles)
.where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
async function findReferenceId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
syncId: string,
userId: string,
): Promise<number | null> {
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.syncId, syncId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
return row?.id ?? null;
}
const [row] = await context.drizzle
.select({ id: vaultProfiles.id })
.from(vaultProfiles)
.where(
and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
)
.limit(1);
return row?.id ?? null;
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
@@ -161,11 +229,13 @@ router.get(
: null;
try {
const { table } = ENTITY_CONFIG[entityType];
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
conditions.push(gt((table as typeof hosts).updatedAt, since));
conditions.push(
timestampAtOrAfter((table as typeof hosts).updatedAt, since),
);
}
const rows = await context.drizzle
@@ -173,8 +243,18 @@ router.get(
.from(table as typeof hosts)
.where(and(...conditions));
const decrypted = rows.map((row) =>
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
const decrypted = await Promise.all(
rows.map(async (row) => {
const result = await serializeSyncReferences(
entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
);
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
}),
);
res.json({ rows: decrypted });
@@ -189,6 +269,71 @@ router.get(
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
await context.drizzle
.delete(table as typeof hosts)
.where(
singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
);
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
@@ -227,22 +372,30 @@ router.post(
}
try {
const { table } = ENTITY_CONFIG[entityType];
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
const writePayload = stripWritePayload(entityType, payload);
const resolvedPayload = await deserializeSyncReferences(
entityType,
payload,
(referenceType, referenceSyncId) =>
findReferenceId(context, referenceType, referenceSyncId, userId),
);
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
@@ -265,11 +418,15 @@ router.post(
} else {
const insertedRows = await context.drizzle
.insert(table as typeof hosts)
.values({
...encryptedPayload,
userId,
syncId,
} as typeof hosts.$inferInsert)
.values(
(singleton
? { ...encryptedPayload, userId }
: {
...encryptedPayload,
userId,
syncId,
}) as typeof hosts.$inferInsert,
)
.returning();
resultRow = insertedRows[0] as Record<string, unknown>;
}
@@ -349,67 +506,4 @@ router.get(
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
await context.drizzle
.delete(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
);
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
export default router;
+157 -53
View File
@@ -1,6 +1,7 @@
import { authLogger } from "../../utils/logger.js";
import type { SSOProviderType } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js";
import { Agent } from "undici";
import {
createCurrentSettingsRepository,
@@ -10,6 +11,17 @@ import {
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
/**
* Raised when a token cannot be verified because it is not a compact JWS,
* as opposed to a signature or claim check that actually failed.
*/
export class OIDCTokenFormatError extends Error {
constructor(message: string) {
super(message);
this.name = "OIDCTokenFormatError";
}
}
function normalizeIssuer(url: string): string {
return url.trim().replace(/\/+$/, "");
}
@@ -27,6 +39,7 @@ export type OIDCConfig = {
allowed_users: string;
admin_group: string;
group_claim?: string;
role_map?: string;
ca_cert?: string;
};
@@ -35,6 +48,26 @@ export function buildFetchOptions(caCert?: string): Record<string, unknown> {
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
}
/**
* Renders why a fetch failed in a form an administrator can act on.
*
* undici reports every transport failure as the same "fetch failed" message
* and puts the reason that actually matters -- ENOTFOUND, ECONNREFUSED,
* UNABLE_TO_VERIFY_LEAF_SIGNATURE, a timeout -- on the cause. Reporting only
* the outer message says nothing at all.
*/
export function describeFetchFailure(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof Error) {
const code = (cause as { code?: unknown }).code;
return code
? `${error.message}: ${cause.message} (${code})`
: `${error.message}: ${cause.message}`;
}
return cause ? `${error.message}: ${String(cause)}` : error.message;
}
export function getOIDCConfigFromEnv(): OIDCConfig | null {
const client_id = process.env.OIDC_CLIENT_ID;
const client_secret = process.env.OIDC_CLIENT_SECRET;
@@ -65,9 +98,77 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null {
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
admin_group: process.env.OIDC_ADMIN_GROUP || "",
group_claim: process.env.OIDC_GROUP_CLAIM || "",
role_map: process.env.OIDC_ROLE_MAP || "",
};
}
/**
* Normalizes a group name for comparison. Providers are inconsistent about
* whether they emit bare names (`devops-interns`) or full paths
* (`/devops-interns`, Keycloak's "Full group path" option), so leading slashes
* are stripped and case is ignored.
*/
function normalizeGroupName(group: string): string {
return group.trim().replace(/^\/+/, "").toLowerCase();
}
/**
* Parses `OIDC_ROLE_MAP` into a group -> role-name lookup.
*
* Format is a comma- or newline-separated list of `group:role` pairs, e.g.
* `devops-interns:devops-intern,devops-seniors:devops-senior`. Group keys are
* normalized via {@link normalizeGroupName}; role names are passed through
* verbatim because they must match `roles.name` exactly.
*
* Malformed entries are skipped rather than throwing a typo in one pair must
* not lock every user out of login.
*/
export function parseOidcRoleMap(raw?: string | null): Map<string, string> {
const map = new Map<string, string>();
if (!raw || !raw.trim()) return map;
for (const entry of raw.split(/[\n,]/)) {
const trimmed = entry.trim();
if (!trimmed) continue;
// rsplit on the last ":" so group names containing a colon still work.
const separator = trimmed.lastIndexOf(":");
if (separator <= 0 || separator === trimmed.length - 1) continue;
const group = normalizeGroupName(trimmed.slice(0, separator));
const roleName = trimmed.slice(separator + 1).trim();
if (!group || !roleName) continue;
map.set(group, roleName);
}
return map;
}
/**
* Resolves which mapped roles a user should hold, given their provider groups.
*
* Returns both the `desired` roles (mapped groups the user is actually in) and
* the full set of `managed` roles (every role named in the map). Callers must
* only ever add/remove roles within `managed` roles assigned by hand in
* Termix, and the `admin`/`user` roles maintained by the admin-group sync, are
* deliberately left alone.
*/
export function resolveOidcMappedRoles(
groups: string[],
roleMap: Map<string, string>,
): { desired: Set<string>; managed: Set<string> } {
const managed = new Set(roleMap.values());
const desired = new Set<string>();
for (const group of groups) {
const roleName = roleMap.get(normalizeGroupName(group));
if (roleName) desired.add(roleName);
}
return { desired, managed };
}
/**
* Extracts the list of group/role names from an OIDC userInfo payload.
*
@@ -149,6 +250,15 @@ export async function verifyOIDCToken(
clientId: string,
caCert?: string,
): Promise<Record<string, unknown>> {
const segments = idToken.split(".");
if (segments.length !== 3) {
throw new OIDCTokenFormatError(
segments.length === 5
? "Token is a JWE (encrypted). Termix cannot verify encrypted tokens; disable token encryption for this client in your OIDC provider."
: `Token is not a compact JWS: expected 3 segments, got ${segments.length}.`,
);
}
const fetchOptions = buildFetchOptions(caCert);
const normalizedIssuerUrl = issuerUrl.endsWith("/")
? issuerUrl.slice(0, -1)
@@ -166,20 +276,30 @@ export async function verifyOIDCToken(
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
];
// Every attempt records why it failed. Without this the only thing an
// administrator ever sees is "Failed to fetch JWKS from any URL", which
// does not distinguish an issuer URL typo from a proxy, a private CA, or
// a provider outage.
const attempts: string[] = [];
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
if (discoveryResponse.ok) {
if (!discoveryResponse.ok) {
attempts.push(`${discoveryUrl}: HTTP ${discoveryResponse.status}`);
} else {
const discovery = (await discoveryResponse.json()) as Record<
string,
unknown
>;
if (discovery.jwks_uri) {
jwksUrls.unshift(discovery.jwks_uri as string);
if (typeof discovery.jwks_uri === "string" && discovery.jwks_uri) {
jwksUrls.unshift(discovery.jwks_uri);
} else {
attempts.push(`${discoveryUrl}: no jwks_uri in the discovery document`);
}
}
} catch (discoveryError) {
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
attempts.push(`${discoveryUrl}: ${describeFetchFailure(discoveryError)}`);
}
let jwks: Record<string, unknown> | null = null;
@@ -187,26 +307,25 @@ export async function verifyOIDCToken(
for (const url of jwksUrls) {
try {
const response = await fetch(url, fetchOptions);
if (response.ok) {
const jwksData = (await response.json()) as Record<string, unknown>;
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
jwks = jwksData;
break;
} else {
authLogger.error(
`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`,
);
}
} else {
// expected - non-ok response, try next URL
if (!response.ok) {
attempts.push(`${url}: HTTP ${response.status}`);
continue;
}
} catch {
continue;
const jwksData = (await response.json()) as Record<string, unknown>;
if (jwksData && Array.isArray(jwksData.keys)) {
jwks = jwksData;
break;
}
attempts.push(`${url}: response contains no "keys" array`);
} catch (error) {
attempts.push(`${url}: ${describeFetchFailure(error)}`);
}
}
if (!jwks) {
throw new Error("Failed to fetch JWKS from any URL");
throw new Error(
`Failed to fetch JWKS from any URL. Attempts:\n ${attempts.join("\n ")}`,
);
}
if (!jwks.keys || !Array.isArray(jwks.keys)) {
@@ -215,9 +334,8 @@ export async function verifyOIDCToken(
);
}
const header = JSON.parse(
Buffer.from(idToken.split(".")[0], "base64").toString(),
);
const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose");
const header = decodeProtectedHeader(idToken);
const keyId = header.kid;
const publicKey = jwks.keys.find(
@@ -229,8 +347,9 @@ export async function verifyOIDCToken(
);
}
const { importJWK, jwtVerify } = await import("jose");
const key = await importJWK(publicKey);
const algorithm =
typeof publicKey.alg === "string" ? publicKey.alg : header.alg;
const key = await importJWK(publicKey, algorithm);
const { payload } = await jwtVerify(idToken, key, {
issuer: possibleIssuers,
@@ -283,30 +402,15 @@ function applyProviderDefaults(
};
}
function decryptConfigSecret(
/**
* Reads the provider secrets. System-key encrypted values are decrypted;
* values still carrying a legacy base64 prefix are decoded so login keeps
* working until the provider is next saved.
*/
async function decryptConfigSecret(
config: Record<string, unknown>,
): Record<string, unknown> {
const out = { ...config };
for (const field of ["client_secret", "bindPassword"] as const) {
const val = out[field] as string | undefined;
if (val?.startsWith("encoded:")) {
try {
out[field] = Buffer.from(val.substring(8), "base64").toString("utf8");
} catch {
// leave as-is
}
} else if (val?.startsWith("encrypted:")) {
// encrypted: prefix means it was encrypted with DataCrypto; without a
// userId/dataKey here we cannot decrypt it. The caller should use the
// full admin decrypt path when possible. Fall back to stripping prefix.
try {
out[field] = Buffer.from(val.substring(10), "base64").toString("utf8");
} catch {
// leave as-is
}
}
}
return out;
): Promise<Record<string, unknown>> {
return decryptSsoConfigSecrets(config);
}
export async function loadProviderConfig(
@@ -340,10 +444,10 @@ export async function loadProviderConfig(
);
}
} catch {
parsed = decryptConfigSecret(parsed);
parsed = await decryptConfigSecret(parsed);
}
} else {
parsed = decryptConfigSecret(parsed);
parsed = await decryptConfigSecret(parsed);
}
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
@@ -380,7 +484,7 @@ export async function loadProviderConfig(
} catch {
parsed = {};
}
parsed = decryptConfigSecret(parsed);
parsed = await decryptConfigSecret(parsed);
const oidcProviderType = oidcRow.type as SSOProviderType;
return {
config: applyProviderDefaults(
@@ -401,7 +505,7 @@ export async function loadProviderConfig(
await createCurrentSettingsRepository().get("oidc_config");
if (legacyValue) {
let config = JSON.parse(legacyValue) as Record<string, unknown>;
config = decryptConfigSecret(config);
config = await decryptConfigSecret(config);
return {
config: config as unknown as OIDCConfig,
providerType: "oidc",
@@ -432,7 +536,7 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
} catch {
continue;
}
parsed = decryptConfigSecret(parsed);
parsed = await decryptConfigSecret(parsed);
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
@@ -7,6 +7,7 @@ import {
setGlobalLogLevel,
} from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
@@ -524,7 +525,7 @@ export function registerUserSettingsRoutes(
* /users/analytics-enabled:
* get:
* summary: Get analytics enabled setting
* description: Returns whether anonymous usage telemetry is enabled.
* description: Returns whether anonymous usage telemetry is enabled, and whether the value is locked by the ENABLE_TELEMETRY environment variable.
* tags:
* - Users
* responses:
@@ -537,14 +538,21 @@ export function registerUserSettingsRoutes(
* properties:
* enabled:
* type: boolean
* locked:
* type: boolean
*/
router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
try {
const override = getTelemetryEnvOverride();
if (override !== null) {
return res.json({ enabled: override, locked: true });
}
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"analytics_enabled",
true,
),
locked: false,
});
} catch (err) {
authLogger.error("Failed to get analytics enabled setting", err);
@@ -576,6 +584,8 @@ export function registerUserSettingsRoutes(
* description: Setting updated.
* 403:
* description: Not authorized.
* 409:
* description: Setting is locked by the ENABLE_TELEMETRY environment variable.
* 500:
* description: Failed to update setting.
*/
@@ -586,6 +596,11 @@ export function registerUserSettingsRoutes(
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
if (getTelemetryEnvOverride() !== null) {
return res.status(409).json({
error: "Telemetry is locked by the ENABLE_TELEMETRY env variable",
});
}
const { enabled } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "enabled must be a boolean" });
@@ -421,8 +421,7 @@ export function registerUserWebAuthnRoutes(
}
const response = req.body?.response as
| AuthenticationResponseJSON
| undefined;
AuthenticationResponseJSON | undefined;
if (!response?.id) {
return res.status(400).json({ error: "Invalid passkey response" });
}
+117 -13
View File
@@ -24,11 +24,15 @@ import {
resolveDesktopAutoSessionUser,
} from "./desktop-auto-session.js";
import { shouldShowDonationModal } from "./donation-modal-utils.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
getOIDCConfigFromEnv,
isOIDCUserAllowed,
OIDCTokenFormatError,
verifyOIDCToken,
extractOidcGroups,
parseOidcRoleMap,
resolveOidcMappedRoles,
loadProviderConfig,
buildFetchOptions,
resolveProviderByIssuer,
@@ -1049,20 +1053,37 @@ router.get("/oidc/callback", async (req, res) => {
);
if (tokenData.id_token) {
userInfo = await verifyOIDCToken(
tokenData.id_token as string,
config.issuer_url,
config.client_id,
caCert,
);
try {
userInfo = await verifyOIDCToken(
tokenData.id_token as string,
config.issuer_url,
config.client_id,
caCert,
);
const expectedNonce = storedNonce;
if (userInfo.nonce !== expectedNonce) {
authLogger.warn("OIDC ID token nonce mismatch", {
operation: "oidc_nonce_mismatch",
providerId: callbackProviderId,
});
return res.status(401).json({ error: "Invalid OIDC token nonce" });
const expectedNonce = storedNonce;
if (userInfo.nonce !== expectedNonce) {
authLogger.warn("OIDC ID token nonce mismatch", {
operation: "oidc_nonce_mismatch",
providerId: callbackProviderId,
});
return res.status(401).json({ error: "Invalid OIDC token nonce" });
}
} catch (error) {
// A token we cannot parse as a JWS carries no claims we could trust, so
// fall through to the userinfo endpoint instead of failing the login.
// Signature and claim failures still reject: those are real rejections.
if (!(error instanceof OIDCTokenFormatError)) throw error;
userInfo = null;
authLogger.warn(
"OIDC ID token cannot be verified, falling back to userinfo endpoint",
{
operation: "oidc_id_token_unverifiable",
providerId: callbackProviderId,
reason: error.message,
},
);
}
}
@@ -1331,6 +1352,89 @@ router.get("/oidc/callback", async (req, res) => {
}
}
// Sync RBAC roles from provider group membership (OIDC_ROLE_MAP).
//
// This is what makes environment-scoped access work without hand-assigning
// roles: map a provider group to a Termix role, grant that role access to a
// set of hosts once, and membership follows the identity provider.
//
// Only roles named in the map are touched. Roles assigned by hand, and the
// admin/user pair maintained by the admin-group sync above, are never
// removed here — otherwise this would fight that block on every login.
//
// Non-fatal by design: a role-sync failure must not block a valid login.
try {
const roleMap = parseOidcRoleMap(
config.role_map ?? process.env.OIDC_ROLE_MAP,
);
if (roleMap.size > 0) {
const groups = extractOidcGroups(
userInfo as Record<string, unknown>,
config.group_claim,
);
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
const roleRepository = createCurrentRoleRepository();
const currentRoles = await roleRepository.listUserRoles(userRecord.id);
const currentNames = new Set(currentRoles.map((r) => r.roleName));
const toAdd = [...desired].filter((name) => !currentNames.has(name));
const toRemove = currentRoles.filter(
(r) => managed.has(r.roleName) && !desired.has(r.roleName),
);
authLogger.info(
`Evaluating OIDC role map sync. parsedGroups: ${JSON.stringify(groups)}, desiredRoles: ${JSON.stringify([...desired])}, managedRoles: ${JSON.stringify([...managed])}, groupClaim: ${config.group_claim || "(default)"}`,
{
operation: "oidc_role_map_sync_eval",
userId: userRecord.id,
},
);
for (const roleName of toAdd) {
const assigned = await roleRepository.assignRoleNameToUser({
userId: userRecord.id,
roleName,
grantedBy: userRecord.id,
});
if (!assigned) {
authLogger.warn(
"OIDC role map references a role that does not exist",
{
operation: "oidc_role_map_missing_role",
userId: userRecord.id,
roleName,
},
);
}
}
for (const role of toRemove) {
await roleRepository.removeRoleFromUser(userRecord.id, role.roleId);
}
if (toAdd.length > 0 || toRemove.length > 0) {
authLogger.info("OIDC roles synced from group membership", {
operation: "oidc_role_map_sync",
userId: userRecord.id,
added: toAdd,
removed: toRemove.map((r) => r.roleName),
});
// Host access is resolved through cached role permissions; drop the
// cache so the new roles apply to this session immediately.
PermissionManager.getInstance().invalidateUserPermissionCache(
userRecord.id,
);
}
}
} catch (roleSyncError) {
authLogger.error("Failed to sync OIDC roles", roleSyncError, {
operation: "oidc_role_map_sync_failed",
userId: userRecord.id,
});
}
try {
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
} catch (setupError) {
+35
View File
@@ -0,0 +1,35 @@
import { sql, type SQLWrapper } from "drizzle-orm";
/**
* Sync cursors and stored timestamps do not share a layout.
*
* `updated_at` and `deleted_at` are TEXT columns written both by
* `default(sql`CURRENT_TIMESTAMP`)` ("2026-07-29 10:11:21") and by
* `new Date().toISOString()` ("2026-07-29T10:11:21.123Z"), while the desktop
* sync engine always sends the ISO form as `since`. Comparing those as text is
* decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the
* answer depends on which writer produced the row rather than on when it was
* written -- and `column > :isoCursor` is false for every row stored in the
* CURRENT_TIMESTAMP form, however new it is.
*
* Both layouts share a prefix once the separator is levelled, so comparing
* "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and
* `substr` are used rather than `datetime()` to keep the expression portable
* across engines.
*/
export const CANONICAL_TIMESTAMP_LENGTH = 19;
export function normalizeSyncTimestamp(value: string): string {
return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH);
}
/**
* `>=` rather than `>`: normalising truncates sub-second precision, so a strict
* comparison would permanently skip rows written in the same second as the
* cursor. Re-sending that boundary second costs nothing -- the sync engine only
* pushes a row when one side is strictly newer, so rows equal on both sides are
* a no-op.
*/
export function timestampAtOrAfter(column: SQLWrapper, since: string) {
return sql`substr(replace(${column}, 'T', ' '), 1, ${CANONICAL_TIMESTAMP_LENGTH}) >= ${normalizeSyncTimestamp(since)}`;
}