Files
Termix/src/backend/database/routes/termix-id.ts
T
+7 ddbdd5c437 release-2.5.1 (#1067)
* chore(deps): bump node from 24-slim to 26-slim in /docker in the docker-major-updates group (#1021)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump node in /docker in the docker-major-updates group

Bumps the docker-major-updates group in /docker with 1 update: node.


Updates `node` from 24-slim to 26-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-major-updates
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.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-dev): bump the dev-patch-updates group with 24 updates (#1023)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps-dev): bump the dev-patch-updates group with 24 updates

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

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.1` | `2.5.2` |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.10.3` | `6.10.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.1` | `6.43.5` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.14` | `1.2.15` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.5` | `1.3.6` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.18` | `2.1.19` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.10` | `2.1.11` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.10` | `1.1.11` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.12` | `1.2.13` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.1` | `2.3.2` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.10` | `1.1.11` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.1` | `1.4.2` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.1` | `1.3.2` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.10` | `1.2.11` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.1` | `4.3.2` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` |
| [i18next](https://github.com/i18next/i18next) | `26.3.1` | `26.3.4` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.0` | `1.6.1` |
| [sharp](https://github.com/lovell/sharp) | `0.35.2` | `0.35.3` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.1` | `4.3.2` |


Updates `@biomejs/biome` from 2.5.1 to 2.5.2
- [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.2/packages/@biomejs/biome)

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

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

Updates `@radix-ui/react-accordion` from 1.2.14 to 1.2.15
- [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.17 to 1.1.18
- [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.5 to 1.3.6
- [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.17 to 1.1.18
- [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.18 to 2.1.19
- [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.10 to 2.1.11
- [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.17 to 1.1.18
- [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.10 to 1.1.11
- [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.12 to 1.2.13
- [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.1 to 2.3.2
- [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.10 to 1.1.11
- [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.1 to 1.4.2
- [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-switch` from 1.3.1 to 1.3.2
- [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.15 to 1.1.16
- [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.10 to 1.2.11
- [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 `@tailwindcss/vite` from 4.3.1 to 4.3.2
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/@tailwindcss-vite)

Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3
- [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.3/packages/plugin-react)

Updates `i18next` from 26.3.1 to 26.3.4
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.1...v26.3.4)

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

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

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

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.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.15
  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.18
  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.6
  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.18
  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.19
  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.11
  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.18
  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.11
  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.13
  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.2
  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.11
  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.2
  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.2
  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.16
  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.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: radix-ui
  dependency-version: 1.6.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.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-patch-updates group with 2 updates (#1025)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps): bump the prod-patch-updates group with 2 updates

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


Updates `axios` from 1.18.0 to 1.18.1
- [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.0...v1.18.1)

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

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.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 (#1026)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

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

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


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

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

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

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: motion
  dependency-version: 12.42.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: undici
  dependency-version: 8.7.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: LukeGus <bugattiguy527@gmail.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-dev): bump electron from 42.4.1 to 43.0.0 in the major-updates group (#1027)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore(deps-dev): bump electron in the major-updates group

Bumps the major-updates group with 1 update: [electron](https://github.com/electron/electron).


Updates `electron` from 42.4.1 to 43.0.0
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v42.4.1...v43.0.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 43.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.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 MC syntax highlighting artifacts (#996)

* Filter dashboard status hosts (#997)

* Persist dashboard service link changes (#999)

* Fix snippet text overflow (#1000)

* Persist remote desktop credential auth (#1001)

* Guard language switching failures (#1002)

* Resolve tunnel source credentials (#1003)

* Support Vault auth for monitors (#1004)

* Fix Windows file delete command (#1005)

* Fix release artifact checkout ref (#1006)

* Fix command palette escape in fullscreen (#1008)

* Fix alerts and audit log data normalization (#1010)

* Fix macOS VNC protocol negotiation (#1012)

* Fix port knocking before SSH connect (#1013)

* Allow Escape to close link confirmation (#1014)

* Prevent Electron modifier wheel zoom (#1016)

* Fix credential auth optional password (#1009)

* Retry transient terminal DNS lookups (#1011)

* Retry transient terminal DNS lookups

* Apply DNS retry to SSH entry points

* Fix OIDC redirect forwarded port handling (#1007)

* Preserve recent open tabs on startup (#1015)

* Fix fish prompt OSC highlighting (#998)

* Fix terminal font selection (#1018)

* fix: font legibility (#1019)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* fix: font legibility

Text was entirely unreadable in places for me. Especially with themes
like Catppuccin. The muted-foreground text and the tags too similiar to
the background.

---------

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

* fix(file-manager): chunked uploads fail with 'Expected multipart/form-data request' (#1020)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* fix(file-manager): use postForm for chunked uploads so multipart content-type is sent

The fileManagerApi axios instance defaults to Content-Type:
application/json. Axios 1.x's default transformRequest converts a
FormData body to JSON whenever the request content type is
application/json, so every chunk POSTed to /ssh/uploadFileChunk
arrived as a JSON body like {"chunk":{}} and the backend rejected
it with 400 'Expected multipart/form-data request'. This breaks all
uploads of files larger than the 1.5 GiB chunking threshold.

The non-chunked path already uses postForm for /ssh/uploadFileStream;
use it for the chunk path too so axios keeps the FormData intact and
the browser sets the multipart boundary.

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

---------

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

* feat: implement OIDC back-channel logout support with session management (#1028)

* feat: implement OIDC back-channel logout support with session management

* Fix OIDC back-channel logout handling

* Require logout token replay identifiers

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* Add API key host enrollment endpoint (#1029)

* Fix tmux detection for non-POSIX shells (#1030)

* Fix OPKSSH js-yaml ESM import (#1031)

* Fix Android Vietnamese IME input (#1032)

* Fix Firefox RDP clipboard paste (#1033)

* Fix Proxmox discovery over HTTPS (#1041)

* Fix external editor actions in file preview (#1042)

* Allow pinned hosts with name sorting (#1043)

* Fix Firefox desktop OIDC callback (#1044)

* feat(session): add recording and replay (#1049)

* Fix status checks through jump hosts (#1045)

* Add terminal font size shortcuts (#1047)

* feat: add Open File Manager to tab right-click menu (#1051)

Co-authored-by: SankeerthNara <sankeerthnara@gmail.com>

* perf: frontend request cache, poll pause, and code-split shell (#1052)

Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists.

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* Merge commit from fork

* feat: save quick connect sessions as hosts (#1055)

* fix: restore sudo password autofill settings (#1056)

* fix: preserve file editor position on save (#1057)

* fix: sync cloud preference storage mode (#1058)

* fix: render RDP sessions at native pixel density (#1059)

* fix: restore database import in embedded desktop mode (#1060)

* Update Auto-complete.tsx (#1061)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore: donation bar reporting wrong result

* feat: add Open File Manager to tab right-click menu (#1046)

* Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050)

This reverts commit 0712fdd731.

* Remove donation badge from README

Removed donation badge from README.

* Delete .github/workflows/donation-goal.yml

* Update Auto-complete.tsx

---------

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

* feat(auth): opt-in OIDC DEK unlock for API-key requests (ALLOW_APIKEY_DATA_UNLOCK) (#1064)

* chore: fix release workflow to merge docs branch

* fix: svg donation generator push fail

* fix: svg donation generator push fail

* Update termix.rb

* fix: svg donation generator push fail

* chore: move donation badge to badges branch to avoid ruleset conflicts

* chore: remove unneeded token from donation badge workflow

* chore: debug donation badge commit step

* fix: escape < character in donation SVG

* fix: point donation badge to badges branch

* chore: remove unused donation badge svg from main

* Add Rack Genius logo to README

Added Rack Genius logo to the README.

* chore: improve donation goal svg generator to include stablecoins

* chore: donation goal generator syntax error

* chore: donation goal generator incorrect docs url usage

* chore: donation bar reporting wrong result

* feat: add Open File Manager to tab right-click menu (#1046)

* Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050)

This reverts commit 0712fdd731.

* Remove donation badge from README

Removed donation badge from README.

* Delete .github/workflows/donation-goal.yml

* feat(auth): opt-in OIDC DEK unlock for API-key requests

API keys authenticate but cannot touch the encrypted credential/host store
('User data not unlocked') unless the user has a live interactive session,
making them unusable for headless automation. For OIDC users the DEK is
server-derivable (deriveOIDCSystemKey), so handleApiKeyAuth can unlock it
without a password.

Gated behind ALLOW_APIKEY_DATA_UNLOCK (default off) because enabling it widens
the blast radius of a leaked API key. OIDC-only; password users are untouched.

Refs #1063

---------

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

* chore: package lock sync

* Add Proxmox guest auto sync (#1053)

* draft: database layer refactor (#1054)

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

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

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

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

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

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

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

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

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

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

---------

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Bumps the major-updates group with 5 updates:

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


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

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

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

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

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

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

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

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

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

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

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

Two bugs introduced in v2.4.1:

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

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

Fixes #928

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

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

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

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

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

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

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

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

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

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

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

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

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

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

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

* chore: start database layer refactor

* docs: plan database layer refactor

* docs: audit database layer refactor phase zero

* chore: add database runtime adapter skeleton

* chore: add settings repository skeleton

* chore: add user session repository skeleton

* chore: add host credential repository skeleton

* chore: add field encryption boundary

* chore: migrate settings route slice

* chore: migrate user settings routes

* chore: migrate host metrics settings routes

* chore: migrate acme settings route

* chore: migrate terminal settings route

* chore: migrate tailscale settings read

* chore: migrate guacamole settings reads

* chore: migrate session timeout settings reads

* chore: migrate auth route settings reads

* chore: migrate host metrics settings reads

* chore: migrate startup settings reads

* chore: migrate user settings cleanup

* chore: migrate password reset settings

* chore: migrate oidc legacy settings read

* chore: migrate user route settings slice

* chore: migrate oidc state settings

* chore: migrate user login settings reads

* chore: migrate user crypto settings

* chore: consolidate startup settings defaults

* chore: consolidate database settings import export

* chore: migrate core session auth paths

* chore: migrate remaining session auth paths

* chore: migrate admin user routes

* chore: migrate user route admin checks

* chore: migrate user lifecycle routes

* chore: migrate auth user lookups

* chore: migrate oidc user routes

* chore: migrate api key repository paths

* docs: add database gray rollout guide

* chore: migrate trusted device paths

* chore: migrate user session route user lookups

* chore: add database repository rollout guard

* chore: expose repository rollout status

* chore: warn on repository rollout misconfiguration

* chore: migrate remaining user lookup helpers

* chore: migrate ssh user lookups

* chore: migrate user settings admin lookups

* chore: migrate acme ssl user lookups

* chore: migrate audit log admin checks

* chore: migrate oidc account user updates

* chore: migrate password reset user updates

* chore: migrate user deletion core records

* chore: migrate snippet audit user lookups

* chore: migrate ldap user sync paths

* chore: migrate totp user updates

* chore: migrate rbac user checks

* chore: migrate rbac role paths

* chore: migrate permission role lookups

* chore: migrate rbac access list reads

* chore: migrate shared rbac reads

* chore: migrate rbac access writes

* chore: migrate permission host access

* chore: migrate role host access lookup

* chore: migrate snippet access lookup

* chore: migrate shared credential access lookups

* chore: migrate host access cleanup writes

* chore: migrate host list access checks

* chore: migrate host access cleanup routes

* chore: migrate shared credential role lookups

* chore: migrate user role cleanup

* chore: migrate admin role sync

* chore: migrate ldap role sync

* chore: migrate user role assignment

* chore: migrate sso provider access

* chore: migrate audit log access

* chore: migrate user preference access

* chore: migrate open tab access

* chore: migrate dismissed alert access

* chore: migrate homepage layout access

* chore: migrate network topology access

* chore: migrate dashboard service link access

* chore: migrate command history access

* chore: migrate recent activity cleanup

* chore: migrate ssh credential usage access

* chore: migrate transfer recent access

* chore: migrate file manager bookmark access

* chore: migrate c2s tunnel preset access

* chore: migrate homepage item access

* chore: migrate session recording access

* chore: migrate tmux session tag access

* chore: migrate opkssh token access

* chore: migrate vault token access

* chore: migrate vault profile access

* chore: migrate host metrics preference access

* chore: migrate host health access

* chore: migrate host metrics history access

* chore: migrate alert persistence access

* chore: route alert host lookup through repository

* chore: migrate user data export reads

* chore: route host metrics stats sync through repository

* chore: migrate host folder persistence

* chore: migrate host resolution reads

* chore: route jump host resolution reads

* chore: route docker console jump host reads

* chore: route docker ssh resolution reads

* chore: route proxmox discovery resolution reads

* chore: route file manager activity host reads

* chore: route host metrics resolution reads

* chore: route ssh auth credential reads

* chore: route tunnel endpoint credential reads

* chore: route credential deployment resolution reads

* chore: route command history host flag reads

* chore: route snippet execution resolution reads

* chore: route terminal host resolution reads

* chore: route vault oidc host resolution reads

* chore: route wake on lan host reads

* chore: route internal host list reads

* chore: route host key verification persistence

* chore: route credential read paths

* chore: route credential host usage reads

* chore: route credential folder rename

* chore: route host owner access checks

* chore: route shared credential source reads

* chore: route user host credential cleanup

* chore: route credential delete reads

* chore: route credential update reads

* chore: route host credential reads

* chore: route host read paths

* chore: route host projection reads

* chore: route host list reads

* chore: route snippet read paths

* chore: route snippet folder writes

* chore: route snippet crud paths

* chore: route snippet bulk import

* chore: route rbac ownership reads

* chore: route user count reads

* chore: route cleanup snippets folders

* chore: route shared credential persistence

* chore: route dashboard activity

* chore: route guacamole host reads

* chore: route host bulk lookups

* chore: remove unlock-only simple db ops

* chore: route host autostart persistence

* chore: route ldap provisioning through users

* chore: route credential encrypted writes

* chore: route host encrypted writes

* chore: route bulk host encrypted writes

* chore: route termix id credentials

* chore: route termix id ca persistence

* chore: route termix identity persistence

* chore: route credential system migration

* chore: isolate user encryption migration storage

* chore: remove legacy simple db ops

* chore: isolate legacy sqlite migration copy

* chore: route database settings import export

* chore: route database host credential export

* chore: route database host credential import

* chore: route database file-manager import export

* chore: route database alert usage import export

* chore: route database user checks

* chore: isolate auth lazy migration storage

* chore: route explicit database saves

* chore: initialize database save boundary

* chore: route migration snapshot saves

* chore: isolate sqlite import constraints

* chore: route import sqlite boundary

* chore: route user encryption migration store

* chore: centralize current repository runtime

* chore: route more current repositories

* chore: route activity repository runtimes

* chore: route token repository runtimes

* chore: route health repository runtimes

* chore: route identity repository runtimes

* chore: route rbac repository runtime

* chore: centralize current sqlite runtime access

* chore: route user deletion key cleanup

* chore: route user deletion vault cleanup

* chore: route user deletion homepage cleanup

* chore: route user deletion health cleanup

* chore: route user deletion alert cleanup

* chore: route user deletion identity cleanup

* chore: add database layer preupgrade backup

* Fix database repository type errors

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>

* refactor(db): collapse repository rollout scaffolding into single factory

Repositories are now the only data path. Replaces the 41 current-*-repository
wrapper files, the DATABASE_LAYER_REPOSITORY_ROLLOUT flag/alias map and the
unused database/runtime adapter with repositories/factory.ts, a plain
DatabaseContext type and an in-memory TestSqliteDatabase test harness.

* refactor(db): route remaining raw DB access through repositories

proxmox, session-log, oidc-utils, webauthn and guacamole recording now use
repositories (new WebauthnCredentialRepository; SsoProviderRepository
listEnabled; HostRepository findDecryptedByIdAs/listProxmoxEnabled).
Remaining raw access: db boot code, simple-db-ops and docker.ts, which are
removed/restructured in later phases.

* feat(crypto): add UserKeyManager with system-wrapped per-user DEKs

New utils/user-keys.ts: one random 32-byte DEK per user, wrapped
AES-256-GCM under an HKDF key derived from the system ENCRYPTION_KEY
(per-user info string + AAD binding, versioned v3 wrap format stored in
settings). Synchronous unwrap-on-demand with a 15-minute cache so the
existing DataCrypto facade keeps its sync call sites. Not wired up yet.

* feat(crypto): boot-time DEK migration to system-wrapped v3 format

utils/crypto-migration/dek-migration.ts carries the legacy unwrap paths
(PBKDF2 password KEK, OIDC/WebAuthn system keys, hardcoded-default
fallback) and migrates every server-unwrappable DEK to the v3 wrap at
startup. Password-wrapped DEKs migrate at next login or from a live
session via adoptRecoveredDEK. Legacy rows are kept for now; cleanup
flips on once the new path is authoritative.

* refactor(crypto): make system-wrapped DEKs the authoritative key path

DataCrypto and AuthManager now read keys through UserKeyManager: DEKs are
always unwrappable server-side, so the in-memory unlock session, DEK-in-JWT
wrapping, session-expiry data locks and ALLOW_APIKEY_DATA_UNLOCK are gone.
utils/user-crypto.ts is deleted; boot migration now cleans legacy wraps.
A one-release shim adopts DEKs from legacy dataKeyWrap tokens so active
password users migrate without re-login. Password login migrates legacy
password-wrapped DEKs via migratePasswordUserAtLogin.

* refactor(crypto): remove pending share queue and credential sharing key

With server-unwrappable DEKs both sides of a share are always available,
so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_*
shadow columns on ssh_credentials are gone. A one-time boot cleanup
re-creates legacy pending share copies where possible (dropping
unresolvable ones with a warning) and drops the legacy columns.

* feat(auth): non-destructive password resets and admin reset endpoint

Password resets no longer destroy user data: the DEK is system-wrapped, so
forgot-password and admin resets are just a hash update plus session revoke.
The wipe branch survives only for accounts that never logged in since the
encryption upgrade and now requires explicit confirmDataWipe (surfaced as a
409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds
POST /users/admin/reset-password and removes the dead re-encryption paths.

* refactor(ssh): consolidate four jump-host chain copies into one module

terminal, host-metrics and docker now use ssh/jump-host-chain.ts (already
shared by file-manager, tmux-monitor and docker-console); docker's inline
copy also drops its raw SimpleDBOps host/credential lookups in favor of
repositories.

* refactor(ssh): single shared createConnectionLog helper

file-manager-log.ts becomes ssh/connection-log.ts; the copies in docker.ts
and host-metrics-helpers.ts are gone.

* refactor(ssh): split docker module into layered directory

ssh/docker/{index,routes,session-manager,container-routes,console}.ts:
server boot and wiring in index, HTTP handlers in routes, SSH session
registry and command execution in session-manager. Code motion only;
port 30007/30009 and endpoints unchanged. Swagger now scans ssh
subdirectories.

* refactor(ssh): split tunnel module into layered directory

ssh/tunnel/{index,routes,manager}.ts: server boot in index, HTTP handlers
in routes, tunnel state and engine (connect/retry/autostart) in manager.
Code motion only; port 30003 and endpoints unchanged.

* refactor(backend): reorganize top-level layout

- ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics)
- serial/serial.ts and guacamole/ moved inside hosts/
- dashboard.ts and homepage.ts moved to services/
- swagger.ts moved to utils/ with adjusted scan globs

Import paths and the generate:openapi script updated; ports and endpoints
unchanged.

* refactor(tests): move backend tests into src/backend/tests mirror tree

Backend *.test.ts files (and the test-support harness) no longer sit next
to source files; they live under src/backend/tests/ mirroring the source
layout. Imports rewritten accordingly; CLAUDE.md convention updated.

* refactor(hosts): group host modules into per-feature directories

file-manager/, metrics/ (incl. widgets, managers, alert-engine),
terminal/, tmux/ and tunnel/ each own their files; docker/ gains
container-runtime. Genuinely shared helpers (jump-host chain, host
resolver, connection pool, opkssh, vault, serial) stay at hosts/ root.
Pure file moves with import path updates; mirrored test paths follow.

* refactor(backend): final cleanup pass

- re-register WebAuthn passkey routes (registration was dropped in the
  #1054 merge, breaking passkey login) and document all six endpoints
- delete utils/simple-db-ops.ts (last caller migrated to DataCrypto)
- starter: use the typed serverReady export, collapse the four-way
  version lookup to env then package.json candidates
- add OpenAPI JSDoc to c2s-tunnel-presets endpoints
- strip block-divider comment banners

* feat: remove legacy "data_unlocked" field

* feat: refactor rbac/sharing to support new permissions and auth types

* feat: refactor rbac/sharing to support new permissions and auth types

* feat: add "id" to user profile hide list

* chore: root cleanup

* feat: add more donation references and a 30-day donation reminder

* chore: update readme

* feat: automate beta tests

* feat: add links to milestones

* fix: hoist github/google SSO defaults to module scope (#1065)

* fix(ssh-tools): allow clipboard paste in key recording field (#1066)

The broadcast key-recording input was marked readOnly, which makes
browsers block paste entirely (no context-menu Paste, Ctrl+V does
nothing). handleKeyDown also called preventDefault() unconditionally,
swallowing the Ctrl+V shortcut before a paste event could even fire.

Let Ctrl/Cmd+V pass through in handleKeyDown, drop readOnly, and add
an onPaste handler that reads the clipboard text and broadcasts it to
the selected terminals like any other captured keystroke.

Signed-off-by: emreumar <emreumar@users.noreply.github.com>
Co-authored-by: emreumar <emreumar@users.noreply.github.com>

* chore: write release notes

* chore: update release notes

* chore: update readmes

* chore: add crypto only reminder in en.json

* fix: macOS and cask errors on release workflow

* chore: sync Crowdin translations for 2.5.1

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: emreumar <emreumar@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Russell Poovey <09.our_seekers@icloud.com>
Co-authored-by: russell <git@0896c69e.com>
Co-authored-by: Subedi Bibek <77529535+questbibek@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexander Elsner <101340634+Bensonheimer992@users.noreply.github.com>
Co-authored-by: SankeerthNara <sankeerthnara@gmail.com>
Co-authored-by: Stephan Groth <96803994+Kalvalax@users.noreply.github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: lhojun <ldgs3324@gmail.com>
Co-authored-by: Yunus Emre Umar <77045015+emre155@users.noreply.github.com>
Co-authored-by: emreumar <emreumar@users.noreply.github.com>
2026-07-19 12:29:52 -05:00

1425 lines
43 KiB
TypeScript

import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import {
createCurrentCredentialRepository,
createCurrentTermixIdentityRepository,
createCurrentTermixIdentityCaRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
// ssh2 is CommonJS; Node's cjs-module-lexer does not surface its `utils` named
// export, so we use a default import (esModuleInterop) and read `.utils` off it.
import ssh2 from "ssh2";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { parsePublicKey, matchesAlgoFilter } from "./termix-id-keys.js";
import {
generateCa,
signUserCertificate,
ed25519RawFromLine,
} from "./ssh-certificate.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Handle: github-like public namespace. lowercase, starts alphanumeric,
// 1-39 chars of [a-z0-9_-].
const HANDLE_REGEX = /^[a-z0-9][a-z0-9_-]{0,38}$/;
const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]);
// Max stored length for a free-text description.
const MAX_DESCRIPTION_LENGTH = 500;
function cleanDescription(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null;
}
// True when a write failed because of a UNIQUE constraint (handle / one-per-user),
// so the handler can return a clean 409 instead of a generic 500.
function isUniqueConstraintError(err: {
code?: string;
message?: string;
}): boolean {
return (
err?.code === "SQLITE_CONSTRAINT_UNIQUE" ||
(typeof err?.message === "string" &&
err.message.includes("UNIQUE constraint failed"))
);
}
// A create can violate either the handle or the one-per-user (user_id) UNIQUE
// constraint in a concurrent double-submit; report the right 409 for each.
function uniqueConstraintMessage(err: { message?: string }): string {
return typeof err?.message === "string" && err.message.includes("user_id")
? "You already have a Termix ID. Use update to rename it."
: "Handle already taken";
}
/**
* Best-effort derivation of a public key from a private key using ssh2, used
* when importing a credential that only stored a private key.
*/
async function derivePublicFromPrivate(
privateKey: string,
passphrase?: string,
): Promise<string | null> {
try {
const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined);
if (!parsed || parsed instanceof Error) return null;
return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
async function getIdentityForUser(userId: string) {
return createCurrentTermixIdentityRepository().findIdentityForUser(userId);
}
// Resolve the real username for audit_logs (the column expects a username, not
// the user id), matching how the credentials/snippets routes log.
async function getActorUsername(userId: string): Promise<string> {
const user = await createCurrentUserRepository().findById(userId);
return user?.username ?? userId;
}
// Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a
// small HTML viewer for browsers, keyed by handle, with optional /<ALGO> filter.
// Never let an intermediary/CDN cache a public resolver feed (a disabled/removed
// key could keep being served after revocation), and keep it out of search
// indexes. Applied to EVERY response — including early 404s.
function setResolverHeaders(res: Response) {
res.setHeader("Cache-Control", "no-store, max-age=0");
res.setHeader("X-Robots-Tag", "noindex, nofollow");
}
async function resolveHandle(req: Request, res: Response) {
setResolverHeaders(res);
const handle = String(req.params.handle || "").toLowerCase();
const algoFilter = req.params.algo
? String(req.params.algo).toUpperCase()
: null;
if (!HANDLE_REGEX.test(handle)) {
return res.status(404).type("text/plain").send("Not found\n");
}
try {
const identity =
await createCurrentTermixIdentityRepository().findIdentityByHandle(
handle,
);
if (!identity) {
return res.status(404).type("text/plain").send("Not found\n");
}
let keys =
await createCurrentTermixIdentityRepository().listEnabledKeysByIdentityId(
identity.id,
);
if (algoFilter) {
keys = keys.filter((k) => matchesAlgoFilter(k.algorithm, algoFilter));
}
const wantsHtml = (req.headers.accept || "").includes("text/html");
if (wantsHtml) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.send(renderHtml(handle, keys));
}
const body = keys
.map((k) => `${k.publicKey} #termix-id @${handle}`)
.join("\n");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
return res.send(body ? `${body}\n` : "");
} catch (err) {
authLogger.error("Termix ID resolve failed", err);
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function renderHtml(
handle: string,
keys: Array<{ algorithm: string; comment: string | null; publicKey: string }>,
): string {
const keyRows = keys.length
? keys
.map(
(k) =>
`<div class="key"><span class="algo">${escapeHtml(
k.algorithm,
)}</span><code>${escapeHtml(k.publicKey)}</code></div>`,
)
.join("")
: `<p class="empty">No public keys published yet.</p>`;
// Standalone page (not the React app), so the Termix dark theme is inlined.
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<meta name="color-scheme" content="dark" />
<title>Termix ID — @${escapeHtml(handle)}</title>
<style>
:root {
--bg: #0c0d0b; --panel: #141614; --panel-2: #181a17; --border: rgba(255,255,255,.12);
--fg: #f8f8f6; --muted: #8f9189; --accent: #f59145;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); line-height: 1.5;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.wrap { max-width: 820px; margin: 0 auto; padding: 32px 16px 48px; }
.card { background: var(--panel); border: 1px solid var(--border); }
header.card { padding: 16px 18px; display: flex; align-items: center; gap: 10px; }
h1 { font-size: 1.05rem; font-weight: 700; margin: 0; letter-spacing: .02em; color: var(--fg); }
h1 .handle { color: var(--accent); }
.label { font-size: 10px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase;
color: var(--muted); margin: 22px 0 8px; }
pre.cmd { margin: 0; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted);
padding: 12px 14px; overflow-x: auto; font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.keys { display: flex; flex-direction: column; gap: 8px; }
.key { background: var(--panel); border: 1px solid var(--border); padding: 10px 12px;
display: flex; align-items: center; gap: 10px; overflow-x: auto; }
.algo { flex: 0 0 auto; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: var(--accent); border: 1px solid rgba(245,145,69,.4); padding: 1px 5px; line-height: 1.4; }
code { white-space: pre; word-break: break-all; font-size: 12px; color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.empty { color: var(--muted); font-size: 13px; }
footer { margin-top: 28px; color: var(--muted); font-size: 11px; letter-spacing: .04em; }
</style>
</head>
<body>
<div class="wrap">
<header class="card">
<h1>Termix ID <span class="handle">@${escapeHtml(handle)}</span></h1>
</header>
<div class="label">Provision a server</div>
<pre class="cmd">curl -fsSL https://your-termix-host/termix-id/u/${escapeHtml(
handle,
)} >> ~/.ssh/authorized_keys</pre>
<script>
(function () {
var el = document.querySelector("pre.cmd");
if (el) el.textContent =
"curl -fsSL " + location.origin + "/termix-id/u/${escapeHtml(
handle,
)} >> ~/.ssh/authorized_keys";
})();
</script>
<div class="label">Published keys</div>
<div class="keys">${keyRows}</div>
<footer>Served by Termix</footer>
</div>
</body>
</html>`;
}
// Public CA key — for `TrustedUserCAKeys` or an `@cert-authority` line. Must be
// registered before `/u/:handle/:algo` so "ca" is not treated as an algo filter.
async function caResolver(req: Request, res: Response) {
setResolverHeaders(res);
const handle = String(req.params.handle || "").toLowerCase();
if (!HANDLE_REGEX.test(handle)) {
return res.status(404).type("text/plain").send("Not found\n");
}
try {
const identity =
await createCurrentTermixIdentityRepository().findIdentityByHandle(
handle,
);
if (!identity) {
return res.status(404).type("text/plain").send("Not found\n");
}
const ca =
await createCurrentTermixIdentityCaRepository().findPublicByIdentityId(
identity.id,
);
res.setHeader("Content-Type", "text/plain; charset=utf-8");
if (!ca) {
return res.status(404).send("No CA configured\n");
}
return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`);
} catch (err) {
authLogger.error("Termix ID CA resolve failed", err);
return res.status(500).type("text/plain").send("Internal Server Error\n");
}
}
router.get("/u/:handle/ca", caResolver);
router.get("/u/:handle", resolveHandle);
router.get("/u/:handle/:algo", resolveHandle);
// Authenticated management API.
/**
* @openapi
* /termix-id/me:
* get:
* summary: Get current user's Termix ID and keys
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Identity and keys (identity may be null)
*/
router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res.json({ identity: null, keys: [] });
}
const keys =
await createCurrentTermixIdentityRepository().listKeysByIdentityId(
identity.id,
);
res.json({
identity: {
...identity,
resolverPath: `/termix-id/u/${identity.handle}`,
},
keys,
});
} catch (err) {
authLogger.error("Failed to fetch Termix ID", err);
res.status(500).json({ error: "Failed to fetch Termix ID" });
}
});
/**
* @openapi
* /termix-id/check/{handle}:
* get:
* summary: Check if a handle is available
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Availability and validity status
*/
router.get(
"/check/:handle",
authenticateJWT,
async (req: Request, res: Response) => {
const handle = String(req.params.handle || "").toLowerCase();
if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) {
return res.json({ available: false, valid: false });
}
try {
const taken =
await createCurrentTermixIdentityRepository().isHandleTaken(handle);
res.json({ available: !taken, valid: true });
} catch (err) {
authLogger.error("Failed to check Termix ID handle", err);
res.status(500).json({ error: "Failed to check handle" });
}
},
);
/**
* @openapi
* /termix-id:
* post:
* summary: Create a Termix ID
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [handle]
* properties:
* handle:
* type: string
* description:
* type: string
* responses:
* 201:
* description: Created identity
* 409:
* description: Handle taken or user already has an identity
*/
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const handle = String(req.body?.handle || "").toLowerCase();
const description = cleanDescription(req.body?.description);
if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
try {
const owned = await getIdentityForUser(userId);
if (owned) {
return res.status(409).json({
error: "You already have a Termix ID. Use update to rename it.",
});
}
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const taken = await termixIdentityRepository.isHandleTaken(handle);
if (taken) {
return res.status(409).json({ error: "Handle already taken" });
}
const inserted = await termixIdentityRepository.createIdentity({
userId,
handle,
description,
});
databaseLogger.info("Termix ID created", {
operation: "termix_id_create",
userId,
handle,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "create_termix_id",
resourceType: "termix_id",
resourceId: String(inserted.id),
resourceName: handle,
ipAddress,
userAgent,
success: true,
});
res.status(201).json(inserted);
} catch (err) {
// The check-then-insert above is not atomic; the UNIQUE constraints on
// handle and user_id are the real guard, so map a violation to 409.
if (isUniqueConstraintError(err)) {
return res.status(409).json({ error: uniqueConstraintMessage(err) });
}
authLogger.error("Failed to create Termix ID", err);
res.status(500).json({ error: "Failed to create Termix ID" });
}
});
/**
* @openapi
* /termix-id:
* put:
* summary: Update Termix ID handle or description
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* handle:
* type: string
* description:
* type: string
* responses:
* 200:
* description: Updated identity
*/
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res.status(404).json({ error: "No Termix ID found" });
}
const updates: { handle?: string; description?: string | null } = {};
if (req.body?.handle !== undefined) {
const handle = String(req.body.handle || "").toLowerCase();
if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) {
return res.status(400).json({ error: "Invalid handle" });
}
if (handle !== identity.handle) {
const taken =
await createCurrentTermixIdentityRepository().isHandleTaken(handle);
if (taken) {
return res.status(409).json({ error: "Handle already taken" });
}
}
updates.handle = handle;
}
if (req.body?.description !== undefined) {
updates.description = cleanDescription(req.body.description);
}
const updated =
await createCurrentTermixIdentityRepository().updateIdentityForUser(
userId,
{ ...updates, updatedAt: new Date().toISOString() },
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "update_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: updated?.handle ?? identity.handle,
details:
updates.handle && updates.handle !== identity.handle
? `renamed ${identity.handle} -> ${updates.handle}`
: undefined,
ipAddress,
userAgent,
success: true,
});
res.json(updated);
} catch (err) {
if (isUniqueConstraintError(err)) {
return res.status(409).json({ error: "Handle already taken" });
}
authLogger.error("Failed to update Termix ID", err);
res.status(500).json({ error: "Failed to update Termix ID" });
}
});
/**
* @openapi
* /termix-id:
* delete:
* summary: Delete Termix ID and all associated keys
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
*/
router.delete("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res.status(404).json({ error: "No Termix ID found" });
}
await createCurrentTermixIdentityRepository().deleteIdentityForUser(userId);
databaseLogger.info("Termix ID deleted", {
operation: "termix_id_delete",
userId,
handle: identity.handle,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "delete_termix_id",
resourceType: "termix_id",
resourceId: String(identity.id),
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete Termix ID", err);
res.status(500).json({ error: "Failed to delete Termix ID" });
}
});
/**
* @openapi
* /termix-id/keys:
* post:
* summary: Publish a public key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* publicKey:
* type: string
* credentialId:
* type: integer
* label:
* type: string
* responses:
* 201:
* description: Published key
*/
router.post(
"/keys",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const label =
typeof req.body?.label === "string"
? req.body.label.trim() || null
: null;
const credentialId = req.body?.credentialId
? parseInt(String(req.body.credentialId), 10)
: null;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle before adding keys" });
}
let rawPublicKey: string | null = null;
let source = "manual";
let resolvedLabel = label;
if (credentialId) {
const cred =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
credentialId,
);
if (!cred) {
return res.status(404).json({ error: "Credential not found" });
}
// The UI only offers key credentials, but the UI is not authoritative —
// reject non-key credentials server-side before treating cred.key as a
// private key.
if (cred.authType !== "key") {
return res
.status(400)
.json({ error: "Selected credential is not an SSH key" });
}
source = "credential";
resolvedLabel = resolvedLabel || cred.name || null;
if (cred.publicKey && typeof cred.publicKey === "string") {
rawPublicKey = cred.publicKey;
} else {
const priv = cred.privateKey || cred.key || undefined;
if (priv) {
rawPublicKey = await derivePublicFromPrivate(
priv,
cred.keyPassword || undefined,
);
}
if (!rawPublicKey) {
return res.status(400).json({
error:
"This credential has no public key and one could not be derived. Paste the public key manually.",
});
}
}
} else {
rawPublicKey =
typeof req.body?.publicKey === "string" ? req.body.publicKey : null;
}
const parsed = parsePublicKey(rawPublicKey);
if (!parsed) {
return res.status(400).json({ error: "Invalid SSH public key" });
}
// Dedupe within this identity by normalized "<type> <blob>".
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const existing = await termixIdentityRepository.listKeysByIdentityId(
identity.id,
);
if (existing.some((k) => k.publicKey === parsed.normalized)) {
return res.status(409).json({ error: "This key is already published" });
}
const inserted = await termixIdentityRepository.createKey({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: resolvedLabel,
comment: parsed.comment || null,
source,
credentialId: credentialId || null,
});
databaseLogger.info("Termix ID key added", {
operation: "termix_id_key_add",
userId,
algorithm: parsed.algorithm,
source,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "add_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(inserted.id),
resourceName: identity.handle,
details: `${parsed.algorithm} (${source})`,
ipAddress,
userAgent,
success: true,
});
res.status(201).json(inserted);
} catch (err) {
authLogger.error("Failed to add Termix ID key", err);
res.status(500).json({ error: "Failed to add key" });
}
},
);
/**
* @openapi
* /termix-id/keys/generate:
* post:
* summary: Generate a new key pair and publish the public key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* type:
* type: string
* enum: [ed25519, rsa]
* label:
* type: string
* saveCredential:
* type: boolean
* username:
* type: string
* responses:
* 201:
* description: Generated key info with private key (shown once)
*/
router.post(
"/keys/generate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const keyType = req.body?.type === "rsa" ? "rsa" : "ed25519";
const label =
typeof req.body?.label === "string"
? req.body.label.trim() || null
: null;
// Default to saving the generated key pair into the encrypted credential
// vault so it can be used to connect from Termix (opt out with false).
const saveCredential = req.body?.saveCredential !== false;
const credentialUsername =
typeof req.body?.username === "string" ? req.body.username.trim() : null;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle before adding keys" });
}
const comment = `termix-${identity.handle}`;
const pair =
keyType === "rsa"
? ssh2.utils.generateKeyPairSync("rsa", { bits: 4096, comment })
: ssh2.utils.generateKeyPairSync("ed25519", { comment });
const parsed = parsePublicKey(pair.public);
if (!parsed) {
return res.status(500).json({ error: "Key generation failed" });
}
// Optionally persist the FULL key pair (incl. private key) into the
// encrypted ssh_credentials vault — the same secure, per-user-encrypted
// store used for host credentials. The Termix ID tables themselves only ever
// hold the public key, because the resolver endpoint is unauthenticated.
let credentialId: number | null = null;
if (saveCredential) {
const credData = {
userId,
name: `Termix ID @${identity.handle} (${parsed.algorithm})`,
description: "Auto-generated by Termix ID",
folder: null,
tags: "",
authType: "key",
username: credentialUsername || null,
password: null,
key: pair.private,
privateKey: pair.private,
publicKey: parsed.normalized,
keyPassword: null,
keyType: null,
detectedKeyType: parsed.type,
usageCount: 0,
lastUsed: null,
};
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
credData,
);
credentialId = created.id;
}
// There is no single transaction here because credential encryption is async,
// so if publishing the key fails
// after the vault credential was created, compensate by deleting it to
// avoid leaving an orphaned credential behind.
const termixIdentityRepository = createCurrentTermixIdentityRepository();
const runInsert = () =>
termixIdentityRepository.createKey({
identityId: identity.id,
userId,
publicKey: parsed.normalized,
keyType: parsed.type,
algorithm: parsed.algorithm,
label: label || `Generated ${parsed.algorithm}`,
comment: parsed.comment || null,
source: "generated",
credentialId,
});
let inserted: Awaited<ReturnType<typeof runInsert>>;
try {
inserted = await runInsert();
} catch (insertErr) {
if (credentialId !== null) {
try {
await createCurrentCredentialRepository().deleteForUser(
userId,
credentialId,
);
} catch {
// best-effort cleanup
}
}
throw insertErr;
}
databaseLogger.info("Termix ID key generated", {
operation: "termix_id_key_generate",
userId,
algorithm: parsed.algorithm,
savedCredential: credentialId !== null,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "generate_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(inserted.id),
resourceName: identity.handle,
details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`,
ipAddress,
userAgent,
success: true,
});
// The private key is also returned once so the user can download it; when
// saveCredential is true it additionally lives (encrypted) in the vault.
res.status(201).json({
key: inserted,
privateKey: pair.private,
publicKey: parsed.normalized,
credentialId,
});
} catch (err) {
authLogger.error("Failed to generate Termix ID key", err);
res.status(500).json({ error: "Failed to generate key" });
}
},
);
/**
* @openapi
* /termix-id/keys/{id}:
* patch:
* summary: Update key metadata (enabled state or label)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* label:
* type: string
* responses:
* 200:
* description: Updated key
*/
router.patch(
"/keys/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "Invalid key id" });
}
try {
const updates: { enabled?: boolean; label?: string | null } = {};
if (req.body?.enabled !== undefined) {
updates.enabled = !!req.body.enabled;
}
if (req.body?.label !== undefined) {
updates.label =
typeof req.body.label === "string"
? req.body.label.trim() || null
: null;
}
const updated =
await createCurrentTermixIdentityRepository().updateKeyForUser(
userId,
id,
updates,
);
if (!updated) {
return res.status(404).json({ error: "Key not found" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "update_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: String(updated.label ?? updated.algorithm),
details:
updates.enabled !== undefined
? `enabled: ${updates.enabled}`
: "label updated",
ipAddress,
userAgent,
success: true,
});
res.json(updated);
} catch (err) {
authLogger.error("Failed to update Termix ID key", err);
res.status(500).json({ error: "Failed to update key" });
}
},
);
/**
* @openapi
* /termix-id/keys/{id}:
* delete:
* summary: Revoke and delete a published key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted
*/
router.delete(
"/keys/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "Invalid key id" });
}
try {
const deleted =
await createCurrentTermixIdentityRepository().deleteKeyForUser(
userId,
id,
);
if (!deleted) {
return res.status(404).json({ error: "Key not found" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "delete_termix_id_key",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: deleted[0].algorithm,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete Termix ID key", err);
res.status(500).json({ error: "Failed to delete key" });
}
},
);
// Certificate authority — central revocation (rotate) + expiry (validity).
type CaRow = {
id: number;
publicKey: string;
privateKey: string;
validityDays: number;
};
function clampValidityDays(
value: number | string | null | undefined,
fallback: number,
): number {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(Math.floor(n), 3650);
}
async function getCaForUser(
userId: string,
identityId: number,
): Promise<CaRow | undefined> {
return (
(await createCurrentTermixIdentityCaRepository().findDecryptedByIdentityId(
userId,
identityId,
)) ?? undefined
);
}
/**
* @openapi
* /termix-id/ca:
* get:
* summary: Get current user's certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: CA info or null
*/
router.get("/ca", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.json({ ca: null });
const ca =
await createCurrentTermixIdentityCaRepository().findPublicByIdentityId(
identity.id,
);
if (!ca) return res.json({ ca: null });
res.json({
ca: {
publicKey: ca.publicKey,
validityDays: ca.validityDays,
resolverPath: `/termix-id/u/${identity.handle}/ca`,
},
});
} catch (err) {
authLogger.error("Failed to fetch Termix ID CA", err);
res.status(500).json({ error: "Failed to fetch CA" });
}
});
/**
* @openapi
* /termix-id/ca:
* post:
* summary: Create a certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* responses:
* 201:
* description: Created CA
*/
router.post(
"/ca",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) {
return res
.status(400)
.json({ error: "Create a Termix ID handle first" });
}
const caRepository = createCurrentTermixIdentityCaRepository();
const existing = await caRepository.findPublicByIdentityId(identity.id);
if (existing) {
return res.status(409).json({ error: "CA already exists" });
}
const validityDays = clampValidityDays(req.body?.validityDays, 90);
const generated = generateCa();
await caRepository.createEncryptedForUser(userId, {
identityId: identity.id,
userId,
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "create_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.status(201).json({
publicKey: generated.publicKeyLine,
validityDays,
resolverPath: `/termix-id/u/${identity.handle}/ca`,
});
} catch (err) {
authLogger.error("Failed to create Termix ID CA", err);
res.status(500).json({ error: "Failed to create CA" });
}
},
);
/**
* @openapi
* /termix-id/ca/rotate:
* post:
* summary: Rotate the certificate authority (revokes all issued certificates)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* responses:
* 200:
* description: New CA public key
*/
router.post(
"/ca/rotate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const caRepository = createCurrentTermixIdentityCaRepository();
const existing = await caRepository.findPublicByIdentityId(identity.id);
if (!existing) {
return res.status(404).json({ error: "No CA to rotate" });
}
const validityDays = clampValidityDays(
req.body?.validityDays,
existing.validityDays,
);
const generated = generateCa();
// Rotating invalidates every previously issued certificate — this IS the
// central revocation mechanism.
await caRepository.updateEncryptedForIdentity(userId, identity.id, {
publicKey: generated.publicKeyLine,
privateKey: generated.privateKeyPem,
validityDays,
updatedAt: new Date().toISOString(),
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "rotate_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.json({
publicKey: generated.publicKeyLine,
validityDays,
resolverPath: `/termix-id/u/${identity.handle}/ca`,
});
} catch (err) {
authLogger.error("Failed to rotate Termix ID CA", err);
res.status(500).json({ error: "Failed to rotate CA" });
}
},
);
/**
* @openapi
* /termix-id/ca:
* delete:
* summary: Delete the certificate authority
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Deleted
*/
router.delete("/ca", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.status(404).json({ error: "No Termix ID found" });
const deleted =
await createCurrentTermixIdentityCaRepository().deleteByIdentityId(
identity.id,
);
if (!deleted) {
return res.status(404).json({ error: "No CA to delete" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "delete_termix_id_ca",
resourceType: "termix_id_ca",
resourceName: identity.handle,
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete Termix ID CA", err);
res.status(500).json({ error: "Failed to delete CA" });
}
});
/**
* @openapi
* /termix-id/keys/{id}/certificate:
* post:
* summary: Issue an SSH certificate for a key (ed25519 only)
* tags: [Termix ID]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* validityDays:
* type: integer
* principals:
* type: array
* items:
* type: string
* responses:
* 200:
* description: Issued certificate
*/
router.post(
"/keys/:id/certificate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
return res.status(400).json({ error: "Invalid key id" });
}
try {
const key = await createCurrentTermixIdentityRepository().findKeyForUser(
userId,
id,
);
if (!key) {
return res.status(404).json({ error: "Key not found" });
}
if (!ed25519RawFromLine(key.publicKey)) {
return res.status(400).json({
error: "Certificates are only supported for Ed25519 keys",
});
}
const identity = await getIdentityForUser(userId);
if (!identity)
return res.status(404).json({ error: "No Termix ID found" });
const ca = await getCaForUser(userId, identity.id);
if (!ca) {
return res
.status(400)
.json({ error: "Enable a certificate authority first" });
}
const validityDays = clampValidityDays(
req.body?.validityDays,
ca.validityDays,
);
const principals = Array.isArray(req.body?.principals)
? req.body.principals
.filter((p: string) => typeof p === "string" && p.trim())
.map((p: string) => p.trim())
.slice(0, 32)
: [];
const now = Math.floor(Date.now() / 1000);
const validBefore = now + validityDays * 86400;
const keyId = `termix:@${identity.handle}:${id}`;
const certificate = signUserCertificate({
userPublicKeyLine: key.publicKey,
caPrivateKeyPem: ca.privateKey,
caPublicKeyLine: ca.publicKey,
keyId,
principals,
validAfter: now - 60,
validBefore,
});
if (!certificate) {
return res.status(500).json({ error: "Failed to sign certificate" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getActorUsername(userId),
action: "issue_termix_id_certificate",
resourceType: "termix_id_key",
resourceId: String(id),
resourceName: identity.handle,
details: `validity ${validityDays}d${principals.length ? `, principals: ${principals.join(",")}` : ""}`,
ipAddress,
userAgent,
success: true,
});
res.json({ certificate, keyId, validBefore, principals, validityDays });
} catch (err) {
authLogger.error("Failed to issue Termix ID certificate", err);
res.status(500).json({ error: "Failed to issue certificate" });
}
},
);
/**
* @openapi
* /termix-id/linked-credentials:
* get:
* summary: Get credential IDs that have at least one enabled published Termix ID key
* tags: [Termix ID]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of credential IDs
*/
router.get(
"/linked-credentials",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const identity = await getIdentityForUser(userId);
if (!identity) return res.json({ credentialIds: [] });
const credentialIds =
await createCurrentTermixIdentityRepository().listLinkedCredentialIds(
identity.id,
);
res.json({ credentialIds });
} catch (err) {
authLogger.error("Failed to fetch linked credential IDs", err);
res.status(500).json({ error: "Failed to fetch linked credentials" });
}
},
);
export default router;