release-2.7.0 (#1264)

* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder

* chore: run format

* chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162)

Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg).


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

---
updated-dependencies:
- dependency-name: "@types/pg"
  dependency-version: 8.20.3
  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-dev): bump the dev-minor-updates group with 4 updates (#1163)

Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `react-hook-form` from 7.79.0 to 7.84.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0)

Updates `react-icons` from 5.6.0 to 5.7.0
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0)

Updates `typescript-eslint` from 8.61.1 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint)

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

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.84.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-icons
  dependency-version: 5.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

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

* chore(deps): bump the prod-patch-updates group with 3 updates (#1164)

Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid).


Updates `jose` from 6.2.7 to 6.2.8
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.7...v6.2.8)

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

Updates `nanoid` from 6.0.0 to 6.0.1
- [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/6.0.0...6.0.1)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: js-yaml
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nanoid
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

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

* chore(deps): bump undici in the prod-minor-updates group (#1165)

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


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

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

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

* chore(deps-dev): bump the major-updates group with 2 updates (#1166)

Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom).


Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3)

Updates `jsdom` from 29.1.1 to 30.0.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1)

---
updated-dependencies:
- dependency-name: "@types/better-sqlite3"
  dependency-version: 9.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: jsdom
  dependency-version: 30.0.1
  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>

* fix: stop resyncAutoIncrement failing on tables without an id column (#1173)

The Postgres branch asked pg_get_serial_sequence(table, 'id') about every
table a fixture had inserted into. That function raises 42703 when the
column does not exist, rather than returning null, so any seed touching a
table keyed on something else took down the fixture.

host_sidebar_preferences is keyed on user_id and has no id at all, which
is why the Postgres job on dev-2.7.0 fails for every pull request.

Drive the lookup from information_schema so a missing id column yields no
row instead of an error. A text primary key still returns a null sequence
and is still skipped, as before.

* chore: install the git hooks that were already configured (#1174)

husky, lint-staged, commitlint and their config have been in the repo
since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg
runs commitlint, the lint-staged globs are in package.json and the
commitlint rules in .commitlintrc.json.

None of it has ever run. husky only takes effect once it sets
core.hooksPath, and that happens in the prepare lifecycle script, which
the package did not define -- so every clone installed the tooling and
left the hooks unwired.

That is why formatting keeps failing in CI rather than locally: three of
the four open pull requests fail lint-and-build on prettier alone,
touching between one and five files each, and the check is the first place
anyone finds out.

prepare falls back to true so a checkout without a .git directory cannot
break installation. The Docker build passes --ignore-scripts, so it never
runs this at all.

Also pin the Prettier extension to the repo's own copy via
prettier.prettierPath, and let .vscode/settings.json out of .gitignore so
it applies to everyone. The extension bundles its own prettier otherwise,
which formats to a different version's rules than the one CI enforces.

* fix: derive the ssh_credentials rebuild from the live schema (#1172)

The startup rebuild that drops the old username NOT NULL constraint
restated the table's columns as a literal and then copied rows with
INSERT INTO temp SELECT <every live column>. The table has gained columns
since that literal was written — cert_public_key, pin, sort_order and
sync_id are all added by addColumnIfNotExists before the rebuild runs —
so the destination was narrower than the source. SQLite rejected the
INSERT on a column count mismatch, the error was swallowed as a warning,
and the constraint survived every restart.

Read the CREATE TABLE statement back from sqlite_master and rewrite just
the table name and the username constraint, so the replacement table
cannot fall behind the real one. Copy rows by explicit column name rather
than positionally, and replay the table's indexes, which DROP TABLE would
otherwise take with it along with the sync_id uniqueness.

* fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171)

The audit trail is meant to outlive the account it belongs to: deleting a
user nulls user_id and keeps username for attribution. schema.ts, the
Drizzle migrations and AuditLogRepository.anonymizeByUserId were all
written against that, but the runtime bootstrap still created
user_id TEXT NOT NULL.

A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the
correct nullable column, but it can never run — the primary bootstrap has
already created the table, so IF NOT EXISTS is a no-op. Every fresh
install therefore got the old constraint, and user deletion failed with
"NOT NULL constraint failed: audit_logs.user_id" for any account that had
logged in at least once, via both the admin delete path and the OIDC
account-link cleanup.

Fix the primary bootstrap, and rebuild the table on existing databases
using the same pattern already used for ssh_credentials.username, since
SQLite cannot ALTER a column.

* fix: key the sync upsert on the row it just looked up (#1175)

A sync push locates the stored row twice -- once to decide insert vs
update, once to write it -- and the two lookups were spelled out
separately. Only the read knew about singleton entities; the write always
keyed on table.id.

userPreferences is the only singleton, and user_preferences is the one
synced table with no id column: its primary key is user_id. table.id was
therefore undefined, and drizzle emitted a comparison with nothing on its
left:

  ( = ? and "user_preferences"."user_id" = ?)

The insert branch was unaffected, so the first push of preferences
succeeded and every push after it -- the steady state -- failed with
SqliteError: near "=": syntax error. Preference sync never converged, and
both sides ship the same handler, so the desktop's embedded backend failed
identically.

Extract the lookup into locateSyncRow() and use it for the read, the
update and the tombstone delete, so the three cannot drift apart again.
The tombstone path already handled singletons correctly; it now shares the
one expression rather than keeping a third copy of it.

* fix: refuse an SSH connection whose host id resolves elsewhere (#1176)

A client identifies a host by the numeric row id of the database it is
displaying. With the desktop connection origin set to "Remote server",
that id is resolved against the sync server's ssh_data instead, and the
two autoincrement sequences need not line up -- they diverge as soon as
each side accumulates inserts and deletes in a different order.

resolveHostById() then returns whichever row owns that id here, and the
handler takes the address, the credentials, the jump hosts and the stored
host key from it. The session opens on a machine the user did not pick,
while the host list, host details and export all keep showing the right
one. Commands run on the wrong server, a host key mismatch is reported
for the wrong reason, and anything typed at the prompt goes to the wrong
place.

Compare the resolved address against the one the client sent, and refuse
when they disagree. Checking at the point the row is loaded covers every
use of it rather than each site separately. Addresses are compared with
brackets stripped and casing folded, so an IPv6 literal or a hostname
written differently is not treated as a different machine; when the
server has no address stored, the client's own details are used as
before.

This stops the wrong-machine session. It does not make delegated
connections work when the ids have drifted -- that needs the host to be
addressed by syncId across the boundary, which the connection protocol
does not currently carry.

* fix: refuse SFTP and Docker console on a mismatched host id too (#1177)

The wrong-machine guard added for SSH covered one of the paths that
resolve a client-supplied host id against this server's ssh_data. The
file manager and the Docker console take the same id from the same client
and dial whatever row owns it here.

The file manager then browses, edits and deletes files on that machine,
and the Docker console attaches to its daemon -- both while the UI shows
the host the user actually picked.

Reuse hostAddressMismatch at each point the row is loaded. The two file
manager sites sit inside "failed to resolve credentials, carry on"
handlers, so the refusal is a distinct error type those catches rethrow;
swallowing it would resume the connection this is meant to stop. The
Docker console reports over its socket, as it does for every other
refusal.

The user-facing wording now lives next to the check instead of being
written out at each site.

Still uncovered, and not fixable this way: file-manager's transfer
session, jump-host-chain and the proxmox routes resolve an id with no
client-supplied address to compare it against. Those need the host to be
addressed by syncId across the boundary.

* feat: address hosts by syncId when a connection is delegated (#1178)

A numeric host id belongs to the database that produced it. The desktop
app lists hosts from its embedded database and names them by row id, so
when a connection is delegated to a sync server that id is resolved
against a different table, whose autoincrement sequence has no reason to
agree. The row it lands on is a different machine, and it supplies the
address, the credentials, the jump hosts and the stored host key.

#1176 and #1177 made that refuse rather than connect. Refusing is right,
but it leaves "Remote server" unusable once the ids have drifted, which
is the state the reporter was in.

syncId already names a host identically on both sides -- remote sync
relies on it, ssh_data.sync_id is unique, and the API already returns it.
It just never reached the backend: hostToSSHHost() builds its result field
by field and dropped it.

Carry it through, and resolve with it when it is present:

  resolveHostBySyncId(syncId, userId)   // translate, then reuse
    -> findHostIdBySyncId(syncId)       // this database's own row id
    -> resolveHostById(hostId, userId)  // permissions, decryption, audit

The translation is deliberately not scoped to a user -- sync_id is unique
across the table and a shared host belongs to someone else -- so access
stays with the permission check in the id-based path, which the new tests
cover.

An unknown syncId resolves to nothing rather than falling back to the
numeric id: an unknown host is precisely where guessing picks the wrong
machine. Clients that send no syncId are unchanged, address comparison
included, so an older desktop keeps its safety net instead of breaking.

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

---------

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

* fix: keep already-shared hosts sharing their SSH authentication (#1179)

Sharing a host used to hand the owner's SSH authentication to the
recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth,
added as NOT NULL DEFAULT 0.

Existing rows took the default, so every host shared before the upgrade
stopped supplying credentials the moment the column appeared. The snapshot
in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing
was captured; resolveRecipientSharedHostAuthentication() then fell through
to "required" and the recipient got "No valid authentication method
provided" on a host that had worked the day before. Downgrading to 2.6.0
restored it, since that code has no such column to consult.

Backfill the flag for hosts that already appear in host_access. That is
where the previous behaviour was in effect and where the owner had already
agreed to share; hosts nobody has shared keep the new default and stay off
until their owner shares them.

Guarded by a settings key so it runs once. Without that, an owner who
turns sharing back off would have it turned on again by the next restart.

* fix: let a single credential disable 2FA again (#1180)

The disable dialog has one field, labelled "Enter TOTP code or password",
and its caller passes that value as disableTOTP(input) -- so it arrives as
`password` with `totp_code` undefined. That call has been unchanged since
v2.3.0.

2.5.1 changed the route to require both:

  if (!totp_code || (!userRecord.isOidc && !password)) -> 400

replacing `const credential = password || totp_code`. The first check has
rejected every attempt since, whatever the user typed, so nobody has been
able to turn 2FA off -- the client reports the generic "Failed to disable
2FA", which hides which check failed.

Take one credential again and try it as a TOTP code, a backup code, then
the account password. verifyTotpReauth still refuses the password itself,
so that comparison stays in the route; an OIDC user has no password hash
and reaches neither.

The backup-codes route has the same shape but no caller in the UI -- its
codes are returned when TOTP is enabled -- so it is left alone rather than
changed blind.

* fix: attach user-managed CA certificates over SFTP too (#1181)

opkssh-cert-auth.ts exports two helpers that end in the same
_applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for
user-managed CA-signed -cert.pub files. The file manager called the first
one twice and the second one never.

So a host whose key is paired with a CA-signed certificate authenticated
in a terminal and failed over SFTP, while OPKSSH certificates -- going
through the other helper -- worked in both. The file manager was not
missing certificate support in general; it was missing one of the two
paths into it.

The connection also never carried the certificate to begin with:
cert_public_key was not among the fields copied into resolvedCredentials,
so both places that build an SFTP connection now read it and attach it
where the private key is prepared -- the dedicated transfer session and
the main connect route.

An unusable certificate is logged and skipped rather than failing the
connection. The key alone may still be accepted, which is what happened
while this was not wired up at all, and turning that into a hard failure
would break setups that currently work.

Reported in #1160 with the call-site asymmetry already traced; the
reporter noted they could not confirm the link to their failure, having
moved off SSH CAs. The asymmetry is real either way and reproduces the
symptom exactly.

* fix: authenticate the desktop Docker console WebSocket (#1182)

The console WS opted out of the query token:

  buildOriginWsUrl({ ..., includeLocalJwt: false })

leaving it with no credential at all on the desktop. The browser
WebSocket API cannot set an Authorization header, and while Electron's
main process injects a remembered JWT cookie, it requires an exact origin
match -- the cookie belongs to the API origin (localhost:30001) while the
console connects to 127.0.0.1:30009, so nothing is attached.

The backend then closes the handshake with 1008 before it logs anything,
which is why the log has no docker-console entries while stats and logs
polling keep succeeding on the same host. The web build is unaffected: it
connects same-origin and its cookie is sent normally.

Drop the opt-out so the console carries the local JWT like the SSH
terminal does -- the same token, the same query parameter, and the
backend already reads it there.

Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always
resolve to "remote", so that call never reaches the local branch.

* fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

* fix: use getClientIp in getRequestMeta for correct audit-log IPs

getRequestMeta had near-duplicate, strictly worse forwarded-header
logic: the array branch didn't split/trim, there was no socket-peer
fallback, and it returned "" instead of "unknown". Delegate to
getClientIp so the audit trail gets the same correctness as the
terminal login-alert path.

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

---------

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

* feat: add terminal image handoff (#1170)

* chore: sync Crowdin translations

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

---------

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

* feat: add terminal image handoff

Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar.

* docs: document terminal image handoff deployment

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167)

* fix(desktop): stop suppressing the update prompt for users who need it

The startup update modal stored its dismissal under the local app version
rather than the remote version being offered, and the up-to-date branch
wrote that key with no user interaction at all. A user who launched while
current had their own version recorded; once the next release shipped,
`dismissedVersion === currentVersion` still held and the modal was skipped
on every launch. It reappeared only after the user had already updated --
the inverse of what it is for. Present since v2.3.0.

Key the dismissal on the offered remote version instead. The change is
backward compatible: an existing key holding 2.6.0 compares unequal against
a remote 2.6.1, so affected installs are prompted on their next launch. When
the check itself fails there is no remote version, so nothing is recorded and
no future prompt is suppressed.

That left the version badge as the only remaining signal, and it was an inert
span on both surfaces that render it -- the profile panel and the dashboard
stats bar -- even though the `getVersionInfo()` response it is built from
already carries `latest_release.html_url`. Extract the duplicated badge into
`components/version-badge.tsx` and make the update case a link to the release,
with an accessible name that says where it goes. The beta and stable cases
stay inert.

`getVersionInfo()` returned `Record<string, unknown>`, so the release URL was
unreachable without a cast; give it a `VersionInfo` type that keeps an index
signature, since `SystemOverviewWidget` reads `updateAvailable` off the same
response.

* test: cover the read that actually reaches the badge

The extracted VersionBadge is unit-tested, but the line that decides whether
it ever receives a URL -- pulling `latest_release.html_url` out of the version
response -- was duplicated at both call sites and asserted nowhere. A wrong
property there compiles (the response type keeps an index signature) and every
existing test still passes.

Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the
happy path, a response with no release, a release with no URL, and a missing
response, since the caller's fetch can reject. Empty string is the contract the
badge reads as "nothing to link to", so it stays an inert span rather than
rendering a dead anchor.

* docs: state the index signature's real reason

The comment claimed the version endpoint carries fields beyond the typed ones,
citing `updateAvailable`. It does not -- `GET /version` returns status,
localVersion, version, remoteVersion, latest_release, cached and cache_age, and
nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless,
which is why the permissive index signature has to stay, but that is a stale
read rather than an undocumented field. Say so accurately.

* Send alerts in Discord channels with Webhooks (#1158)

* feat(utils): add discord webhook sender

Add a utility to send alert embeds to Discord webhooks.

* fix(utils): validate DNS and use global fetch for outbound requests

Prevent private destination access and rely on global fetch after DNS validation.

* chore(logger): include extra context in logs

Show additional sanitized context entries for clearer diagnostics.

* feat(alerts): support discord channel type in routes and engine

Accept discord channels and route alerts to the Discord sender.

* feat(ui): add Discord option to notification channel dialog

Allow creating/editing Discord webhook channels with username/avatar.

* fix(ui/api): accept structured config payload for notification channels

Allow the client to pass structured config objects (or strings) when creating/updating channels.

* chore: sync Crowdin translations

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

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

* test: cover getClientIp forwarded-header and socket fallback paths

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

---------

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

* chore: add url to SENSITIVE_FIELDS for discord url

* fix: enforce SSRF protection on outbound fetches

Use `undici.fetch` with the custom DNS lookup hook to ensure the validated
DNS resolution is the one used for the connection. Fix DNS lookup/address
validation bugs and add coverage for private, public and invalid addresses,
including the resolution issue affecting Discord endpoints.

* chore: prettier format

* fix: validate all DNS addresses and close dispatcher

* fix DNS lookup validation and callback handling
* update safe outbound fetch tests
* ensure created dispatcher is properly closed

* chore: remode url from SENSITIVE_FIELDS for other logs

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix tmux UTF-8 path handling (#1157)

Co-authored-by: Carl <scarlettme@qq.com>

* chore: update package lock

* chore: update gitnore

* fix: [BUG] (#1049)

https://github.com/Termix-SSH/Support/issues/1049

* fix: test commitlint path fix (#1021)

* fix: SGR mouse-tracking escape codes printed as text (#1023)

* fix: quote $1 in commit-msg hook so it works from git worktrees

* fix: [BUG] could not connect to the database (#1057)

https://github.com/Termix-SSH/Support/issues/1057

* fix: [BUG] VNC connect macOS screen sharing failed (#1063)

https://github.com/Termix-SSH/Support/issues/1063

* fix: [BUG] Meta key (#1075)

https://github.com/Termix-SSH/Support/issues/1075

* fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085)

https://github.com/Termix-SSH/Support/issues/1085

* fix: [BUG] webhook not working (#1080)

https://github.com/Termix-SSH/Support/issues/1080

* fix: [BUG] First server sync doesn't refresh UI (#1084)

https://github.com/Termix-SSH/Support/issues/1084

* fix: [BUG] How to enable SSL using custom certificate (#1083)

https://github.com/Termix-SSH/Support/issues/1083

* fix: [BUG] Sudo Password Auto-fill Persistance (#1098)

https://github.com/Termix-SSH/Support/issues/1098

* feat: [FEATURE] Expand Snippets Function (#1031)

https://github.com/Termix-SSH/Support/issues/1031

* feat: [FEATURE] (#1055)

https://github.com/Termix-SSH/Support/issues/1055

* feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013)

https://github.com/Termix-SSH/Support/issues/1013

* feat: [FEATURE] Allow paste on non https (#1026)

https://github.com/Termix-SSH/Support/issues/1026

* feat: be-azerty layout (#1073)

https://github.com/Termix-SSH/Support/issues/1073

* feat: Keyboard shortcuts to move between open tabs (#1069)

https://github.com/Termix-SSH/Support/issues/1069

* feat: Session Logs as a downloadable text file (#1058)

https://github.com/Termix-SSH/Support/issues/1058

* fix: persist and auto-fill saved SSH and sudo passwords

* fix: persist docker runtime selection and docker manager UI issues

* feat: Allow excluding specific mounts from disk usage metrics (#1046)

https://github.com/Termix-SSH/Support/issues/1046

* feat: Expand Snippets Function (#1031)

https://github.com/Termix-SSH/Support/issues/1031

* chore: restore the prettier baseline on dev-2.7.0 (#1185)

Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .`
fails and takes lint-and-build with it — on every pull request, whatever
it changes.

Formatting only, produced by `npx prettier --write` on exactly the files
the check names. No logic touched: tsc passes for both configs, backend
148 files / 1106 tests and UI 71 files / 479 tests all pass.

* test: keep the tmux escaping test runnable on Windows (#1184)

The escaping check ran its command through /bin/sh. That binary does not
exist on Windows, and Windows is a supported platform for the desktop
app, so `npm test` fails there on a test about string quoting. CI is
ubuntu-only and would never see it.

Assert the escaped string directly, which covers the rule on every
platform, and keep the round trip through a real shell as a separate case
guarded by platform -- it is the stronger evidence where a shell exists.

* chore: drop the unreachable table probes from migrateSchema (#1186)

Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS
behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap
had already created earlier in the same startup. The probe could not
throw, so the catch never ran.

Two of those unreachable copies had drifted from the definition actually
in use. sessions had lost ON DELETE CASCADE, and session_recordings still
carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username --
the shape from before audit trails were made to outlive the account. They
would have taken effect had anything ever reordered startup.

Kept, because they are not the same thing:

  - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT
    EXISTS is a no-op on a table that exists, so a database created before
    a column was added still needs the ALTER. Those probe a column, not a
    table.
  - blocks that are a table's only creation point.
  - the user_open_tabs block, which is a data migration; its SELECT is a
    precondition, not a probe.

Deletion only, no behaviour change.

* fix: repair the frontend type-check and clear the 299 errors behind it (#1189)

* fix: repair frontend type-check configuration and the errors it exposed

The root tsconfig.json is solution-style with "files": [], so the
`npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types
have therefore never been checked, and 299 errors had accumulated behind
that no-op. This clears just over half of them; nothing here changes
runtime behaviour.

Configuration:
- "@/types" resolved through the "@/*" fallback to src/ui/types, which
  does not exist. Added an explicit mapping to src/types/index.ts.
- src/vite-env.d.ts sits outside the include list, so import.meta.env and
  the ?url import suffix were unknown. Added.
- src/ui/types/ held a single file, keybindings.ts, while every other
  shared type lives in src/types/. Six modules imported it as
  "@/types/keybindings" and silently resolved to nothing. Moved.

Type definitions that had fallen behind the code:
- guacamoleConfig and terminalConfig were Record<string, unknown> in
  ui-types while the editor read concrete fields off them. Both now use
  the real interfaces; GuacamoleConfig is extracted from its inline
  definition in guacamole-api.ts so the two cannot drift again.
- customThemeColors and TerminalTheme["colors"] described the same object
  with different optionality. Aligned.
- FileWindow declared its own SSHHost whose authType was "password" | "key",
  which no longer matches the eight the app supports.
- connectSSH and listSSHFiles returned Record<string, unknown>, so every
  field the callers destructured arrived as unknown.
- AxiosRequestConfig and AxiosResponse were used without being imported.

Also adds asHttpError() for the handful of catch blocks that reached into
an unknown binding, and narrows the Host | HostFolder comparator and the
RailItem union at the points where the discriminant was not carrying.

Note: dbHealthMonitor.reportDatabaseError was being called with a second
argument it does not accept, so the authenticated-or-not flag was already
being discarded at runtime. Dropped the argument to match the signature;
whether that flag was meant to gate the report is worth a separate look.

* fix: clear more of the frontend type-check baseline

Continues the previous commit; 140 errors down to 70. Three of these were
real defects rather than missing annotations.

Defects:
- DashboardTab counted active tunnels by comparing status to "CONNECTED",
  but CONNECTION_STATES.CONNECTED is "connected" and that is what the
  tunnel manager emits, so the count was always zero. Now compares against
  the constant.
- QuickActionsCard requires isAdmin and gates a block of admin-only actions
  on it, but neither call site passed it — those actions never rendered.
  Both call sites also passed isAdmin to HostStatusCard, which does not
  accept it; the prop had evidently been moved and the call sites missed.
- The host editor stores jump host ids as strings and sent them straight to
  an API typed for numbers. Backend host lookups compare against an integer
  column, which a string does not match on Postgres or MySQL. Converted.

Types brought in line with the data:
- Host and HostData were missing hasPassword, hasSudoPassword, sortOrder,
  instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault"
  authType; TabContextTab was missing the "tunnel" tab, which TabContext
  already branched on.
- statsConfig and terminalConfig used inline shapes that had drifted from
  StatsConfig and TerminalConfig. Both now reference the real interfaces;
  excludedMounts, which the editor reads, was added to StatsConfig.
- downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all
  returned Record<string, unknown> while callers read named fields.
- The Guacamole declarations were missing Keyboard.reset, Client.onfile,
  InputStream.sendAck, Status.Code and BlobReader, all already in use.
- NetworkTopologyNode/Edge could not be discriminated, though the graph
  code tells them apart by testing for source/target.

ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor
writes the string spellings while proxy-helper.ts tests for "http" and
casts everything else to 4|5 before handing it to the socks client, so a
chained proxy reaches it as "socks5" rather than 5. Typed as what is
actually stored; reconciling the two spellings needs a migration decision
and is left alone here.

* fix: continue clearing the frontend type-check baseline

70 errors down to 44.

Dead configuration removed:
- Terminal set terminal.options.bellStyle on xterm, which dropped the
  option in v5. The host editor still exposes the setting and stores it;
  it has simply had no effect on the terminal since that upgrade. Making
  the bell work again means handling the onBell event and is left alone.
- CodeEditor passed scrollPastEnd to basicSetup, which has no such option.
- FileManager passed an id to openWindow, which assigns its own and
  discards what it is given — the component was already being rendered
  under a different id than the one the caller held.

Widgets that were registered but unreachable:
- DockerActivityWidget and SshQuickConnectWidget register under
  "docker_activity" and "ssh_quick_connect", neither of which was in
  WidgetTypeId, and both referenced config interfaces that did not exist.
  Added the ids and the two interfaces, inferred from their edit forms and
  defaultConfig.

More endpoints given their real return types: getRecentFiles,
getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile,
copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets.

parseGuacamoleConfig() handles the host row carrying guacamoleConfig either
parsed or as raw JSON, which GuacamoleApp was reading fields off directly.
TerminalHostConfig was missing name, which it reads for the activity log.

* fix: continue clearing the frontend type-check baseline

44 errors down to 17.

Host and AuditLog are now type aliases rather than interfaces. An
interface has no implicit index signature, so neither could be assigned
to the `[key: string]: unknown` shapes that TerminalHostConfig,
HostMetricsTab's HostConfig and several helpers declare — eight errors
came from that alone.

More dead configuration:
- i18n passed checkWhitelist to the language detector, which no longer
  has that option; supportedLngs already covers it.
- SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor.
- AudioPreview's onLoadedMetadata never fired: react-h5-audio-player
  spells the prop onLoadedMetaData.
- MarkdownRenderer destructured `inline` from code(), which react-markdown
  removed in v9, so the flag was always undefined and every inline span
  took the block branch when it happened to carry a language class. Now
  derived from whether a className is present at all.
- SnippetsPanel put a title prop on a lucide icon, which does not forward
  it; changed to aria-label so the hint is actually reachable.

updateHostConfig in TabContext replaced tab.hostConfig wholesale with the
six-field literal it receives, dropping everything else the tab held about
the host. It now merges onto the existing config.

Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return
types (UpdateLog kept private copies of two of them, and VersionInfo was
missing `version`, which the endpoint sends and the panel renders);
wakeOnLan and vncCredentialId get the numeric ids they are typed for; and
the tmux formatter takes i18next's TFunction instead of a hand-written
signature it does not satisfy.

* fix: clear the last frontend type errors and make CI actually run the check

Baseline is now zero, so the check can be turned on.

`npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles
nothing: the root tsconfig.json is solution-style with "files": [], and
plain tsc does not follow project references. Both are now `tsc -b`, which
builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type
error and watching the command fail.

Last defects in this batch:
- patchOpenTab could not carry hostId, so quick-connect's "save this host
  and attach the tab to it" call was passing a field excluded from the
  type all the way down. The column exists and updateForUser spreads
  whatever it receives, so the write worked; only the types disagreed.
  Widened front to back.
- The file-comparison window opened without x, y, width or height — every
  other openWindow call passes them — and sent a `type` field WindowInstance
  does not have.
- HostEditor gated a block on authType === "warpgate", which is not one of
  the eight authType values. Unreachable, and it held only a label and a
  description. Removed.
- FileManager passed onLoadDirectory to a sidebar that neither declares nor
  reads it, and FileManagerApp passed embedded to a FileManager that has no
  such prop.
- TunnelApp's minimal Host was missing three required flags.

The remainder were assertions at boundaries that are genuinely loose: bulk
host import takes rows assembled from untyped input and validates them
server-side, and a vi.fn() whose body only throws infers never.

* feat: add drive file browser and drag-and-drop upload for RDP (#1187)

Drive redirection could already be enabled per host, but the redirected
drive lived inside guacd with no way to reach it from the browser: the
client never handled onfilesystem, so the mounted volume was writable
from Windows and invisible from Termix.

Add a file browser panel that lists the drive, downloads files, and
uploads them, plus drag-and-drop onto the display which opens the panel
and uploads into the directory currently shown. The disable-upload and
disable-download connection settings are honoured by the UI, not just
passed to guacd.

A rejected upload stops the BlobWriter without firing onerror or
oncomplete, so the error ack is watched explicitly; otherwise the
transfer would hang forever. Directory reads carry a deadline for the
same reason.

Also declares Guacamole.Object, Client.onfilesystem, BlobReader and
BlobWriter in the local type definitions, which previously omitted them.

* fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190)

Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102).

2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a
three-way branch on touchMode, and the touch branches replace the mouse
binding instead of adding to it:

    if (touchMode === "touchscreen")      new Guacamole.Mouse.Touchscreen(el)
    else if (touchMode === "touchpad")    new Guacamole.Mouse.Touchpad(el)
    else                                  new Guacamole.Mouse(el)

The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/
mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/
touchend. So in a touch mode nothing is listening for the mouse at all.

touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0,
which is true of every laptop with a touchscreen — machines that are still
driven by a mouse. Those users lost the pointer entirely while the keyboard
kept working, because Guacamole.Keyboard is bound independently.

The physical pointer is now always bound and a touch emulator is layered on
top when one is selected. Extracted to bindPointerInput() so the binding is
testable; the test fails against the old branch.

Note the issue also carries a second, unrelated report where well-formed
mouse frames do reach guacd and the VNC leg ignores them. That one is not
this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it
is not an upgrade either.

* fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191)

* chore: sync Crowdin translations

* fix: deduplicate /api/folders requests to prevent intermittent folder disappearance

getSSHFolders() had no request deduplication while getSSHHosts() used a TTL
cache with in-flight dedupe. When loadHosts() fired multiple times during
rapid navigation between Credentials and Hosts panels, the folder response
could arrive after the hosts response, causing the sidebar tree to render
without folder metadata.

- Add foldersCache (10s TTL) in hosts-request-cache.ts
- Wrap getSSHFolders() API call in getCachedSSHFolders()
- Invalidate folders cache on renameFolder, updateFolderMetadata,
  deleteAllHostsInFolder, and renameCredentialFolder
- Include foldersCache in invalidateHostsAndStatusCaches()

Closes Termix-SSH/Support#1103

Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>

---------

Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195)

* chore: sync Crowdin translations

* chore(deps): bump undici in the prod-minor-updates group

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


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

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.10.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: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: proxmox metrics integration

* feat: add folder select to the host multi select feature

* feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc

* feat: made toolbar open file manager at path

* fix: delete folder route not invalidating host list cache

* fix: match host list icons with tab bar iconfix

* fix: change sidebar reset button icon to seperate against fullscreen button

* feat: unify connection system and add connection logs to guacd hosts

* fix: make mobile terminal scrollback match xterm wheel behavior (#1198)

* fix: route mobile terminal scrolling through xterm viewport

* docs: document mobile terminal touch scrolling

* chore: add a note to not place files in docs

* chore: remove touch imput from docs

* feat: improve snippet system with variable snippets and collapse settings

* feat: new fleet system with snippet, packages, files, and inventory features

* fix: command pallete not loading new activity and made enter load first item

* feat: add subhost from parent host organization feature

* feat: add workspaces feature to save tab layout

* perf: greatly improved performance across metrics polling and host management for enterprise users

* feat: add a onboarding system with a new interface simplicity system

* feat: finalize the multi dialect database system

* fix: bind trusted MFA devices to client installs (#1202)

* fix: merge OIDC group claims across sources (#1203)

* fix: allow disabling SSH keepalives (#1204)

* fix: distinguish reachable and available hosts (#1206)

* fix: throttle session activity persistence (#1207)

* fix: preserve saved RDP connection settings (#1208)

* fix: authenticate tunnel status stream (#1209)

* fix: select quick-created credentials (#1210)

* fix: stagger initial metrics collection (#1211)

* fix: stagger initial metrics collection

* fix: admit reachable hosts to initial metrics

* fix: prevent long host names shifting dashboard metrics (#1205)

* feat: add global touch input settings (#1201)

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

* fix: keep host list row sizing stable (#1213)

* fix(guacamole): correct Windows key mapping (#1216)

* fix: normalize OIDC discovery issuer URLs (#1218)

* fix: prompt for RDP domain credentials (#1212)

* fix: route status checks by connection origin (#1214)

* fix: restore desktop Tailscale configuration (#1215)

* fix(docker): restore Node 24 for ssh2 native crypto (#1217)

* feat: added new automations feature with events, channels, and steps

* feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar

* feat: expand onboarding process with more customization/features

* feat: initial implementation of the termix ai feature

* chore: run linter

* fix: issue #424 (#424)

https://github.com/Termix-SSH/Support/issues/424

* fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133)

https://github.com/Termix-SSH/Support/issues/1133

* fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109)

https://github.com/Termix-SSH/Support/issues/1109

* feat: How to enable SSL using custom certificate (#1083)

https://github.com/Termix-SSH/Support/issues/1083

* fix: show profile API key after creation (#1221)

* feat: add trusted proxy authentication (#1222)

* fix: clarify SSH agent authentication (#1224)

* feat: add first-class split screen tabs (#1226)

* feat: add split tab data model

* feat: make split screens top-level tabs

* feat: persist and manage split layouts

* feat: launch native RDP on Windows desktop (#1223)

* feat: launch native RDP on Windows

* style: format native RDP launcher

* feat: enhance custom disk and network metrics (#1220)

* feat: enhance host disk and network metrics

* fix: align enhanced metrics types

* fix: preserve Proxmox guest identity on edit (#1219)

* fix: preserve Proxmox guest identity on edit

* fix: type Proxmox guest source metadata

* chore: dead-code cleanup and small refactors (#1225)

* chore: remove dead code and unused exports

* chore: remove unused api client functions

* chore: remove unused backend helpers

* refactor: extract getErrorMessage helper for repeated error extraction

* refactor: unify error message extraction across backend with getErrorMessage

* refactor: unify error message extraction in frontend with getErrorMessage

* refactor: merge duplicate imports from the same module

* refactor: use Array.includes in TabBar

* chore: drop biome, keep prettier as the single formatter

* style: apply prettier formatting to refactored files

* fix: close active tab with Ctrl+W on Windows

* fix: make tray Quit terminate the desktop app

* feat: verify host transfer integrity

* fix: reuse transfer sessions during verification

* feat: select the fastest host transfer route

* feat: tune host transfers adaptively

* feat: adapt background polling to activity (#1233)

* feat: adapt background polling to activity

* feat: extend adaptive polling coverage

* feat: make polling cost and network aware (#1234)

* feat: make repeat navigation feel instant (#1235)

* feat: make file operations feel immediate (#1236)

* feat: preload likely user actions (#1237)

* feat: preload likely file previews

* feat: preload likely host tools

* feat: preload likely file viewers

* fix: replace stale terminal input listeners

* feat: add links to docs for all new features

* chore: update readme

* fix: warn before discarding host changes (#1229)

* feat: learn local host action preferences (#1238)

* feat(terminal-toolbar): add bounded movable desktop toolbar (#1239)

* feat: add local adaptive decision engine (#1240)

* feat: adapt speculative resource usage (#1241)

* feat: persist adaptive transfer profiles (#1242)

* Fix .preferred_username when using LDAP login. (#1243)

* chore: sync Crowdin translations

* Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username.

---------

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

* feat: learn direct transfer routes (#1244)

* feat: learn speculative preload usefulness (#1245)

* fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152)

https://github.com/Termix-SSH/Support/issues/1152

* fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145)

https://github.com/Termix-SSH/Support/issues/1145

* feat: single click on host in list opens session - should be only on double click (#1146)

https://github.com/Termix-SSH/Support/issues/1146

* feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140)

https://github.com/Termix-SSH/Support/issues/1140

* fix: revert host single click to open session, make double click an option (#1146)

Single click opens a session again by default. The old double click
behavior can be turned on in Customize Sidebar.

* chore: drop prettier check from beta release workflow, run formatter

* chore: patch dependabot vulnerabilities via npm overrides

* fix: reset adaptive resource state between tests to stop cross-test leaks

* feat: replace terminal toolbar density popover with a native select

* fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier

* fix: allow dylib files in mac universal arch rules so mas build packages sharp

* feat: add file manager trash (#1250)

* feat: add inheritable connection defaults (#1246)

* feat: add desktop local terminal (#1247)

* feat: add interactive terminal macros (#1248)

* feat: add adaptive SSH local echo (#1249)

* fix: sync desktop host changes immediately (#1252)

* fix: route desktop sharing through synced server (#1253)

* Fix terminal image uploads and add safe diagnostics (#1254)

* feat: add configurable terminal image storage backends

* feat: add admin image storage settings

* fix: preserve native clipboard PNG uploads

* fix: quote terminal image paths safely

* docs: record image storage security remediation plan

* fix: close remote image SFTP channels

* fix: restrict remote image SFTP permissions

* fix: bound remote image SFTP writes

* fix: add best effort remote image retention

* fix: cap normalized image output size

* fix: bound concurrent image processing

* fix: fail closed on local image inspection errors

* test: cover fail closed image storage and atomic settings

* fix: enforce remote image quota and upload admission

* fix: serialize remote quota and verify existing paths

* fix: use synchronous sqlite settings transaction

* fix: keep settings transactions portable across dialects

* fix: bound image processing admission queue

* fix: serialize remote image quota across processes

* fix: recover stale remote image locks safely

* fix: preserve remote storage errors during unlock

* fix: fail closed when stale lock removal fails

* fix: harden image upload resource and storage cleanup

* fix: bound SFTP operations and lock lifetime

* fix: bound SFTP acquisition and cleanup callbacks

* fix: close late SFTP channels and test cleanup stalls

* fix: preserve SFTP inspection client context

* feat: add image upload source metadata

* fix: expose image upload metadata in logs

* chore: exclude internal plan from pull request

* style: apply prettier formatting

---------

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

* fix: batch of security hardening fixes (#1255)

* fix: bind desktop auto-session loopback check to the TCP peer address

* fix: escape HTML entities in Vault OIDC callback responses

* fix: route homepage ping and rss through the SSRF-safe outbound fetch

* fix: scope tunnel status endpoints to hosts the caller can access

* chore: update release notes

* chore: update release notes to write more about the ai integration

* fix: unbreak windows and macos electron builds after node-pty

Install Spectre-mitigated MSVC libs on the Windows runner and cover
node-pty's spawn-helper in the macOS universal arch rules.

* fix: rework connection defaults ui into a dialog and add missing i18n keys

* fix: rework macros panel with i18n, plain text matching, and list layout

* feat: add docs links for trash, connection defaults, and local echo

* fix: make image storage and trash tests pass on windows

* fix: stop docs links squeezing sidebar panel headers

* fix: put automations docs link back on the tabs row

* fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261)

Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls
back to the basic_text store for any desktop it has no mapping for, which
covers every wlroots-style compositor (Hyprland, sway, niri, river).
isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt
refused every write and the OIDC sign-in it was storing appeared to succeed.
The sync engine then found no JWT and reported the session as expired, which
sent users looking at their OIDC provider for a fault that was never there.

Name the libsecret backend explicitly on those desktops. They run an ordinary
Secret Service, so that is enough to make encryption available again. KWallet
desktops keep their auto-detected backend, an explicit --password-store still
wins, and no stored secret can be orphaned by the switch because
isEncryptionAvailable() gated every write that would have created one.

Also stop discarding the {success: false} the main process returns when it
cannot store a credential: on a machine with no Secret Service at all, the
sign-in now says so instead of silently completing.

Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>

* chore: update release notes

* chore: update release notes

* fix(file-manager): widen trash dialog so names and paths are not cut off

* fix(sidebar): stop hover action tray overlapping the row below it

* fix(hosts): make real status colors toggle actually apply

* feat(local-terminal): add rail button and fix hardcoded tab label

* chore: update release notes

* fix(ai): hide assistant everywhere when admin disables it globally

* fix(automations): fix concurrency race, wire docker and internal event triggers

Claim the in-flight slot in the same tick it is checked, poll container
state for docker_event triggers, emit the internal events, apply the
schedule time zone, and expose the concurrency policy in the editor.

* fix(sidebar): rework host and credential drag-to-reorder

Adds a lock toggle in the sort menu and fixes reorder positioning,
cross-folder drops, and the duplicate drop indicator.

* chore(sidebar): drop unused sortKey prop from host and credential trees

* fix(sidebar): fix row height in click tray mode so status stripes stop overlapping

* fix(onboarding): remove add-first-host step that closed onboarding mid-flow

* fix(release): upload release notes so Mac App Store review submission stops failing

* chore: sync Crowdin translations for 2.7.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: RawNuke <67506722+RawNuke@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: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com>
Co-authored-by: Horziox <horziox.dev@gmail.com>
Co-authored-by: William Shi <184219650@qq.com>
Co-authored-by: Carl <scarlettme@qq.com>
Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: njz-cvm <njz@cvm.com>
Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com>
Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
Luke Gustafson
2026-08-19 14:12:06 -05:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ZacharyZcR kacperpietrzyk Brennan Neoh Claude Sonnet 5 T3rM1nAt0-R Horziox William Shi Carl Raw_Nuke njz-cvm Alexandre VARGAS alexandre-vl
parent 5021ccf3e2
commit 7ae1648c25
837 changed files with 387941 additions and 15831 deletions
+55
View File
@@ -0,0 +1,55 @@
/**
* The system prompt.
*
* Deliberately small: the assistant starts with almost no context and must call
* a read tool to learn anything. That keeps token cost predictable, makes every
* data access visible in the transcript, and means nothing is sent to a
* third-party provider that the user did not implicitly ask for.
*/
export function buildSystemPrompt(options: {
hostCount: number;
activeTab?: string | null;
allowReadOnlyCommands: boolean;
}): string {
const lines: string[] = [
"You are the assistant built into Termix, a self-hosted server management app.",
"You help the user manage their servers, snippets, automations, fleets and alerts.",
"",
"How you work:",
"- You start with no knowledge of this user's setup. Call a read tool to find out anything you need.",
"- You cannot change anything directly. To make a change, call a propose_* tool; the user sees a card and approves or rejects it.",
"- You have no access to passwords, SSH keys, API keys or any other credential, and you never ask the user to paste one into the chat.",
"",
"Scope:",
"- Do exactly what was asked, and nothing beyond it. A question is a request for an answer, not for changes.",
"- Questions like 'what is running on this server', 'check this host' or 'why is this slow' are answered with information. Read, then report. Do not propose anything.",
"- Only propose a change when the user asked for one, in words like 'add', 'create', 'set up', 'fix' or 'change'.",
"- Do not propose follow-up work you thought of yourself: no monitoring, no alert rules, no scripts, no snippets, no cleanup, unless that is what was requested.",
"- If you think something is worth doing, say so in one sentence and stop. Let the user ask.",
"- One request means one proposal at most. Do not bundle extras alongside it.",
"",
"How to answer:",
"- Lead with the answer. Keep responses short and concrete.",
"- When you propose something, say in one line what it does and why.",
"- If a request is ambiguous in a way that changes what you would propose, ask before proposing.",
"- Never claim you have done something. You propose; the user applies.",
];
if (options.allowReadOnlyCommands) {
lines.push(
"- The user has allowed you to run read-only diagnostic commands directly. Anything that changes state still has to be proposed.",
);
}
lines.push(
"",
"Current context:",
`- The user has ${options.hostCount} host${options.hostCount === 1 ? "" : "s"} configured.`,
);
if (options.activeTab) {
lines.push(`- They are currently looking at: ${options.activeTab}.`);
}
return lines.join("\n");
}
+113
View File
@@ -0,0 +1,113 @@
import { isIP } from "net";
import { isBlockedAddress } from "../utils/safe-outbound-fetch.js";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
/**
* Where the assistant is allowed to send requests.
*
* Cloud providers are reached through the SSRF-guarded path, which refuses to
* resolve to a private address. That guard is exactly what a self-hosted Ollama
* on localhost trips over, so private destinations are permitted only when an
* admin has named the host. Without that split, any logged-in user could point
* a "provider" at an internal service and use the backend as an authenticated
* probe of the server's own network.
*/
export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist";
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
export const DEFAULT_PRIVATE_ALLOWLIST = [
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
];
export function parseAllowlist(raw: string | null): string[] {
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
} catch {
return [...DEFAULT_PRIVATE_ALLOWLIST];
}
}
export async function readPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
AI_PRIVATE_ALLOWLIST_KEY,
);
return parseAllowlist(raw);
}
function normalizeHost(hostname: string): string {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}
/**
* True when the URL names a destination the SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else resolves through DNS and
* is caught at connect time by the guard instead.
*/
export function isPrivateDestination(rawUrl: string): boolean {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return false;
}
const host = normalizeHost(url.hostname);
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (isIP(host)) return isBlockedAddress(host);
return false;
}
export interface EgressDecision {
allowed: boolean;
/** True when the destination needs the allowlisted-private path. */
isPrivate: boolean;
reason?: string;
}
export function evaluateEgress(
rawUrl: string,
allowlist: string[],
): EgressDecision {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return { allowed: false, isPrivate: false, reason: "Invalid URL" };
}
if (!["http:", "https:"].includes(url.protocol)) {
return { allowed: false, isPrivate: false, reason: "Unsupported protocol" };
}
if (url.username || url.password) {
return {
allowed: false,
isPrivate: false,
reason: "Credentials in URL are not allowed",
};
}
const host = normalizeHost(url.hostname);
const isPrivate = isPrivateDestination(rawUrl);
if (!isPrivate) return { allowed: true, isPrivate: false };
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
return {
allowed: false,
isPrivate: true,
reason:
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
};
}
+150
View File
@@ -0,0 +1,150 @@
import { getErrorMessage } from "../utils/error-message.js";
import { getAdapter } from "./providers/registry.js";
import type {
ChatMessage,
ProviderConfig,
ToolCall,
} from "./providers/types.js";
import { redact, redactToJson } from "./redaction.js";
import { getTool, toolDefinitions } from "./tools/catalog.js";
import {
isProposalDraft,
type ProposalDraft,
type ToolContext,
} from "./tools/types.js";
/**
* The agent loop: stream a turn, run any tools the model asked for, feed the
* results back, repeat. Bounded so a model that keeps calling tools cannot spin
* forever.
*/
const MAX_TURNS = 8;
export type EngineEvent =
| { type: "token"; text: string }
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
| { type: "tool_result"; name: string; result: unknown }
| { type: "proposal"; draft: ProposalDraft }
| { type: "assistant_message"; content: string; toolCalls: ToolCall[] }
| { type: "done" }
| { type: "error"; message: string };
export interface EngineOptions {
config: ProviderConfig;
model: string;
system: string;
history: ChatMessage[];
context: ToolContext;
signal?: AbortSignal;
}
export async function* runAgent(
options: EngineOptions,
): AsyncGenerator<EngineEvent> {
const adapter = getAdapter(options.config.providerType);
const tools = toolDefinitions();
const messages: ChatMessage[] = [...options.history];
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
let text = "";
const calls: ToolCall[] = [];
let failed = false;
try {
for await (const chunk of adapter.streamChat(options.config, {
model: options.model,
system: options.system,
messages,
tools,
signal: options.signal,
})) {
if (chunk.type === "text") {
text += chunk.text;
yield { type: "token", text: chunk.text };
} else if (chunk.type === "tool_call") {
calls.push(chunk.call);
} else if (chunk.type === "error") {
failed = true;
yield { type: "error", message: chunk.message };
}
}
} catch (error) {
const message = getErrorMessage(error, "The provider request failed");
yield { type: "error", message };
return;
}
if (failed) return;
yield { type: "assistant_message", content: text, toolCalls: calls };
if (!calls.length) {
yield { type: "done" };
return;
}
messages.push({ role: "assistant", content: text, toolCalls: calls });
for (const call of calls) {
yield { type: "tool_call", name: call.name, arguments: call.arguments };
const result = await runTool(call, options.context);
if (isProposalDraft(result)) {
// Closes the tool call before the proposal card is emitted. Without
// this the call has no matching result and renders as permanently
// running, even though the work is done and awaiting the user.
yield {
type: "tool_result",
name: call.name,
result: { status: "awaiting_user_approval" },
};
yield { type: "proposal", draft: result };
// The model is told the proposal is awaiting the user rather than done,
// so it does not go on to describe the change as applied.
messages.push({
role: "tool",
content: JSON.stringify({
status: "awaiting_user_approval",
summary: result.summary,
}),
toolCallId: call.id,
toolName: call.name,
});
continue;
}
yield { type: "tool_result", name: call.name, result: redact(result) };
messages.push({
role: "tool",
content: redactToJson(result),
toolCallId: call.id,
toolName: call.name,
});
}
}
// Ran out of turns with the model still calling tools.
yield {
type: "error",
message: "The assistant used too many steps without finishing.",
};
}
async function runTool(call: ToolCall, context: ToolContext): Promise<unknown> {
const tool = getTool(call.name);
// A model can emit any name it likes; only the catalog decides what runs.
if (!tool) {
return { error: `Unknown tool: ${call.name}` };
}
try {
return await tool.handler(call.arguments ?? {}, context);
} catch (error) {
return {
error: getErrorMessage(error, "The tool failed"),
};
}
}
+70
View File
@@ -0,0 +1,70 @@
import type { NextFunction, Response } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import {
createCurrentSettingsRepository,
createCurrentUserPreferenceRepository,
} from "../database/repositories/factory.js";
/**
* Three gates, all checked on the server.
*
* The admin global is a hard kill switch: when it is off the feature does not
* exist for anyone, regardless of what any user has enabled. It defaults to
* false so upgrading an existing install turns nothing on by surprise.
*
* Mirrors the shape of isSharingEnabledForHost in the session-sharing routes,
* where the global also wins over the per-entity setting.
*/
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
export async function isAiGloballyEnabled(): Promise<boolean> {
return createCurrentSettingsRepository().getBoolean(
AI_GLOBAL_ENABLED_KEY,
false,
);
}
export interface AiAccess {
enabled: boolean;
allowReadOnlyCommands: boolean;
}
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
const globalEnabled = await isAiGloballyEnabled();
if (!globalEnabled) {
return { enabled: false, allowReadOnlyCommands: false };
}
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
return {
// Null means the user was never asked, which is not consent.
enabled: preferences?.aiAssistantEnabled === true,
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
};
}
/** Rejects any AI request unless both gates are open. */
export function createAiGate() {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): Promise<void> => {
if (!req.userId) {
res.status(401).json({ error: "Authentication required" });
return;
}
const access = await resolveAiAccess(req.userId);
if (!access.enabled) {
res.status(403).json({ error: "The AI assistant is not enabled" });
return;
}
(req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access;
next();
};
}
+976
View File
@@ -0,0 +1,976 @@
import { getErrorMessage } from "../utils/error-message.js";
import express from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { AuthManager } from "../utils/auth-manager.js";
import { databaseLogger } from "../utils/logger.js";
import {
getAuditUsername,
getRequestMeta,
logAudit,
} from "../utils/audit-logger.js";
import {
createCurrentAiRepository,
createCurrentHostRepository,
} from "../database/repositories/factory.js";
import { buildSystemPrompt } from "./context.js";
import { runAgent } from "./engine.js";
import {
createAiGate,
isAiGloballyEnabled,
resolveAiAccess,
} from "./gating.js";
import {
FALLBACK_MODELS,
getAdapter,
REQUIRES_API_KEY,
REQUIRES_BASE_URL,
} from "./providers/registry.js";
import type {
AiProviderType,
ChatMessage,
ProviderConfig,
} from "./providers/types.js";
import { isAiProviderType } from "./providers/types.js";
import { applyProposal } from "./tools/executor.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const aiGate = createAiGate();
function parseId(raw: unknown): number | null {
const id = typeof raw === "string" ? parseInt(raw, 10) : Number(raw);
return Number.isInteger(id) && id > 0 ? id : null;
}
/**
* @openapi
* /ai/status:
* get:
* summary: Whether the AI assistant is available to this user
* description: >
* Deliberately not behind the AI gate: the frontend calls this to decide
* whether to render any AI surface at all, and needs a plain answer rather
* than a 403 when the feature is off.
* tags:
* - AI
* responses:
* 200:
* description: The effective enablement state.
*/
router.get("/status", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
try {
const [globalEnabled, access] = await Promise.all([
isAiGloballyEnabled(),
resolveAiAccess(userId),
]);
res.json({
globallyEnabled: globalEnabled,
enabled: access.enabled,
allowReadOnlyCommands: access.allowReadOnlyCommands,
});
} catch (err) {
databaseLogger.error("Failed to read AI status", err, {
operation: "ai_status_failed",
userId,
});
res.status(500).json({ error: "Failed to read AI status" });
}
});
/**
* @openapi
* /ai/providers:
* get:
* summary: List the user's configured AI providers
* tags:
* - AI
* responses:
* 200:
* description: Providers, with API keys masked.
* 403:
* description: The AI assistant is not enabled.
*/
router.get(
"/providers",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
try {
const providers = await createCurrentAiRepository().listProviders(userId);
res.json({ providers });
} catch (err) {
databaseLogger.error("Failed to list AI providers", err, {
operation: "ai_providers_list_failed",
userId,
});
res.status(500).json({ error: "Failed to list providers" });
}
},
);
/**
* @openapi
* /ai/providers:
* post:
* summary: Add an AI provider
* tags:
* - AI
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* providerType:
* type: string
* label:
* type: string
* baseUrl:
* type: string
* apiKey:
* type: string
* defaultModel:
* type: string
* responses:
* 201:
* description: Provider created.
* 400:
* description: Invalid request body.
*/
router.post(
"/providers",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const { providerType, label, baseUrl, apiKey, defaultModel } =
req.body ?? {};
if (!isAiProviderType(providerType)) {
return res.status(400).json({ error: "Unknown provider type" });
}
if (typeof label !== "string" || !label.trim()) {
return res.status(400).json({ error: "label is required" });
}
if (REQUIRES_BASE_URL.includes(providerType) && !baseUrl?.trim()) {
return res.status(400).json({ error: "This provider needs a base URL" });
}
if (REQUIRES_API_KEY.includes(providerType) && !apiKey?.trim()) {
return res.status(400).json({ error: "This provider needs an API key" });
}
try {
const created = await createCurrentAiRepository().createProvider({
userId,
providerType,
label: label.trim(),
baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null,
apiKey: typeof apiKey === "string" ? apiKey.trim() : null,
defaultModel:
typeof defaultModel === "string" ? defaultModel.trim() : null,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "create_ai_provider",
resourceType: "ai_provider",
resourceId: String(created.id),
resourceName: created.label,
ipAddress,
userAgent,
success: true,
});
res.status(201).json({ provider: created });
} catch (err) {
databaseLogger.error("Failed to create AI provider", err, {
operation: "ai_provider_create_failed",
userId,
});
res.status(500).json({ error: "Failed to create provider" });
}
},
);
/**
* @openapi
* /ai/providers/{id}:
* patch:
* summary: Update an AI provider
* tags:
* - AI
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider updated.
* 404:
* description: Provider not found.
*/
router.patch(
"/providers/:id",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid provider id" });
try {
const updated = await createCurrentAiRepository().updateProvider(
id,
userId,
req.body ?? {},
);
if (!updated)
return res.status(404).json({ error: "Provider not found" });
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "update_ai_provider",
resourceType: "ai_provider",
resourceId: String(id),
resourceName: updated.label,
ipAddress,
userAgent,
success: true,
});
res.json({ provider: updated });
} catch (err) {
databaseLogger.error("Failed to update AI provider", err, {
operation: "ai_provider_update_failed",
userId,
});
res.status(500).json({ error: "Failed to update provider" });
}
},
);
/**
* @openapi
* /ai/providers/{id}:
* delete:
* summary: Delete an AI provider
* tags:
* - AI
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider deleted.
* 404:
* description: Provider not found.
*/
router.delete(
"/providers/:id",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid provider id" });
try {
const deleted = await createCurrentAiRepository().deleteProvider(
id,
userId,
);
if (!deleted)
return res.status(404).json({ error: "Provider not found" });
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "delete_ai_provider",
resourceType: "ai_provider",
resourceId: String(id),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to delete AI provider", err, {
operation: "ai_provider_delete_failed",
userId,
});
res.status(500).json({ error: "Failed to delete provider" });
}
},
);
/**
* @openapi
* /ai/probe-models:
* post:
* summary: List models for a provider that has not been saved yet
* description: >
* Lets the add-provider form fill its model picker before the provider
* exists, so nobody has to go and look up model names by hand.
* tags:
* - AI
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* providerType:
* type: string
* baseUrl:
* type: string
* apiKey:
* type: string
* providerId:
* type: integer
* responses:
* 200:
* description: Model ids, possibly a curated fallback list.
* 400:
* description: Unknown provider type.
*/
router.post(
"/probe-models",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const { providerType, baseUrl, apiKey, providerId } = req.body ?? {};
if (!isAiProviderType(providerType)) {
return res.status(400).json({ error: "Unknown provider type" });
}
try {
// Editing an existing provider sends no key, so fall back to the stored
// one rather than making the user retype it just to refresh the list.
let resolvedKey =
typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : null;
if (!resolvedKey && parseId(providerId)) {
const stored = await createCurrentAiRepository().findProviderWithSecret(
parseId(providerId) as number,
userId,
);
resolvedKey = stored?.apiKey ?? null;
}
const models = await getAdapter(providerType).listModels({
providerType,
baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null,
apiKey: resolvedKey,
});
res.json({ models, source: "live" });
} catch (err) {
// A provider that cannot be reached yet still gets a usable list, so the
// form is never a blank text box the user has to guess into.
const fallback = FALLBACK_MODELS[providerType as AiProviderType] ?? [];
res.json({
models: fallback,
source: fallback.length ? "fallback" : "none",
warning: err instanceof Error ? err.message : undefined,
});
}
},
);
/**
* @openapi
* /ai/providers/{id}/models:
* get:
* summary: List models available from a provider
* tags:
* - AI
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Model ids.
* 502:
* description: The provider could not be reached.
*/
router.get(
"/providers/:id/models",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid provider id" });
try {
const provider = await createCurrentAiRepository().findProviderWithSecret(
id,
userId,
);
if (!provider)
return res.status(404).json({ error: "Provider not found" });
const models = await getAdapter(provider.providerType).listModels({
providerType: provider.providerType as ProviderConfig["providerType"],
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
});
res.json({ models });
} catch (err) {
// The message can carry the allowlist hint, which the user needs to act on.
const message = getErrorMessage(err, "Could not reach the provider");
databaseLogger.warn("Failed to list provider models", {
operation: "ai_provider_models_failed",
userId,
});
res.status(502).json({ error: message });
}
},
);
/**
* @openapi
* /ai/conversations:
* get:
* summary: List the user's AI conversations
* tags:
* - AI
* responses:
* 200:
* description: Conversations, newest first.
*/
router.get(
"/conversations",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
try {
const conversations =
await createCurrentAiRepository().listConversations(userId);
res.json({ conversations });
} catch (err) {
databaseLogger.error("Failed to list AI conversations", err, {
operation: "ai_conversations_list_failed",
userId,
});
res.status(500).json({ error: "Failed to list conversations" });
}
},
);
/**
* @openapi
* /ai/conversations/{id}:
* get:
* summary: Get one conversation with its messages and proposals
* tags:
* - AI
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The conversation.
* 404:
* description: Conversation not found.
*/
router.get(
"/conversations/:id",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid conversation id" });
try {
const repository = createCurrentAiRepository();
const conversation = await repository.findConversation(id, userId);
if (!conversation) {
return res.status(404).json({ error: "Conversation not found" });
}
const [messages, proposals] = await Promise.all([
repository.listMessages(id),
repository.listProposals(userId, id),
]);
res.json({ conversation, messages, proposals });
} catch (err) {
databaseLogger.error("Failed to load AI conversation", err, {
operation: "ai_conversation_load_failed",
userId,
});
res.status(500).json({ error: "Failed to load conversation" });
}
},
);
/**
* @openapi
* /ai/conversations/{id}:
* delete:
* summary: Delete a conversation
* tags:
* - AI
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Conversation deleted.
*/
router.delete(
"/conversations/:id",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid conversation id" });
try {
const deleted = await createCurrentAiRepository().deleteConversation(
id,
userId,
);
if (!deleted) {
return res.status(404).json({ error: "Conversation not found" });
}
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to delete AI conversation", err, {
operation: "ai_conversation_delete_failed",
userId,
});
res.status(500).json({ error: "Failed to delete conversation" });
}
},
);
/**
* @openapi
* /ai/chat/stream:
* post:
* summary: Send a message and stream the assistant's reply
* description: >
* Server-sent events. Emits token, tool_call, tool_result, proposal,
* done and error frames.
* tags:
* - AI
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* conversationId:
* type: integer
* providerId:
* type: integer
* model:
* type: string
* message:
* type: string
* activeTab:
* type: string
* responses:
* 200:
* description: An event stream.
* 400:
* description: Invalid request body.
*/
router.post(
"/chat/stream",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const { conversationId, providerId, model, message, activeTab } =
req.body ?? {};
if (typeof message !== "string" || !message.trim()) {
return res.status(400).json({ error: "message is required" });
}
const resolvedProviderId = parseId(providerId);
if (!resolvedProviderId) {
return res.status(400).json({ error: "providerId is required" });
}
const repository = createCurrentAiRepository();
try {
const provider = await repository.findProviderWithSecret(
resolvedProviderId,
userId,
);
if (!provider) {
return res.status(404).json({ error: "Provider not found" });
}
const chosenModel =
(typeof model === "string" && model.trim()) ||
provider.defaultModel ||
"";
if (!chosenModel) {
return res.status(400).json({ error: "No model selected" });
}
// Resolve or create the conversation before the stream opens, so a
// failure here is still a normal JSON error the client can render.
let conversation = conversationId
? await repository.findConversation(Number(conversationId), userId)
: null;
if (!conversation) {
conversation = await repository.createConversation({
userId,
title: message.trim().slice(0, 60),
providerId: resolvedProviderId,
model: chosenModel,
});
}
const history = await repository.listMessages(conversation.id);
await repository.appendMessage({
conversationId: conversation.id,
role: "user",
content: message.trim(),
});
const access = await resolveAiAccess(userId);
const hosts = await createCurrentHostRepository().listByUserId(userId);
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
const heartbeat = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
clearInterval(heartbeat);
}
}, 30000);
const abort = new AbortController();
req.on("close", () => {
clearInterval(heartbeat);
abort.abort();
});
const send = (event: unknown) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
};
send({ type: "conversation", conversationId: conversation.id });
const chatHistory: ChatMessage[] = history.map((entry) => ({
role: entry.role as ChatMessage["role"],
content: entry.content,
...(entry.toolCalls ? { toolCalls: JSON.parse(entry.toolCalls) } : {}),
}));
chatHistory.push({ role: "user", content: message.trim() });
let assistantText = "";
let assistantToolCalls: unknown[] = [];
try {
for await (const event of runAgent({
config: {
providerType:
provider.providerType as ProviderConfig["providerType"],
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
},
model: chosenModel,
system: buildSystemPrompt({
hostCount: hosts.length,
activeTab: typeof activeTab === "string" ? activeTab : null,
allowReadOnlyCommands: access.allowReadOnlyCommands,
}),
history: chatHistory,
context: {
userId,
conversationId: conversation.id,
allowReadOnlyCommands: access.allowReadOnlyCommands,
},
signal: abort.signal,
})) {
if (event.type === "assistant_message") {
assistantText = event.content;
// Kept so the next message replays them verbatim. Gemini rejects a
// turn whose functionCall parts lost their thoughtSignature, so
// dropping these breaks the second message in every conversation.
assistantToolCalls = event.toolCalls;
continue;
}
if (event.type === "proposal") {
const stored = await repository.createProposal({
conversationId: conversation.id,
userId,
kind: event.draft.kind,
summary: event.draft.summary,
payload: JSON.stringify(event.draft.payload),
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "ai_proposal_created",
resourceType: "ai_proposal",
resourceId: String(stored.id),
resourceName: event.draft.kind,
ipAddress,
userAgent,
success: true,
});
send({ type: "proposal", proposal: stored });
continue;
}
send(event);
}
} finally {
clearInterval(heartbeat);
}
if (assistantText || assistantToolCalls.length) {
await repository.appendMessage({
conversationId: conversation.id,
role: "assistant",
content: assistantText,
toolCalls: assistantToolCalls.length
? JSON.stringify(assistantToolCalls)
: null,
});
}
await repository.touchConversation(conversation.id);
send({ type: "done" });
res.end();
} catch (err) {
databaseLogger.error("AI chat stream failed", err, {
operation: "ai_chat_stream_failed",
userId,
});
if (res.headersSent) {
res.write(
`data: ${JSON.stringify({ type: "error", message: "The assistant stopped unexpectedly" })}\n\n`,
);
res.end();
} else {
res.status(500).json({ error: "Failed to start the assistant" });
}
}
},
);
/**
* @openapi
* /ai/proposals/{id}/apply:
* post:
* summary: Apply a pending proposal
* description: >
* Re-validates the stored payload and dispatches it through the same
* repository logic a manual action uses.
* tags:
* - AI
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Proposal applied.
* 400:
* description: The proposal could not be applied.
* 404:
* description: Proposal not found.
*/
router.post(
"/proposals/:id/apply",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid proposal id" });
const repository = createCurrentAiRepository();
try {
const stored = await repository.findProposal(id, userId);
if (!stored) return res.status(404).json({ error: "Proposal not found" });
if (stored.status !== "pending") {
return res
.status(400)
.json({ error: `This proposal was already ${stored.status}` });
}
let payload: Record<string, unknown>;
try {
payload = JSON.parse(stored.payload) as Record<string, unknown>;
} catch {
return res
.status(400)
.json({ error: "The proposal payload is invalid" });
}
const result = await applyProposal(stored.kind, payload, userId);
await repository.setProposalStatus(id, userId, "applied", result.summary);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "ai_proposal_applied",
resourceType: "ai_proposal",
resourceId: String(id),
resourceName: stored.kind,
ipAddress,
userAgent,
success: true,
});
res.json({ success: result.ok, summary: result.summary });
} catch (err) {
const message = getErrorMessage(err, "Failed to apply the proposal");
databaseLogger.error("Failed to apply AI proposal", err, {
operation: "ai_proposal_apply_failed",
userId,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "ai_proposal_applied",
resourceType: "ai_proposal",
resourceId: String(id),
ipAddress,
userAgent,
success: false,
errorMessage: message,
});
res.status(400).json({ error: message });
}
},
);
/**
* @openapi
* /ai/proposals/{id}/reject:
* post:
* summary: Reject a pending proposal
* tags:
* - AI
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Proposal rejected.
* 404:
* description: Proposal not found.
*/
router.post(
"/proposals/:id/reject",
authenticateJWT,
requireDataAccess,
aiGate,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId as string;
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: "Invalid proposal id" });
try {
const updated = await createCurrentAiRepository().setProposalStatus(
id,
userId,
"rejected",
);
if (!updated) {
return res
.status(404)
.json({ error: "Proposal not found or already resolved" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "ai_proposal_rejected",
resourceType: "ai_proposal",
resourceId: String(id),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to reject AI proposal", err, {
operation: "ai_proposal_reject_failed",
userId,
});
res.status(500).json({ error: "Failed to reject the proposal" });
}
},
);
export default router;
+162
View File
@@ -0,0 +1,162 @@
import Anthropic from "@anthropic-ai/sdk";
import { providerFetch } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
import { AiProviderError } from "./types.js";
/**
* Model ids offered in the picker. Users can type any other id; this is a
* convenience list, not a restriction.
*/
export const ANTHROPIC_MODELS = [
"claude-opus-5",
"claude-sonnet-5",
"claude-haiku-4-5",
];
function createClient(config: ProviderConfig): Anthropic {
if (!config.apiKey) {
throw new AiProviderError("This provider needs an API key");
}
return new Anthropic({
apiKey: config.apiKey,
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
// Routes the SDK's HTTP through the shared egress guard.
fetch: providerFetch as unknown as typeof fetch,
});
}
function toAnthropicMessages(request: ChatRequest): Anthropic.MessageParam[] {
const messages: Anthropic.MessageParam[] = [];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: message.toolCallId ?? "",
content: message.content,
},
],
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
const content: Anthropic.ContentBlockParam[] = [];
if (message.content)
content.push({ type: "text", text: message.content });
for (const call of message.toolCalls) {
content.push({
type: "tool_use",
id: call.id,
name: call.name,
input: call.arguments,
});
}
messages.push({ role: "assistant", content });
continue;
}
if (message.role === "system") continue;
messages.push({ role: message.role, content: message.content });
}
return messages;
}
/**
* The SDK throws its own typed errors rather than going through assertOk, so
* they are translated here to match what every other provider reports.
*/
function translateSdkError(error: unknown): never {
const status =
typeof (error as { status?: unknown })?.status === "number"
? (error as { status: number }).status
: undefined;
const detail = error instanceof Error ? error.message : String(error);
if (status === 429) {
throw new AiProviderError(
`Anthropic rate limit reached. Wait a moment and try again, or check your plan's quota. (${detail})`,
429,
);
}
if (status === 401 || status === 403) {
throw new AiProviderError(
`Anthropic rejected the API key. Check that it is correct and still active. (${detail})`,
status,
);
}
throw new AiProviderError(
status ? `Anthropic request failed (${status}): ${detail}` : detail,
status,
);
}
export const anthropicAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const client = createClient(config);
const stream = client.messages.stream({
model: request.model,
max_tokens: 16000,
system: request.system,
messages: toAnthropicMessages(request),
thinking: { type: "adaptive" },
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: tool.parameters as Anthropic.Tool.InputSchema,
})),
}
: {}),
...(request.signal ? { signal: request.signal } : {}),
});
let final: Anthropic.Message;
try {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
yield { type: "text", text: event.delta.text };
}
}
final = await stream.finalMessage();
} catch (error) {
translateSdkError(error);
}
for (const block of final.content) {
if (block.type === "tool_use") {
yield {
type: "tool_call",
call: {
id: block.id,
name: block.name,
arguments: (block.input ?? {}) as Record<string, unknown>,
},
};
}
}
yield { type: "done", stopReason: final.stop_reason ?? undefined };
},
async listModels(): Promise<string[]> {
return [...ANTHROPIC_MODELS];
},
};
+191
View File
@@ -0,0 +1,191 @@
import { assertOk, providerFetch, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
import { AiProviderError } from "./types.js";
const GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
function baseFor(config: ProviderConfig): string {
return config.baseUrl?.trim() || GEMINI_DEFAULT_BASE;
}
/**
* Gemini has no tool role: a tool result is a user-side functionResponse part,
* and an assistant tool request is a model-side functionCall part.
*/
function toGeminiContents(request: ChatRequest): unknown[] {
const contents: unknown[] = [];
for (const message of request.messages) {
if (message.role === "tool") {
contents.push({
role: "user",
parts: [
{
functionResponse: {
name: message.toolName ?? "tool",
response: { result: message.content },
},
},
],
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
const parts: unknown[] = [];
if (message.content) parts.push({ text: message.content });
for (const call of message.toolCalls) {
// thoughtSignature must be returned exactly as received or Gemini
// 2.5+ rejects the turn with a 400.
parts.push({
functionCall: { name: call.name, args: call.arguments },
...(call.providerSignature
? { thoughtSignature: call.providerSignature }
: {}),
});
}
contents.push({ role: "model", parts });
continue;
}
if (message.role === "system") continue;
contents.push({
role: message.role === "assistant" ? "model" : "user",
parts: [{ text: message.content }],
});
}
return contents;
}
/**
* Gemini rejects the JSON Schema keywords it does not implement, so the tool
* schemas are trimmed to the subset it accepts.
*/
function toGeminiSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(toGeminiSchema);
const source = schema as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (key === "additionalProperties" || key === "$schema") continue;
if (key === "properties" && value && typeof value === "object") {
const properties: Record<string, unknown> = {};
for (const [name, child] of Object.entries(
value as Record<string, unknown>,
)) {
properties[name] = toGeminiSchema(child);
}
output[key] = properties;
continue;
}
output[key] = toGeminiSchema(value);
}
return output;
}
export const geminiAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
if (!config.apiKey) {
throw new AiProviderError("This provider needs an API key");
}
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
const response = await providerFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": config.apiKey,
},
signal: request.signal,
body: JSON.stringify({
systemInstruction: { parts: [{ text: request.system }] },
contents: toGeminiContents(request),
...(request.tools.length
? {
tools: [
{
functionDeclarations: request.tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toGeminiSchema(tool.parameters),
})),
},
],
}
: {}),
}),
});
await assertOk(response, "Gemini");
let index = 0;
let stopReason: string | undefined;
for await (const data of readSseLines(response)) {
let frame: any;
try {
frame = JSON.parse(data);
} catch {
continue;
}
const candidate = frame.candidates?.[0];
if (!candidate) continue;
if (candidate.finishReason) stopReason = candidate.finishReason;
for (const part of candidate.content?.parts ?? []) {
if (typeof part.text === "string" && part.text) {
yield { type: "text", text: part.text };
}
if (part.functionCall?.name) {
yield {
type: "tool_call",
call: {
id: `call_${part.functionCall.name}_${index++}`,
name: part.functionCall.name,
arguments: (part.functionCall.args ?? {}) as Record<
string,
unknown
>,
// Carried so the next turn can echo it back; without it Gemini
// 400s as soon as a tool has been used once.
providerSignature: part.thoughtSignature,
},
};
}
}
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
if (!config.apiKey) return [];
const response = await providerFetch(
`${baseFor(config).replace(/\/+$/, "")}/models`,
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
);
await assertOk(response, "Gemini");
const body: any = await response.json();
return (body.models ?? [])
.map((entry: any) => String(entry.name ?? "").replace(/^models\//, ""))
.filter((name: string) => name.length > 0)
.sort();
},
};
+177
View File
@@ -0,0 +1,177 @@
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
import { AiProviderError } from "./types.js";
/**
* Every outbound provider request goes through here so the egress rules cannot
* be bypassed by an adapter calling fetch directly.
*
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
* connect time. Allowlisted private hosts cannot use it (its whole job is to
* refuse them), so they fall back to plain fetch with the proxy dispatcher --
* still respecting corporate proxy configuration.
*/
export async function providerFetch(
url: string,
init: RequestInit,
): Promise<Response> {
const allowlist = await readPrivateAllowlist();
const decision = evaluateEgress(url, allowlist);
if (!decision.allowed) {
throw new AiProviderError(decision.reason ?? "Destination not allowed");
}
if (decision.isPrivate) {
return fetch(url, {
...init,
dispatcher: getFetchDispatcher(url),
} as RequestInit);
}
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
}
export function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
/**
* Yields the data payload of each SSE frame. Providers differ in what they put
* inside, so parsing the JSON is left to the caller.
*/
export async function* readSseLines(
response: Response,
): AsyncGenerator<string> {
const body = response.body;
if (!body) return;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line.startsWith("data:")) {
yield line.slice(5).trim();
}
newlineIndex = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
/** Yields one parsed JSON object per line, for newline-delimited streams. */
export async function* readJsonLines(
response: Response,
): AsyncGenerator<unknown> {
const body = response.body;
if (!body) return;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
try {
yield JSON.parse(line);
} catch {
// A partial or malformed frame is skipped rather than failing the
// whole stream.
}
}
newlineIndex = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
/**
* Digs the human-readable message out of an error body.
*
* Every provider nests it differently, and dumping the raw JSON produced
* something that got cut off mid-sentence. Falls back to a trimmed snippet
* when the shape is unfamiliar.
*/
function extractProviderMessage(body: string): string {
try {
const parsed = JSON.parse(body);
const message =
parsed?.error?.message ??
parsed?.error?.["message"] ??
parsed?.message ??
(typeof parsed?.error === "string" ? parsed.error : null);
if (typeof message === "string" && message.trim()) {
return message.trim();
}
} catch {
// Not JSON; fall through to the snippet.
}
const trimmed = body.trim();
if (!trimmed) return "";
return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
}
export async function assertOk(
response: Response,
provider: string,
): Promise<void> {
if (response.ok) return;
let body = "";
try {
body = await response.text();
} catch {
body = "";
}
const detail = extractProviderMessage(body);
// Rate limits and auth failures are the two the user can actually act on,
// so they say what to do instead of reading like an internal failure.
if (response.status === 429) {
throw new AiProviderError(
`${provider} rate limit reached. Wait a moment and try again, or check your plan's quota.${
detail ? ` (${detail})` : ""
}`,
429,
);
}
if (response.status === 401 || response.status === 403) {
throw new AiProviderError(
`${provider} rejected the API key. Check that it is correct and still active.${
detail ? ` (${detail})` : ""
}`,
response.status,
);
}
throw new AiProviderError(
`${provider} request failed (${response.status})${detail ? `: ${detail}` : ""}`,
response.status,
);
}
+131
View File
@@ -0,0 +1,131 @@
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
export const OLLAMA_DEFAULT_BASE = "http://localhost:11434";
function baseFor(config: ProviderConfig): string {
return config.baseUrl?.trim() || OLLAMA_DEFAULT_BASE;
}
function toOllamaMessages(request: ChatRequest): unknown[] {
const messages: unknown[] = [{ role: "system", content: request.system }];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "tool",
content: message.content,
...(message.toolName ? { tool_name: message.toolName } : {}),
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
messages.push({
role: "assistant",
content: message.content,
tool_calls: message.toolCalls.map((call) => ({
function: { name: call.name, arguments: call.arguments },
})),
});
continue;
}
messages.push({ role: message.role, content: message.content });
}
return messages;
}
export const ollamaAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: request.signal,
body: JSON.stringify({
model: request.model,
messages: toOllamaMessages(request),
stream: true,
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
})),
}
: {}),
}),
});
await assertOk(response, "Ollama");
let index = 0;
let stopReason: string | undefined;
// Ollama streams newline-delimited JSON rather than SSE.
for await (const frame of readJsonLines(response)) {
const payload = frame as any;
if (
typeof payload.message?.content === "string" &&
payload.message.content
) {
yield { type: "text", text: payload.message.content };
}
for (const call of payload.message?.tool_calls ?? []) {
const name = call.function?.name;
if (!name) continue;
const rawArgs = call.function?.arguments;
// Ollama sends an object, but some builds send a JSON string.
let args: Record<string, unknown> = {};
if (rawArgs && typeof rawArgs === "object") {
args = rawArgs as Record<string, unknown>;
} else if (typeof rawArgs === "string" && rawArgs.trim()) {
try {
args = JSON.parse(rawArgs);
} catch {
args = {};
}
}
yield {
type: "tool_call",
call: { id: `call_${name}_${index++}`, name, arguments: args },
};
}
if (payload.done) {
stopReason = payload.done_reason ?? "stop";
break;
}
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), {
method: "GET",
});
await assertOk(response, "Ollama");
const body: any = await response.json();
return (body.models ?? [])
.map((entry: any) => entry.name)
.filter((name: unknown): name is string => typeof name === "string")
.sort();
},
};
+182
View File
@@ -0,0 +1,182 @@
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
ToolCall,
} from "./types.js";
import { AiProviderError } from "./types.js";
const OPENAI_DEFAULT_BASE = "https://api.openai.com/v1";
function baseFor(config: ProviderConfig): string {
if (config.baseUrl?.trim()) return config.baseUrl.trim();
if (config.providerType === "openai") return OPENAI_DEFAULT_BASE;
throw new AiProviderError("This provider needs a base URL");
}
function toOpenAiMessages(request: ChatRequest): unknown[] {
const messages: unknown[] = [{ role: "system", content: request.system }];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "tool",
tool_call_id: message.toolCallId,
content: message.content,
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
messages.push({
role: "assistant",
content: message.content || null,
tool_calls: message.toolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: JSON.stringify(call.arguments),
},
})),
});
continue;
}
messages.push({ role: message.role, content: message.content });
}
return messages;
}
/**
* Tool call arguments arrive as JSON fragments spread across many deltas, so
* they are accumulated per index and only parsed once the stream ends.
*/
interface PartialCall {
id: string;
name: string;
args: string;
}
export const openAiAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const url = joinUrl(baseFor(config), "chat/completions");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(url, {
method: "POST",
headers,
signal: request.signal,
body: JSON.stringify({
model: request.model,
messages: toOpenAiMessages(request),
stream: true,
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
})),
}
: {}),
}),
});
await assertOk(response, "OpenAI");
const partial = new Map<number, PartialCall>();
let stopReason: string | undefined;
for await (const data of readSseLines(response)) {
if (data === "[DONE]") break;
let frame: any;
try {
frame = JSON.parse(data);
} catch {
continue;
}
const choice = frame.choices?.[0];
if (!choice) continue;
if (choice.finish_reason) stopReason = choice.finish_reason;
const delta = choice.delta;
if (!delta) continue;
if (typeof delta.content === "string" && delta.content) {
yield { type: "text", text: delta.content };
}
for (const call of delta.tool_calls ?? []) {
const index = call.index ?? 0;
const existing = partial.get(index) ?? { id: "", name: "", args: "" };
if (call.id) existing.id = call.id;
if (call.function?.name) existing.name = call.function.name;
if (call.function?.arguments) existing.args += call.function.arguments;
partial.set(index, existing);
}
}
for (const call of partial.values()) {
if (!call.name) continue;
yield { type: "tool_call", call: finalizeCall(call) };
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
const headers: Record<string, string> = {};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(joinUrl(baseFor(config), "models"), {
method: "GET",
headers,
});
await assertOk(response, "OpenAI");
const body: any = await response.json();
return (body.data ?? [])
.map((entry: any) => entry.id)
.filter((id: unknown): id is string => typeof id === "string")
.sort();
},
};
export function finalizeCall(call: PartialCall): ToolCall {
let args: Record<string, unknown> = {};
if (call.args.trim()) {
try {
const parsed = JSON.parse(call.args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
args = parsed as Record<string, unknown>;
}
} catch {
// A model that emitted malformed arguments gets an empty object; the
// tool's own schema validation reports the problem back to it.
args = {};
}
}
return {
id:
call.id || `call_${call.name}_${Math.random().toString(36).slice(2, 10)}`,
name: call.name,
arguments: args,
};
}
+64
View File
@@ -0,0 +1,64 @@
import { anthropicAdapter } from "./anthropic.js";
import { geminiAdapter } from "./gemini.js";
import { ollamaAdapter } from "./ollama.js";
import { openAiAdapter } from "./openai.js";
import {
AiProviderError,
type AiProviderType,
type ProviderAdapter,
} from "./types.js";
/**
* openai_compatible reuses the OpenAI adapter: OpenRouter, Groq, Mistral,
* DeepSeek, together.ai, LM Studio and vLLM all speak the same wire format,
* they differ only in base URL.
*/
const ADAPTERS: Record<AiProviderType, ProviderAdapter> = {
ollama: ollamaAdapter,
anthropic: anthropicAdapter,
openai: openAiAdapter,
gemini: geminiAdapter,
openai_compatible: openAiAdapter,
};
export function getAdapter(providerType: string): ProviderAdapter {
const adapter = ADAPTERS[providerType as AiProviderType];
if (!adapter) {
throw new AiProviderError(`Unknown provider type: ${providerType}`);
}
return adapter;
}
/** Provider types that cannot work without a base URL. */
export const REQUIRES_BASE_URL: AiProviderType[] = [
"ollama",
"openai_compatible",
];
/** Provider types that cannot work without an API key. */
export const REQUIRES_API_KEY: AiProviderType[] = [
"anthropic",
"openai",
"gemini",
];
/**
* Shown when a provider's model list cannot be fetched: no key entered yet,
* the endpoint is unreachable, or the vendor has no list endpoint. Users can
* always type a model id the list does not contain, so this is a starting
* point rather than a restriction.
*/
export const FALLBACK_MODELS: Record<AiProviderType, string[]> = {
ollama: [
"llama3.2",
"llama3.1",
"qwen2.5-coder",
"mistral",
"phi4",
"gemma2",
],
anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
openai: ["gpt-5", "gpt-5-mini", "o4-mini", "gpt-4.1"],
gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
openai_compatible: [],
};
+90
View File
@@ -0,0 +1,90 @@
/**
* The shape every provider adapter normalizes to. Adding a provider means
* translating its wire format into these events; nothing downstream (the
* engine, the tool dispatcher, the SSE route) knows which vendor is in use.
*/
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
export const AI_PROVIDER_TYPES: AiProviderType[] = [
"ollama",
"anthropic",
"openai",
"gemini",
"openai_compatible",
];
export function isAiProviderType(value: unknown): value is AiProviderType {
return (
typeof value === "string" && (AI_PROVIDER_TYPES as string[]).includes(value)
);
}
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
/** Set on assistant turns that requested tools. */
toolCalls?: ToolCall[];
/** Set on tool turns, matching the id of the call being answered. */
toolCallId?: string;
toolName?: string;
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
/**
* Opaque provider state that has to be echoed back verbatim on the next
* turn. Gemini 2.5+ rejects a follow-up whose functionCall parts have lost
* their thoughtSignature, so this rides along rather than being dropped.
*/
providerSignature?: string;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ChatRequest {
model: string;
system: string;
messages: ChatMessage[];
tools: ToolDefinition[];
signal?: AbortSignal;
}
export type ChatChunk =
| { type: "text"; text: string }
| { type: "tool_call"; call: ToolCall }
| { type: "done"; stopReason?: string }
| { type: "error"; message: string };
export interface ProviderConfig {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
}
export interface ProviderAdapter {
/** Streams a single assistant turn. Tool execution happens in the engine. */
streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk>;
/** Model ids to offer in the picker, best effort. */
listModels(config: ProviderConfig): Promise<string[]>;
}
export class AiProviderError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = "AiProviderError";
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Defense in depth for anything about to leave the server.
*
* Read tools already select explicit field allowlists rather than spreading
* rows, so nothing secret should reach here. This exists because "should" is
* not a guarantee: a future tool that forgets to project its fields would
* otherwise ship credentials to a third-party model provider.
*/
const SECRET_KEY_PATTERN =
/^(password|passwd|pass|secret|token|api_?key|apikey|private_?key|privatekey|key_?password|keypassword|passphrase|client_?secret|authorization|auth_?token|access_?token|refresh_?token|totp_?secret|backup_?codes|session_?token|cookie|credential|ssh_?cert|data_?key|dek)$/i;
/** Substring markers for keys that are not exact matches but still sensitive. */
const SECRET_KEY_SUBSTRINGS = [
"password",
"secret",
"privatekey",
"private_key",
"apikey",
"api_key",
"passphrase",
];
const VALUE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
{
pattern:
/-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
label: "[redacted private key]",
},
{ pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bghp_[A-Za-z0-9]{20,}\b/g, label: "[redacted token]" },
{ pattern: /\btmx_[A-Za-z0-9_-]{16,}\b/g, label: "[redacted token]" },
{
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/g,
label: "[redacted token]",
},
{
pattern: /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
label: "Bearer [redacted]",
},
];
export const REDACTED = "[redacted]";
function isSecretKey(key: string): boolean {
if (SECRET_KEY_PATTERN.test(key)) return true;
const lower = key.toLowerCase();
return SECRET_KEY_SUBSTRINGS.some((marker) => lower.includes(marker));
}
export function redactString(value: string): string {
let output = value;
for (const { pattern, label } of VALUE_PATTERNS) {
output = output.replace(pattern, label);
}
return output;
}
/**
* Recursively drops secret-named fields and masks secret-shaped values.
* Depth is bounded so a cyclic or pathological structure cannot hang the loop.
*/
export function redact(value: unknown, depth = 0): unknown {
if (depth > 12) return REDACTED;
if (typeof value === "string") return redactString(value);
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) {
return value.map((entry) => redact(entry, depth + 1));
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (isSecretKey(key)) {
// Preserve the shape so the model can still reason about presence,
// without ever seeing the value.
output[key] = entry === null || entry === undefined ? null : REDACTED;
continue;
}
output[key] = redact(entry, depth + 1);
}
return output;
}
/** Convenience wrapper for serializing a tool result. */
export function redactToJson(value: unknown): string {
return JSON.stringify(redact(value));
}
+68
View File
@@ -0,0 +1,68 @@
import { proposeTools } from "./propose-tools.js";
import { readTools } from "./read-tools.js";
import type { AiTool, ToolDefinitionShape } from "./types.js";
/**
* The allowlist, and the security boundary for the whole feature.
*
* A model can only ever invoke what appears here. This matters more than usual
* in this codebase: PermissionManager.requirePermission exists but is currently
* mounted on zero routes, so RBAC strings are a vocabulary for the admin role
* editor rather than route enforcement. "The assistant cannot reach credentials
* or user administration" is therefore a property of this list, not of the
* permission system.
*
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
* SSO or instance settings is deliberately absent and must stay absent.
*/
export const AI_TOOLS: AiTool[] = [...readTools, ...proposeTools];
const BY_NAME = new Map(AI_TOOLS.map((tool) => [tool.name, tool]));
export function getTool(name: string): AiTool | undefined {
return BY_NAME.get(name);
}
export function listToolNames(): string[] {
return AI_TOOLS.map((tool) => tool.name);
}
/**
* Domains the assistant must never be able to reach, in any tool, ever.
* The catalog test asserts no tool name references these.
*/
export const FORBIDDEN_DOMAINS = [
"credential",
"vault",
"rbac",
"role",
"permission",
"user_admin",
"password",
"totp",
"webauthn",
"passkey",
"api_key",
"session",
"oidc",
"sso",
"ldap",
"termix_id",
"identity",
"certificate",
"opkssh",
"acme",
"ssl",
"audit",
"sync",
"settings",
];
/** Tool definitions in the shape the provider adapters expect. */
export function toolDefinitions(): ToolDefinitionShape[] {
return AI_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
}));
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Which commands the assistant may run without a per-command approval click,
* for users who opted into read-only execution.
*
* The check parses the command into arguments and matches the resolved binary
* against the allowlist. Substring matching would be trivially defeated
* ("df; rm -rf /" contains "df"), so any shell metacharacter that could chain,
* redirect or substitute a second command rejects the whole string outright.
*/
export const READ_ONLY_COMMANDS = new Set([
"df",
"du",
"free",
"uptime",
"uname",
"whoami",
"hostname",
"id",
"ps",
"top",
"systemctl",
"journalctl",
"docker",
"ip",
"ss",
"netstat",
"lsblk",
"cat",
"ls",
"stat",
"which",
"date",
"lscpu",
"vmstat",
"iostat",
]);
/** Characters that let one command become several. */
const SHELL_METACHARACTERS = /[;&|`$><\n\r\\]/;
/** Subcommands that are safe for otherwise-powerful binaries. */
const SUBCOMMAND_ALLOWLIST: Record<string, Set<string>> = {
systemctl: new Set([
"status",
"list-units",
"list-unit-files",
"is-active",
"is-enabled",
"show",
]),
docker: new Set([
"ps",
"stats",
"images",
"logs",
"inspect",
"version",
"info",
]),
ip: new Set(["a", "addr", "link", "route", "neigh"]),
};
/** Paths `cat` may read. Anything else could disclose credentials. */
const CAT_ALLOWED_PREFIXES = ["/proc/", "/sys/", "/etc/os-release"];
export interface CommandCheck {
allowed: boolean;
reason?: string;
}
export function isReadOnlyCommand(raw: string): CommandCheck {
const command = raw.trim();
if (!command) return { allowed: false, reason: "Empty command" };
if (SHELL_METACHARACTERS.test(command)) {
return {
allowed: false,
reason: "Command chaining, redirection and substitution are not allowed",
};
}
const parts = command.split(/\s+/).filter(Boolean);
if (!parts.length) return { allowed: false, reason: "Empty command" };
// Reject env-prefixed and privilege-escalating forms outright.
const head = parts[0];
if (head === "sudo" || head === "su" || head === "doas" || head === "env") {
return { allowed: false, reason: `${head} is not allowed` };
}
// A path like /usr/bin/df resolves to its basename.
const binary = head.includes("/")
? head.slice(head.lastIndexOf("/") + 1)
: head;
if (!READ_ONLY_COMMANDS.has(binary)) {
return {
allowed: false,
reason: `${binary} is not on the read-only allowlist`,
};
}
const allowedSubcommands = SUBCOMMAND_ALLOWLIST[binary];
if (allowedSubcommands) {
const subcommand = parts.slice(1).find((part) => !part.startsWith("-"));
if (!subcommand || !allowedSubcommands.has(subcommand)) {
return {
allowed: false,
reason:
`${binary} ${subcommand ?? ""}`.trim() +
" is not on the read-only allowlist",
};
}
}
if (binary === "cat") {
const targets = parts.slice(1).filter((part) => !part.startsWith("-"));
if (!targets.length) {
return { allowed: false, reason: "cat needs a file path" };
}
for (const target of targets) {
const permitted = CAT_ALLOWED_PREFIXES.some((prefix) =>
prefix.endsWith("/") ? target.startsWith(prefix) : target === prefix,
);
if (!permitted) {
return {
allowed: false,
reason: `cat is limited to ${CAT_ALLOWED_PREFIXES.join(", ")}`,
};
}
}
}
return { allowed: true };
}
+332
View File
@@ -0,0 +1,332 @@
import {
createCurrentAlertRepository,
createCurrentAutomationRepository,
createCurrentFleetRepository,
createCurrentHostRepository,
createCurrentSnippetRepository,
} from "../../database/repositories/factory.js";
import { validateDefinition } from "../../database/routes/automations.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { execCommand } from "../../hosts/metrics/widgets/common-utils.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../hosts/ssh-client-factory.js";
import { withConnection } from "../../hosts/ssh-connection-pool.js";
import { getTool } from "./catalog.js";
/** Approved commands get a bounded window rather than hanging the request. */
const COMMAND_TIMEOUT_MS = 60_000;
/**
* Applies an approved proposal.
*
* The stored payload is treated as untrusted input even though the server wrote
* it: the proposal could have sat in the table across a release, and defending
* the apply path rather than the create path means one place to get right.
* Everything goes through the same repositories a human action uses, scoped to
* the approving user.
*/
export interface ApplyResult {
ok: boolean;
summary: string;
}
function requireNumber(value: unknown, field: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return parsed;
}
function requireString(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} is required`);
}
return value.trim();
}
function optionalString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export async function applyProposal(
kind: string,
payload: Record<string, unknown>,
userId: string,
): Promise<ApplyResult> {
// A payload whose tool no longer exists is refused rather than guessed at.
if (!getTool(kind)) {
throw new Error(`Unknown proposal kind: ${kind}`);
}
switch (kind) {
case "propose_create_host": {
const created = await createCurrentHostRepository().create({
userId,
name: requireString(payload.name, "name"),
ip: requireString(payload.ip, "ip"),
port: Number(payload.port) || 22,
username: optionalString(payload.username) ?? "",
folder: optionalString(payload.folder) ?? "",
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
} as any);
return {
ok: true,
summary: `Created host ${(created as any).name ?? ""}`.trim(),
};
}
case "propose_update_host": {
const hostId = requireNumber(payload.hostId, "hostId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!existing) throw new Error("Host not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.ip !== undefined)
updates.ip = requireString(changes.ip, "ip");
if (changes.port !== undefined) updates.port = Number(changes.port);
if (changes.username !== undefined)
updates.username = optionalString(changes.username) ?? "";
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder) ?? "";
if (changes.tags !== undefined) {
updates.tags = JSON.stringify(
Array.isArray(changes.tags) ? changes.tags : [],
);
}
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await createCurrentHostRepository().updateForUser(
userId,
hostId,
updates as any,
);
return { ok: true, summary: `Updated host ${hostId}` };
}
case "propose_delete_host": {
const hostId = requireNumber(payload.hostId, "hostId");
const deleted = await createCurrentHostRepository().deleteForUser(
userId,
hostId,
);
if (!deleted) throw new Error("Host not found");
return { ok: true, summary: `Deleted host ${hostId}` };
}
case "propose_create_snippet": {
const created = await createCurrentSnippetRepository().createSnippet(
userId,
{
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
} as any,
);
return {
ok: true,
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
};
}
case "propose_update_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!existing) throw new Error("Snippet not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.content !== undefined)
updates.content = requireString(changes.content, "content");
if (changes.description !== undefined)
updates.description = optionalString(changes.description);
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder);
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await createCurrentSnippetRepository().updateSnippet(
userId,
snippetId,
updates as any,
);
return { ok: true, summary: `Updated snippet ${snippetId}` };
}
case "propose_delete_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const deleted = await createCurrentSnippetRepository().deleteSnippet(
userId,
snippetId,
);
if (!deleted) throw new Error("Snippet not found");
return { ok: true, summary: `Deleted snippet ${snippetId}` };
}
case "propose_create_fleet": {
const fleet = await createCurrentFleetRepository().create(userId, {
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
} as any);
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
let added = 0;
for (const raw of hostIds) {
const hostId = Number(raw);
if (!Number.isInteger(hostId) || hostId <= 0) continue;
// Only hosts the approving user owns can join their fleet.
const host = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!host) continue;
await createCurrentFleetRepository().addMember(
(fleet as any).id,
hostId,
);
added += 1;
}
return {
ok: true,
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
};
}
case "propose_create_alert_rule": {
const created = await createCurrentAlertRepository().createAlertRule({
userId,
name: requireString(payload.name, "name"),
hostId:
payload.hostId === null || payload.hostId === undefined
? null
: requireNumber(payload.hostId, "hostId"),
enabled: true,
triggerType: requireString(payload.triggerType, "triggerType"),
thresholdValue:
payload.thresholdValue === null ||
payload.thresholdValue === undefined
? null
: Number(payload.thresholdValue),
thresholdDurationSeconds:
payload.thresholdDurationSeconds === null ||
payload.thresholdDurationSeconds === undefined
? null
: Number(payload.thresholdDurationSeconds),
cooldownMinutes: Number(payload.cooldownMinutes) || 15,
channelIds: [],
} as any);
return {
ok: true,
summary: `Created alert rule ${(created as any)?.name ?? ""}`.trim(),
};
}
case "propose_create_automation": {
// Reuses the same validator the automations route runs, so an
// LLM-authored definition is held to exactly the human standard.
const validation = validateDefinition(payload.definition);
if (!validation.ok || !validation.definition) {
throw new Error(
validation.error ?? "The automation definition is invalid",
);
}
const created = await createCurrentAutomationRepository().create({
userId,
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
definition: JSON.stringify(validation.definition),
// Starts disabled: an automation the user has not watched run once
// should not begin firing against their servers on approval.
enabled: false,
});
return {
ok: true,
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
};
}
case "propose_run_command": {
const hostId = requireNumber(payload.hostId, "hostId");
const command = requireString(payload.command, "command");
// resolveHostById, not the raw repository row: it runs the connect-level
// permission check, decrypts auth under the owner's key, and resolves the
// jump host chain. A plain row has none of that, so the SSH factory saw
// an unresolved jumpHosts field and failed the connection.
const host = await resolveHostById(hostId, userId);
if (!host) throw new Error("Host not found");
const result = await runCommandOnHost(host, command);
if (result.error) {
throw new Error(result.error);
}
return {
ok: true,
summary: result.output?.slice(0, 2000) ?? "(no output)",
};
}
default:
throw new Error(`Proposal kind ${kind} cannot be applied automatically`);
}
}
/**
* Runs one approved command over the shared SSH pool, mirroring how an
* automation run_command step executes.
*/
async function runCommandOnHost(
host: Record<string, any>,
command: string,
): Promise<{ output?: string; error?: string }> {
try {
const result = await withConnection(
getFleetPoolKey(host as any),
createFleetSshFactory(host as any),
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
);
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
return { output: output || "(no output)" };
}
return { error: `Exited with code ${result.code}: ${output}`.trim() };
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Kinds the route handles itself rather than through applyProposal.
* Empty: everything the assistant can propose can now be applied.
*/
export const ROUTE_APPLIED_KINDS = new Set<string>();
+279
View File
@@ -0,0 +1,279 @@
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
/**
* Propose tools never mutate anything. They return a draft that is stored as a
* pending proposal and rendered as a card; the change happens only when the
* user approves it, and the payload is re-validated at that point.
*/
export const proposeTools: AiTool[] = [
{
name: "propose_create_host",
description:
"Propose adding a new SSH host. Never include credentials: the user attaches those themselves after approving.",
category: "propose",
parameters: objectSchema(
{
name: str("Display name for the host"),
ip: str("Hostname or IP address"),
port: num("SSH port (defaults to 22)"),
username: str("SSH username"),
folder: str("Folder to file the host under"),
tags: {
type: "array",
items: { type: "string" },
description: "Tags to apply",
},
},
["name", "ip"],
),
handler: async (args) =>
proposal("propose_create_host", `Add host ${String(args.name)}`, {
name: args.name,
ip: args.ip,
port: args.port ?? 22,
username: args.username ?? null,
folder: args.folder ?? null,
tags: args.tags ?? [],
}),
},
{
name: "propose_update_host",
description:
"Propose changing an existing host's non-secret settings. Only include the fields that should change.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to update"),
name: str("New display name"),
ip: str("New hostname or IP address"),
port: num("New SSH port"),
username: str("New SSH username"),
folder: str("New folder"),
tags: {
type: "array",
items: { type: "string" },
description: "Replacement tag list",
},
},
["hostId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of [
"name",
"ip",
"port",
"username",
"folder",
"tags",
]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_host",
`Update host ${String(args.hostId)}`,
{ hostId: args.hostId, changes },
);
},
},
{
name: "propose_delete_host",
description:
"Propose removing a host. Use sparingly and explain why in the reason.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to delete"),
reason: str("Why this host should be removed"),
},
["hostId", "reason"],
),
handler: async (args) =>
proposal("propose_delete_host", `Delete host ${String(args.hostId)}`, {
hostId: args.hostId,
reason: args.reason,
}),
},
{
name: "propose_create_snippet",
description:
"Propose saving a new command snippet the user can run against their hosts.",
category: "propose",
parameters: objectSchema(
{
name: str("Snippet name"),
content: str("The command text"),
description: str("What the snippet does"),
folder: str("Folder to file it under"),
},
["name", "content"],
),
handler: async (args) =>
proposal(
"propose_create_snippet",
`Create snippet ${String(args.name)}`,
{
name: args.name,
content: args.content,
description: args.description ?? null,
folder: args.folder ?? null,
},
),
},
{
name: "propose_update_snippet",
description: "Propose editing an existing snippet.",
category: "propose",
parameters: objectSchema(
{
snippetId: num("The snippet id to update"),
name: str("New name"),
content: str("New command text"),
description: str("New description"),
folder: str("New folder"),
},
["snippetId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of ["name", "content", "description", "folder"]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_snippet",
`Update snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, changes },
);
},
},
{
name: "propose_delete_snippet",
description: "Propose deleting a snippet.",
category: "propose",
parameters: objectSchema(
{
snippetId: num("The snippet id to delete"),
reason: str("Why this snippet should be removed"),
},
["snippetId", "reason"],
),
handler: async (args) =>
proposal(
"propose_delete_snippet",
`Delete snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, reason: args.reason },
),
},
{
name: "propose_create_automation",
description:
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
category: "propose",
parameters: objectSchema(
{
name: str("Automation name"),
description: str("What the automation does"),
definition: {
type: "object",
description:
"The AutomationDefinition: { trigger: {...}, steps: [...] }",
},
},
["name", "definition"],
),
handler: async (args) =>
proposal(
"propose_create_automation",
`Create automation ${String(args.name)}`,
{
name: args.name,
description: args.description ?? null,
definition: args.definition,
},
),
},
{
name: "propose_create_fleet",
description: "Propose grouping hosts into a new fleet.",
category: "propose",
parameters: objectSchema(
{
name: str("Fleet name"),
description: str("What this fleet is for"),
hostIds: {
type: "array",
items: { type: "number" },
description: "Host ids to add as members",
},
},
["name"],
),
handler: async (args) =>
proposal("propose_create_fleet", `Create fleet ${String(args.name)}`, {
name: args.name,
description: args.description ?? null,
hostIds: args.hostIds ?? [],
}),
},
{
name: "propose_create_alert_rule",
description:
"Propose a new alert rule that fires when a host metric crosses a threshold.",
category: "propose",
parameters: objectSchema(
{
name: str("Rule name"),
hostId: num("Host id to watch, or omit to watch all hosts"),
triggerType: str(
"What to watch, for example cpu, memory, disk or host_status",
),
thresholdValue: num("Threshold to compare against"),
thresholdDurationSeconds: num(
"How long the breach must persist before firing",
),
cooldownMinutes: num("Minimum minutes between repeat firings"),
},
["name", "triggerType"],
),
handler: async (args) =>
proposal(
"propose_create_alert_rule",
`Create alert rule ${String(args.name)}`,
{
name: args.name,
hostId: args.hostId ?? null,
triggerType: args.triggerType,
thresholdValue: args.thresholdValue ?? null,
thresholdDurationSeconds: args.thresholdDurationSeconds ?? null,
cooldownMinutes: args.cooldownMinutes ?? 15,
},
),
},
{
name: "propose_run_command",
description:
"Propose running a command on a host. The user reviews and approves it before it runs. Use this for anything that changes state; read-only diagnostics may run directly if the user has enabled that.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to run on"),
command: str("The exact command to run"),
explanation: str("What the command does and why it is needed"),
},
["hostId", "command", "explanation"],
),
handler: async (args) =>
proposal(
"propose_run_command",
// Deliberately short: the card renders the command in its own block,
// so repeating it here made every card twice as tall as it needed.
`Run a command on host ${String(args.hostId)}`,
{
hostId: args.hostId,
command: args.command,
explanation: args.explanation,
},
),
},
];
+331
View File
@@ -0,0 +1,331 @@
import {
createCurrentAlertRepository,
createCurrentAutomationRepository,
createCurrentCommandHistoryRepository,
createCurrentFleetRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
createCurrentNetworkTopologyRepository,
createCurrentSnippetRepository,
createCurrentWorkspaceRepository,
} from "../../database/repositories/factory.js";
import { num, objectSchema, type AiTool } from "./types.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
* afterwards as a second line of defense, but the projection here is the
* primary control: a field that is never selected cannot leak.
*/
interface HostSummary {
id: number;
name: string | null;
ip: string | null;
port: number | null;
username: string | null;
folder: string | null;
tags: unknown;
protocol: string | null;
enableTerminal: boolean | null;
enableFileManager: boolean | null;
enableTunnel: boolean | null;
enableDocker: boolean | null;
}
function toHostSummary(host: Record<string, any>): HostSummary {
return {
id: host.id,
name: host.name ?? null,
ip: host.ip ?? null,
port: host.port ?? null,
username: host.username ?? null,
folder: host.folder ?? null,
tags: host.tags ?? null,
protocol: host.protocol ?? null,
enableTerminal: host.enableTerminal ?? null,
enableFileManager: host.enableFileManager ?? null,
enableTunnel: host.enableTunnel ?? null,
enableDocker: host.enableDocker ?? null,
};
}
export const readTools: AiTool[] = [
{
name: "list_hosts",
description:
"List the user's SSH hosts with their names, addresses, folders and tags. Never returns passwords or keys. Call this before proposing anything that references a host.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const hosts = await createCurrentHostRepository().listByUserId(
context.userId,
);
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
},
},
{
name: "get_host",
description:
"Get one host's non-secret configuration by id. Use it to inspect settings before proposing an update.",
category: "read",
parameters: objectSchema(
{ hostId: num("The host id, as returned by list_hosts") },
["hostId"],
),
handler: async (args, context) => {
const hostId = Number(args.hostId);
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
if (!host) return { error: "Host not found" };
return { host: toHostSummary(host as any) };
},
},
{
name: "list_fleets",
description:
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const fleets = await createCurrentFleetRepository().listByUser(
context.userId,
);
return {
fleets: fleets.map((fleet: any) => ({
id: fleet.id,
name: fleet.name,
description: fleet.description ?? null,
})),
};
},
},
{
name: "list_snippets",
description:
"List the user's saved command snippets, including their folder and the command text.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
context.userId,
);
return {
snippets: snippets.map((snippet: any) => ({
id: snippet.id,
name: snippet.name,
content: snippet.content,
folder: snippet.folder ?? null,
description: snippet.description ?? null,
})),
};
},
},
{
name: "list_automations",
description:
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const automations = await createCurrentAutomationRepository().list(
context.userId,
);
return {
automations: automations.map((automation: any) => ({
id: automation.id,
name: automation.name,
description: automation.description ?? null,
enabled: automation.enabled,
lastRunAt: automation.lastRunAt ?? null,
lastRunStatus: automation.lastRunStatus ?? null,
})),
};
},
},
{
name: "get_automation",
description:
"Get one automation's full definition (trigger and steps) by id.",
category: "read",
parameters: objectSchema(
{
automationId: num("The automation id, as returned by list_automations"),
},
["automationId"],
),
handler: async (args, context) => {
const automation = await createCurrentAutomationRepository().findForUser(
Number(args.automationId),
context.userId,
);
if (!automation) return { error: "Automation not found" };
return {
automation: {
id: (automation as any).id,
name: (automation as any).name,
enabled: (automation as any).enabled,
definition: (automation as any).definition,
},
};
},
},
{
name: "list_workspaces",
description:
"List the user's saved workspace layouts (named sets of open tabs and splits).",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const workspaces = await createCurrentWorkspaceRepository().listByUser(
context.userId,
);
return {
workspaces: workspaces.map((workspace: any) => ({
id: workspace.id,
name: workspace.name,
isDefault: workspace.isDefault ?? false,
})),
};
},
},
{
name: "list_alert_rules",
description:
"List the user's alert rules with their thresholds and enabled state.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const rules = await createCurrentAlertRepository().listAlertRules(
context.userId,
);
return {
rules: rules.map((rule) => ({
id: rule.id,
name: rule.name,
hostId: rule.host_id,
enabled: rule.enabled === 1,
triggerType: rule.trigger_type,
thresholdValue: rule.threshold_value,
thresholdDurationSeconds: rule.threshold_duration_seconds,
cooldownMinutes: rule.cooldown_minutes,
})),
};
},
},
{
name: "list_notification_channels",
description:
"List the user's notification channels by id, name and type. Channel configuration is never returned because it holds tokens.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const channels =
await createCurrentAlertRepository().listNotificationChannels(
context.userId,
);
return {
channels: channels.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
enabled: channel.enabled === 1,
})),
};
},
},
{
name: "get_alert_firings",
description:
"Recent alert firings, newest first. Use this to answer questions about what has been alerting.",
category: "read",
parameters: objectSchema({
limit: num("How many firings to return (default 25, max 100)"),
}),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const result = await createCurrentAlertRepository().listAlertFirings({
userId: context.userId,
limit,
offset: 0,
});
return {
firings: (result.firings ?? []).map((firing) => ({
id: firing.id,
ruleName: firing.rule_name,
hostName: firing.host_name,
firedAt: firing.fired_at,
resolvedAt: firing.resolved_at,
severity: firing.severity,
message: firing.message,
acknowledged: firing.acknowledged === 1,
})),
};
},
},
{
name: "list_homepage_items",
description: "List the user's homepage service-link tiles.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const items = await createCurrentHomepageItemRepository().listByUserId(
context.userId,
);
return {
items: (items as any[]).map((item) => ({
id: item.id,
typeId: item.typeId,
title: item.title ?? null,
})),
};
},
},
{
name: "get_command_history",
description:
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
category: "read",
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
limit: num("How many entries to return (default 25, max 100)"),
},
["hostId"],
),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const hostId = Number(args.hostId);
// Ownership is enforced here rather than trusted from the model.
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
if (!host) return { error: "Host not found" };
const commands =
await createCurrentCommandHistoryRepository().listCommandsForHost(
context.userId,
hostId,
limit,
);
return { commands };
},
},
{
name: "get_network_topology",
description: "The user's saved network topology graph, if they have one.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const topology =
await createCurrentNetworkTopologyRepository().findByUserId(
context.userId,
);
if (!topology) return { topology: null };
return { topology: (topology as any).data ?? null };
},
},
];
+72
View File
@@ -0,0 +1,72 @@
export type ToolCategory = "read" | "propose";
export interface ToolContext {
/** Always taken from the verified JWT, never from model input. */
userId: string;
conversationId: number;
/** Per-user opt-in for running allowlisted read-only commands. */
allowReadOnlyCommands: boolean;
}
export interface AiTool {
name: string;
description: string;
category: ToolCategory;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
* Read tools return data to feed back to the model. Propose tools return a
* ProposalDraft and must not mutate anything.
*/
handler: (
args: Record<string, unknown>,
context: ToolContext,
) => Promise<unknown>;
}
/** What a provider adapter needs to describe a tool to its model. */
export interface ToolDefinitionShape {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ProposalDraft {
__proposal: true;
kind: string;
summary: string;
payload: Record<string, unknown>;
}
export function isProposalDraft(value: unknown): value is ProposalDraft {
return (
typeof value === "object" &&
value !== null &&
(value as ProposalDraft).__proposal === true
);
}
export function proposal(
kind: string,
summary: string,
payload: Record<string, unknown>,
): ProposalDraft {
return { __proposal: true, kind, summary, payload };
}
/** Small helper so tool schemas stay readable. */
export function objectSchema(
properties: Record<string, unknown>,
required: string[] = [],
): Record<string, unknown> {
return {
type: "object",
properties,
required,
additionalProperties: false,
};
}
export const str = (description: string) => ({ type: "string", description });
export const num = (description: string) => ({ type: "number", description });
export const bool = (description: string) => ({ type: "boolean", description });
@@ -0,0 +1,90 @@
import type { HostSelector } from "../../../types/automations.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { createCurrentFleetRepository } from "../../database/repositories/factory.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import type { StepExecutionContext } from "./types.js";
/** A host the caller is allowed to act on. `host` is always resolved. */
export interface ResolvedTarget {
id: number;
name: string;
host: NonNullable<Awaited<ReturnType<typeof resolveHostById>>>;
}
/**
* Turns a selector into the hosts a step may actually act on.
*
* Access is checked here, at execution time rather than when the automation
* was saved, so a permission revoked after the fact takes effect on the next
* run. resolveHostById performs its own connect-level check and returns null
* when the owner can no longer reach the host.
*/
export async function resolveTargets(
selector: HostSelector,
context: StepExecutionContext,
): Promise<{ targets: ResolvedTarget[]; skipped: number[] }> {
const ids = await selectorHostIds(selector, context);
const targets: ResolvedTarget[] = [];
const skipped: number[] = [];
for (const id of ids) {
const host = await resolveHostById(id, context.userId);
if (!host) {
skipped.push(id);
continue;
}
targets.push({ id, name: host.name || host.ip, host });
}
return { targets, skipped };
}
async function selectorHostIds(
selector: HostSelector,
context: StepExecutionContext,
): Promise<number[]> {
switch (selector.kind) {
case "host":
return [selector.hostId];
case "hosts":
return selector.hostIds;
case "trigger":
return context.triggerHostId ? [context.triggerHostId] : [];
case "fleet":
return fleetHostIds(selector.fleetId, context.userId);
case "all":
return allAccessibleHostIds(context.userId);
default:
return [];
}
}
async function fleetHostIds(
fleetId: number,
userId: string,
): Promise<number[]> {
try {
const repository = createCurrentFleetRepository();
const members = await repository.listEffectiveMembers(userId, fleetId);
return members.map((member) => member.id);
} catch {
return [];
}
}
async function allAccessibleHostIds(userId: string): Promise<number[]> {
try {
const { createCurrentHostRepository } =
await import("../../database/repositories/factory.js");
const hosts = await createCurrentHostRepository().listByUserId(userId);
const ids = hosts.map((host) => host.id);
const allowed =
await PermissionManager.getInstance().filterAccessibleHostIds(
userId,
ids,
);
return ids.filter((id) => allowed.has(id));
} catch {
return [];
}
}
+406
View File
@@ -0,0 +1,406 @@
import {
DEFAULT_STEP_TIMEOUT_MS,
type Step,
} from "../../../types/automations.js";
import { execCommand } from "../../hosts/metrics/widgets/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../hosts/metrics/managers/exec-elevated.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../hosts/ssh-client-factory.js";
import { withConnection } from "../../hosts/ssh-connection-pool.js";
import { resolveSnippetCommand } from "../../database/routes/snippets-execution.js";
import {
createCurrentAlertRepository,
createCurrentSnippetRepository,
} from "../../database/repositories/factory.js";
import { sendAutomationNotification } from "../notify.js";
import { automationFetch } from "../http.js";
import { renderRecord, renderTemplate } from "../template.js";
import { resolveTargets, type ResolvedTarget } from "./host-targets.js";
import {
fail,
ok,
stepTimeout,
type StepExecutionContext,
type StepResult,
} from "./types.js";
/**
* One executor per step type.
*
* Anything that leaves Termix checks context.dryRun first and reports what it
* would have done instead of doing it, so an automation can be exercised
* safely while it is being built.
*/
export async function executeStep(
step: Step,
context: StepExecutionContext,
): Promise<StepResult> {
switch (step.type) {
case "notify":
return runNotify(step, context);
case "http":
return runHttp(step, context);
case "run_command":
return runCommand(step, context);
case "run_snippet":
return runSnippet(step, context);
case "docker":
return runDocker(step, context);
case "tunnel":
return runTunnel(step, context);
case "wol":
return runWol(step, context);
case "wait":
return runWait(step, context);
case "set_var":
return runSetVar(step, context);
case "stop":
return {
success: true,
halt: { status: step.status ?? "success" },
output: `Stopped with status ${step.status ?? "success"}`,
};
default:
return fail(`Unsupported step type: ${(step as Step).type}`);
}
}
async function runNotify(
step: Extract<Step, { type: "notify" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const title = renderTemplate(step.title ?? "", context.template);
const body = renderTemplate(step.body ?? "", context.template);
if (step.channelIds.length === 0) {
return fail("No notification channels selected");
}
if (context.dryRun) {
return ok(
`Would notify ${step.channelIds.length} channel(s): ${title || body}`,
);
}
const repository = createCurrentAlertRepository();
const channels = await repository.listNotificationChannels(context.userId);
const selected = channels.filter((channel) =>
step.channelIds.includes(channel.id),
);
if (selected.length === 0) {
return fail("Selected notification channels no longer exist");
}
let delivered = 0;
const errors: string[] = [];
for (const channel of selected) {
if (!channel.enabled) continue;
try {
await sendAutomationNotification(
{ id: channel.id, type: channel.type, config: channel.config },
{
title,
body,
severity: step.severity ?? "warning",
context: context.template,
},
);
delivered++;
} catch (error) {
errors.push(
`${channel.name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
if (delivered === 0) {
return fail(errors.join("; ") || "No enabled channels to notify");
}
return ok(
`Notified ${delivered} channel(s)${errors.length ? `; ${errors.join("; ")}` : ""}`,
);
}
async function runHttp(
step: Extract<Step, { type: "http" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const url = renderTemplate(step.url, context.template);
const headers = renderRecord(step.headers, context.template);
const body = step.body
? renderTemplate(step.body, context.template)
: undefined;
if (context.dryRun) {
return ok(`Would ${step.method} ${url}`);
}
try {
const response = await automationFetch(url, {
method: step.method,
headers,
body,
allowPrivateNetwork: step.allowPrivateNetwork,
timeoutMs: stepTimeout(context, step.timeoutMs, DEFAULT_STEP_TIMEOUT_MS),
});
const text = await response.text();
const summary = `HTTP ${response.status} ${response.statusText}\n${text}`;
return response.ok ? ok(summary) : fail(`HTTP ${response.status}`, summary);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
}
async function runCommand(
step: Extract<Step, { type: "run_command" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const command = renderTemplate(step.command, context.template);
return runOnTargets(step.hostSelector, context, async (target) => {
if (context.dryRun) {
return { output: `Would run on ${target.name}: ${command}` };
}
return execOnHost(
target.host,
command,
step.elevated,
context,
step.timeoutMs,
);
});
}
async function runSnippet(
step: Extract<Step, { type: "run_snippet" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const snippet = await createCurrentSnippetRepository()
.findOwnedById(context.userId, step.snippetId)
.catch(() => null);
if (!snippet) return fail("Snippet not found");
if (snippet.isNote) return fail("Notes cannot be executed on a host");
// Template values only ever reach the snippet through inputValues, never by
// rewriting the snippet body, so automation variables cannot inject snippet
// syntax of their own.
const inputValues = renderRecord(step.inputValues, context.template) ?? {};
return runOnTargets(step.hostSelector, context, async (target) => {
const command = resolveSnippetCommand(
snippet.content,
{
ip: target.host.ip,
username: target.host.username,
port: target.host.port,
name: target.host.name,
},
inputValues,
);
if (context.dryRun) {
return { output: `Would run snippet on ${target.name}: ${command}` };
}
return execOnHost(
target.host,
command,
step.elevated,
context,
step.timeoutMs,
);
});
}
async function runDocker(
step: Extract<Step, { type: "docker" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const container = renderTemplate(step.container, context.template);
if (!container) return fail("Container name is required");
return runOnTargets(step.hostSelector, context, async (target) => {
if (context.dryRun) {
return {
output: `Would ${step.action} container ${container} on ${target.name}`,
};
}
// The Docker HTTP routes are tied to an interactive session, so run the
// equivalent command over the same pooled SSH connection everything else
// uses. The container name is quoted because it comes from a template.
const command = `docker ${step.action} ${shellSingleQuote(container)}`;
return execOnHost(target.host, command, false, context, step.timeoutMs);
});
}
async function runTunnel(
step: Extract<Step, { type: "tunnel" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const name = renderTemplate(step.tunnelName, context.template);
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
try {
const manager = await import("../../hosts/tunnel/manager.js");
const config = manager.tunnelConfigs?.get(name);
if (!config) return fail(`Tunnel "${name}" is not configured`);
if (step.action === "connect") {
await manager.connectSSHTunnel(config);
return ok(`Tunnel ${name} connected`);
}
// shouldRetry false, otherwise the manager immediately reconnects the
// tunnel the automation just asked it to drop.
manager.manualDisconnects.add(name);
await manager.handleDisconnect(name, config, false);
return ok(`Tunnel ${name} disconnected`);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
}
async function runWol(
step: Extract<Step, { type: "wol" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const host = await resolveTargets(
{ kind: "host", hostId: step.hostId },
context,
);
const target = host.targets[0];
if (!target?.host) return fail("Host not found or not accessible");
const mac = (target.host as { macAddress?: string }).macAddress;
if (!mac) return fail("Host has no MAC address configured");
if (context.dryRun) return ok(`Would wake ${target.name} (${mac})`);
try {
const { sendWakeOnLan, isValidMac } =
await import("../../utils/wake-on-lan.js");
if (!isValidMac(mac)) return fail(`Invalid MAC address: ${mac}`);
await sendWakeOnLan(mac);
return ok(`Sent magic packet to ${target.name}`);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
}
async function runWait(
step: Extract<Step, { type: "wait" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const requested = Math.max(step.seconds, 0) * 1000;
const waitMs = Math.min(
requested,
stepTimeout(context, undefined, requested),
);
if (context.dryRun) return ok(`Would wait ${step.seconds}s`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
return ok(`Waited ${Math.round(waitMs / 1000)}s`);
}
async function runSetVar(
step: Extract<Step, { type: "set_var" }>,
context: StepExecutionContext,
): Promise<StepResult> {
const value = renderTemplate(step.value, context.template);
return ok(`${step.name} = ${value}`, { [step.name]: value });
}
/**
* Runs a per-host action across a selector's targets. One host failing does
* not stop the others, matching how fleet execution behaves.
*/
async function runOnTargets(
selector: Extract<Step, { type: "run_command" }>["hostSelector"],
context: StepExecutionContext,
run: (target: ResolvedTarget) => Promise<{ output?: string; error?: string }>,
): Promise<StepResult> {
const { targets, skipped } = await resolveTargets(selector, context);
if (targets.length === 0) {
return fail(
skipped.length > 0
? `No accessible hosts (${skipped.length} skipped)`
: "No hosts matched the selector",
);
}
const results = await Promise.allSettled(
targets.map(async (target) => ({
target,
result: await run(target),
})),
);
const lines: string[] = [];
let failures = 0;
for (const [index, settled] of results.entries()) {
const name = targets[index].name;
if (settled.status === "rejected") {
failures++;
lines.push(`${name}: ${String(settled.reason)}`);
continue;
}
const { result } = settled.value;
if (result.error) {
failures++;
lines.push(`${name}: ${result.error}`);
} else {
lines.push(`${name}: ${result.output ?? "ok"}`);
}
}
if (skipped.length > 0) {
lines.push(`${skipped.length} host(s) skipped: no access`);
}
const output = lines.join("\n");
return failures > 0 && failures === targets.length
? fail(`All ${failures} host(s) failed`, output)
: ok(output);
}
async function execOnHost(
host: ResolvedTarget["host"],
command: string,
elevated: boolean | undefined,
context: StepExecutionContext,
timeoutMs: number | undefined,
): Promise<{ output?: string; error?: string }> {
const sshHost = host as ResolvedTarget["host"] & { sudoPassword?: string };
const timeout = stepTimeout(context, timeoutMs, DEFAULT_STEP_TIMEOUT_MS);
if (timeout <= 0) return { error: "Run deadline exceeded" };
try {
const result = await withConnection(
getFleetPoolKey(sshHost),
createFleetSshFactory(sshHost),
async (client) => {
if (elevated) {
return execElevated(client, command, sshHost.sudoPassword, {
timeoutMs: timeout,
});
}
return execCommand(client, command, timeout);
},
);
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
return { output: output || "(no output)" };
}
return { error: `Exited with code ${result.code}`, output };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Step } from "../../../types/automations.js";
import type { TemplateContext } from "../template.js";
/** What every executor returns. `output` is what later steps can read. */
export interface StepResult {
success: boolean;
output?: string;
error?: string;
/** Merged into the run's variables, for set_var and friends. */
vars?: Record<string, string>;
/** Set by the stop step to end the run early. */
halt?: { status: "success" | "failed" };
}
export interface StepExecutionContext {
userId: string;
automationId: number;
runId: number;
/** Nothing that leaves Termix may actually happen when this is set. */
dryRun: boolean;
template: TemplateContext;
/** Host the trigger fired for, when there was one. */
triggerHostId?: number;
/** Automations already on the stack, to refuse recursion. */
ancestry: number[];
depth: number;
/** Wall-clock deadline for the whole run. */
deadlineAt: number;
signal?: AbortSignal;
}
export type StepExecutor<T extends Step = Step> = (
step: T,
context: StepExecutionContext,
) => Promise<StepResult>;
export function ok(output?: string, vars?: Record<string, string>): StepResult {
return { success: true, output, vars };
}
export function fail(error: string, output?: string): StepResult {
return { success: false, error, output };
}
/** Milliseconds left before the run's overall deadline. */
export function remainingMs(context: StepExecutionContext): number {
return Math.max(context.deadlineAt - Date.now(), 0);
}
/** A step's timeout, clamped so it can never outlive the run. */
export function stepTimeout(
context: StepExecutionContext,
requested: number | undefined,
fallback: number,
): number {
const wanted = requested && requested > 0 ? requested : fallback;
return Math.max(Math.min(wanted, remainingMs(context)), 0);
}
+203
View File
@@ -0,0 +1,203 @@
import type { MetricPath, Operator } from "../../types/automations.js";
/**
* Operator evaluation and metric extraction.
*
* Pure: no database, no SSH, no clock. Everything here is driven by values the
* caller already has, which keeps the comparison rules testable on their own.
*/
/** Shape of the metrics snapshot this module reads. Mirrors collectMetrics(). */
export interface MetricsSnapshot {
cpu?: {
percent?: number | null;
load?: [number, number, number] | null;
} | null;
memory?: { percent?: number | null; usedGiB?: number | null } | null;
disk?: {
percent?: number | null;
filesystems?: Array<{
mount?: string;
percent?: number | null;
availableBytes?: number | null;
}> | null;
} | null;
network?: {
interfaces?: Array<{
name?: string;
rxBytes?: string | number | null;
txBytes?: string | number | null;
rxRateBps?: number | null;
txRateBps?: number | null;
}> | null;
} | null;
temperature?: { highestCelsius?: number | null } | null;
uptime?: { seconds?: number | null } | null;
processes?: { total?: number | null } | null;
}
function toNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Pulls the value a trigger watches out of a metrics snapshot.
*
* The mount and iface selectors are what let a rule watch one filesystem or
* one interface. Without a selector the aggregate is used, which for disk is
* the primary (root) mount, matching what the metrics UI shows.
*/
export function extractMetricValue(
metrics: MetricsSnapshot | null | undefined,
metric: MetricPath,
): number | null {
if (!metrics) return null;
switch (metric.path) {
case "cpu.percent":
return toNumber(metrics.cpu?.percent);
case "cpu.load1":
return toNumber(metrics.cpu?.load?.[0]);
case "cpu.load5":
return toNumber(metrics.cpu?.load?.[1]);
case "cpu.load15":
return toNumber(metrics.cpu?.load?.[2]);
case "memory.percent":
return toNumber(metrics.memory?.percent);
case "memory.usedGiB":
return toNumber(metrics.memory?.usedGiB);
case "disk.percent": {
if (!metric.mount) return toNumber(metrics.disk?.percent);
const fs = findMount(metrics, metric.mount);
return fs ? toNumber(fs.percent) : null;
}
case "disk.availableBytes": {
const fs = metric.mount ? findMount(metrics, metric.mount) : null;
if (metric.mount) return fs ? toNumber(fs.availableBytes) : null;
const first = metrics.disk?.filesystems?.[0];
return first ? toNumber(first.availableBytes) : null;
}
case "temperature.highestCelsius":
return toNumber(metrics.temperature?.highestCelsius);
case "uptime.seconds":
return toNumber(metrics.uptime?.seconds);
case "processes.total":
return toNumber(metrics.processes?.total);
case "network.rxBytes":
return toNumber(findInterface(metrics, metric.iface)?.rxBytes);
case "network.txBytes":
return toNumber(findInterface(metrics, metric.iface)?.txBytes);
case "network.rxRateBps":
return toNumber(findInterface(metrics, metric.iface)?.rxRateBps);
case "network.txRateBps":
return toNumber(findInterface(metrics, metric.iface)?.txRateBps);
default:
return null;
}
}
function findMount(metrics: MetricsSnapshot, mount: string) {
return (
metrics.disk?.filesystems?.find((entry) => entry.mount === mount) ?? null
);
}
function findInterface(metrics: MetricsSnapshot, iface?: string) {
const interfaces = metrics.network?.interfaces;
if (!interfaces || interfaces.length === 0) return null;
if (!iface) return interfaces[0];
return interfaces.find((entry) => entry.name === iface) ?? null;
}
/**
* The key a trigger's durable state is stored under. Including the mount or
* container is what allows a sustained-breach window per filesystem rather
* than per host.
*/
export function metricStateKey(hostId: number, metric: MetricPath): string {
if ("mount" in metric && metric.mount) return `${hostId}:${metric.mount}`;
if ("iface" in metric && metric.iface) return `${hostId}:${metric.iface}`;
return String(hostId);
}
/**
* Compares two values. Numeric when both sides look numeric, so "90" and 90
* behave the same; string comparison otherwise. `changed` is handled by the
* caller, which is the only place that knows the previous value.
*/
export function compare(
left: unknown,
operator: Operator,
right: unknown,
): boolean {
if (operator === "contains" || operator === "not_contains") {
const haystack = String(left ?? "");
const needle = String(right ?? "");
const found = haystack.includes(needle);
return operator === "contains" ? found : !found;
}
const leftNumber = toNumber(left);
const rightNumber = toNumber(right);
const numeric = leftNumber !== null && rightNumber !== null;
switch (operator) {
case ">":
return numeric && leftNumber > rightNumber;
case "<":
return numeric && leftNumber < rightNumber;
case ">=":
return numeric && leftNumber >= rightNumber;
case "<=":
return numeric && leftNumber <= rightNumber;
case "==":
return numeric
? leftNumber === rightNumber
: String(left ?? "") === String(right ?? "");
case "!=":
return numeric
? leftNumber !== rightNumber
: String(left ?? "") !== String(right ?? "");
case "changed":
return String(left ?? "") !== String(right ?? "");
default:
return false;
}
}
/** Whether a cooldown window is still open. */
export function isCoolingDown(
lastFiredAt: string | null | undefined,
cooldownMinutes: number,
now: number = Date.now(),
): boolean {
if (!lastFiredAt) return false;
const last = Date.parse(lastFiredAt);
if (Number.isNaN(last)) return false;
return now - last < Math.max(cooldownMinutes, 0) * 60_000;
}
/** Whether a sustained breach has been held long enough to fire. */
export function hasDwelled(
breachStartedAt: string | null | undefined,
forSeconds: number | undefined,
now: number = Date.now(),
): boolean {
if (!forSeconds) return true;
if (!breachStartedAt) return false;
const started = Date.parse(breachStartedAt);
if (Number.isNaN(started)) return false;
return now - started >= forSeconds * 1000;
}
/** Severity for a threshold breach, matching the old engine's behaviour. */
export function severityForValue(
value: number | null,
explicit?: "info" | "warning" | "critical",
): "info" | "warning" | "critical" {
if (explicit) return explicit;
if (value !== null && value >= 95) return "critical";
return "warning";
}
+291
View File
@@ -0,0 +1,291 @@
/**
* A small five field cron parser, used only to work out when a schedule is
* next due.
*
* Deliberately not a dependency: the engine needs "when is this next due?" and
* nothing else, and a pure function is far easier to test than a scheduler
* library. Fields are the standard minute, hour, day-of-month, month and
* day-of-week, supporting *, lists (1,2), ranges (1-5) and steps (a slash).
*
* Day-of-month and day-of-week follow cron's union rule: when both are
* restricted, a date matching either one matches.
*/
interface CronFields {
minutes: Set<number>;
hours: Set<number>;
daysOfMonth: Set<number>;
months: Set<number>;
daysOfWeek: Set<number>;
domRestricted: boolean;
dowRestricted: boolean;
}
const RANGES: Record<string, [number, number]> = {
minute: [0, 59],
hour: [0, 23],
dayOfMonth: [1, 31],
month: [1, 12],
// 7 is accepted as an alias for Sunday and folded to 0 once parsed.
dayOfWeek: [0, 7],
};
const NAMED_MONTHS: Record<string, number> = {
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
};
const NAMED_DAYS: Record<string, number> = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
function normalize(token: string, kind: string): string {
const lower = token.toLowerCase();
if (kind === "month" && lower in NAMED_MONTHS) {
return String(NAMED_MONTHS[lower]);
}
if (kind === "dayOfWeek" && lower in NAMED_DAYS) {
return String(NAMED_DAYS[lower]);
}
return token;
}
function parseField(field: string, kind: keyof typeof RANGES): Set<number> {
const [min, max] = RANGES[kind];
const values = new Set<number>();
for (const part of field.split(",")) {
const trimmed = part.trim();
if (!trimmed) throw new Error(`Empty value in ${kind} field`);
const [rangePart, stepPart] = trimmed.split("/");
const step = stepPart === undefined ? 1 : Number(stepPart);
if (!Number.isInteger(step) || step < 1) {
throw new Error(`Invalid step in ${kind} field`);
}
let start: number;
let end: number;
if (rangePart === "*" || rangePart === "") {
start = min;
end = max;
} else if (rangePart.includes("-")) {
const [from, to] = rangePart.split("-");
start = Number(normalize(from, kind));
end = Number(normalize(to, kind));
} else {
start = Number(normalize(rangePart, kind));
end = stepPart === undefined ? start : max;
}
if (!Number.isInteger(start) || !Number.isInteger(end)) {
throw new Error(`Invalid value in ${kind} field`);
}
if (start < min || end > max || start > end) {
throw new Error(`Value out of range in ${kind} field`);
}
for (let value = start; value <= end; value += step) {
// Sunday can be written as 7; cron treats it as 0.
values.add(kind === "dayOfWeek" && value === 7 ? 0 : value);
}
}
return values;
}
export function parseCron(expression: string): CronFields {
const fields = expression.trim().split(/\s+/);
if (fields.length !== 5) {
throw new Error("A cron expression needs five fields");
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
return {
minutes: parseField(minute, "minute"),
hours: parseField(hour, "hour"),
daysOfMonth: parseField(dayOfMonth, "dayOfMonth"),
months: parseField(month, "month"),
daysOfWeek: parseField(dayOfWeek, "dayOfWeek"),
domRestricted: dayOfMonth.trim() !== "*",
dowRestricted: dayOfWeek.trim() !== "*",
};
}
export function isValidCron(expression: string): boolean {
try {
parseCron(expression);
return true;
} catch {
return false;
}
}
/** Wall-clock fields for a moment, in a given zone or server local time. */
interface WallClock {
minute: number;
hour: number;
dayOfMonth: number;
month: number;
dayOfWeek: number;
}
const WEEKDAYS: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
const formatterCache = new Map<string, Intl.DateTimeFormat>();
/**
* Whether a zone name is one this runtime actually knows. An unknown zone
* falls back to server local time rather than throwing, so a bad value saved
* against a schedule cannot stop it from ever running.
*/
export function isValidTimezone(timezone: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
return true;
} catch {
return false;
}
}
function formatterFor(timezone: string): Intl.DateTimeFormat | null {
const cached = formatterCache.get(timezone);
if (cached) return cached;
try {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hour12: false,
weekday: "short",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
formatterCache.set(timezone, formatter);
return formatter;
} catch {
return null;
}
}
function wallClock(date: Date, timezone?: string | null): WallClock {
const formatter = timezone ? formatterFor(timezone) : null;
if (!formatter) {
return {
minute: date.getMinutes(),
hour: date.getHours(),
dayOfMonth: date.getDate(),
month: date.getMonth() + 1,
dayOfWeek: date.getDay(),
};
}
const parts: Record<string, string> = {};
for (const part of formatter.formatToParts(date)) {
parts[part.type] = part.value;
}
return {
// Midnight formats as 24 in some locales' hour-cycle handling.
minute: Number(parts.minute),
hour: Number(parts.hour) % 24,
dayOfMonth: Number(parts.day),
month: Number(parts.month),
dayOfWeek: WEEKDAYS[parts.weekday] ?? date.getDay(),
};
}
function matches(
fields: CronFields,
date: Date,
timezone?: string | null,
): boolean {
const clock = wallClock(date, timezone);
if (!fields.months.has(clock.month)) return false;
if (!fields.minutes.has(clock.minute)) return false;
if (!fields.hours.has(clock.hour)) return false;
const domMatch = fields.daysOfMonth.has(clock.dayOfMonth);
const dowMatch = fields.daysOfWeek.has(clock.dayOfWeek);
// Both restricted means either may match, which is how cron behaves.
if (fields.domRestricted && fields.dowRestricted) return domMatch || dowMatch;
if (fields.domRestricted) return domMatch;
if (fields.dowRestricted) return dowMatch;
return true;
}
/**
* The next time on or after `from` that the expression matches, or null when
* nothing matches within a four year window (e.g. Feb 30).
*/
export function nextCronRun(
expression: string,
from: Date = new Date(),
timezone?: string | null,
): Date | null {
const fields = parseCron(expression);
const candidate = new Date(from.getTime());
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
// Four years covers every leap year cycle, so a date that never matches
// gives up rather than looping.
const limit = 366 * 4 * 24 * 60;
for (let i = 0; i < limit; i++) {
if (matches(fields, candidate, timezone)) return candidate;
candidate.setMinutes(candidate.getMinutes() + 1);
}
return null;
}
/**
* Next due time for a schedule trigger, as an ISO string. Interval wins over
* cron when both are set, matching the editor which offers one or the other.
*/
export function computeNextDueAt(
schedule: {
cron?: string | null;
intervalSeconds?: number | null;
timezone?: string | null;
},
from: Date = new Date(),
): string | null {
if (schedule.intervalSeconds && schedule.intervalSeconds > 0) {
return new Date(
from.getTime() + schedule.intervalSeconds * 1000,
).toISOString();
}
if (schedule.cron) {
const next = nextCronRun(schedule.cron, from, schedule.timezone);
return next ? next.toISOString() : null;
}
return null;
}
+238
View File
@@ -0,0 +1,238 @@
import type { AutomationDefinition } from "../../types/automations.js";
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
import { resolveHostById } from "../hosts/host-resolver.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { execCommand } from "../hosts/metrics/widgets/common-utils.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../hosts/ssh-client-factory.js";
import { withConnection } from "../hosts/ssh-connection-pool.js";
import { statsLogger } from "../utils/logger.js";
import { onDockerEvent } from "./triggers.js";
/**
* Container state polling for docker_event triggers.
*
* Everything else Docker-related in the backend hangs off an interactive
* session that only exists while somebody has the UI open, so a trigger built
* on it would only ever fire while being watched. This polls over the same
* pooled SSH connection the other automation steps use, and only for hosts a
* docker_event trigger actually names, so an install with no such automation
* does no extra work at all.
*
* Events are derived by diffing successive snapshots: the poll interval is the
* resolution, so a container that stops and starts between two polls is not
* reported. That is the tradeoff for not holding a `docker events` stream open
* against every host.
*/
const POLL_INTERVAL_MS = 60_000;
const EXEC_TIMEOUT_MS = 15_000;
interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
/** Last snapshot per host, so transitions can be spotted. */
const snapshots = new Map<number, Map<string, ContainerState>>();
const lastPolledAt = new Map<number, number>();
/** Hosts named by an enabled docker_event trigger, with the owning user. */
export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
const watched = new Map<number, string>();
try {
const rows = await createCurrentAutomationRepository().listAllEnabled();
for (const row of rows) {
let definition: AutomationDefinition;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger = definition.trigger;
if (trigger?.kind !== "docker_event") continue;
const selector = trigger.hostSelector;
if (selector?.kind === "host") {
watched.set(selector.hostId, row.userId);
} else if (selector?.kind === "hosts") {
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
}
// Fleet and "all" selectors are deliberately not expanded: polling every
// host a user owns for container state is far too costly to do blindly.
}
} catch {
return watched;
}
return watched;
}
/**
* Parses `docker ps -a` output. One JSON object per line, matching the format
* string the container routes use.
*/
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* Works out which events a pair of snapshots implies.
*
* A container missing from the previous snapshot is treated as newly seen
* rather than started, so the first poll after a restart does not replay every
* running container as a fresh start event.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting")
events.push({ container: name, event: "restarting" });
}
// Health is independent of state: a container can go unhealthy while it
// stays up, which is exactly the case worth alerting on.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
const PS_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
async function pollHost(hostId: number, userId: string): Promise<void> {
const host = await resolveHostById(hostId, userId);
if (!host) {
snapshots.delete(hostId);
return;
}
const result = await withConnection(
getFleetPoolKey(host as never),
createFleetSshFactory(host as never),
(client) =>
execCommand(
client,
`docker ps -a --format ${PS_FORMAT}`,
EXEC_TIMEOUT_MS,
),
);
if (result.code !== 0 && result.code !== null) {
// Docker missing or not permitted on this host. Drop the snapshot so a
// later success is treated as a first observation rather than a diff.
snapshots.delete(hostId);
return;
}
const current = parseContainerStates(result.stdout);
const previous = snapshots.get(hostId);
snapshots.set(hostId, current);
if (!previous) return;
for (const { container, event } of diffContainerStates(previous, current)) {
await onDockerEvent({
hostId,
ownerUserId: userId,
container,
event,
}).catch(() => undefined);
}
}
/**
* Polls every watched host whose interval has elapsed. Called from the
* automation scheduler tick rather than owning a timer of its own.
*/
export async function pollDockerEvents(
now: number = Date.now(),
): Promise<void> {
const watched = await listDockerWatchedHosts();
for (const hostId of [...snapshots.keys()]) {
if (!watched.has(hostId)) {
snapshots.delete(hostId);
lastPolledAt.delete(hostId);
}
}
for (const [hostId, userId] of watched) {
const last = lastPolledAt.get(hostId) ?? 0;
if (now - last < POLL_INTERVAL_MS) continue;
// Host credentials cannot be decrypted while the owner's key is locked.
if (!canAccess(userId)) continue;
lastPolledAt.set(hostId, now);
try {
await pollHost(hostId, userId);
} catch (error) {
statsLogger.warn("Docker event poll failed", {
operation: "automation_docker_poll_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
function canAccess(userId: string): boolean {
try {
return DataCrypto.canUserAccessData(userId);
} catch {
return false;
}
}
/** Clears cached state, for shutdown and tests. */
export function resetDockerWatcher(): void {
snapshots.clear();
lastPolledAt.clear();
}
+422
View File
@@ -0,0 +1,422 @@
import type {
AutomationDefinition,
RunStatus,
Step,
} from "../../types/automations.js";
import {
DEFAULT_MAX_RUN_SECONDS,
MAX_AUTOMATION_DEPTH,
MAX_STEP_OUTPUT_BYTES,
} from "../../types/automations.js";
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
import { statsLogger } from "../utils/logger.js";
import { executeStep } from "./actions/index.js";
import type { StepExecutionContext, StepResult } from "./actions/types.js";
import { compare } from "./conditions.js";
import { renderTemplate, type TemplateContext } from "./template.js";
export interface RunRequest {
automationId: number;
triggerType: string;
triggerContext?: Record<string, unknown>;
triggerHostId?: number;
/** Overrides the automation's own dry-run flag, for "test run". */
dryRun?: boolean;
parentRunId?: number;
ancestry?: number[];
depth?: number;
}
export interface RunOutcome {
runId: number | null;
status: RunStatus;
error?: string;
}
/**
* Executes automations.
*
* Both HTTP handlers and the metrics hooks live in this same process, so this
* is a plain singleton rather than anything cross-process. State that has to
* survive a restart (cooldowns, dwell windows) lives in the database; the only
* thing held in memory is the set of runs currently in flight, which is
* meaningless after a restart anyway.
*/
export class AutomationEngine {
private static instance: AutomationEngine;
private readonly running = new Set<number>();
private readonly queued = new Map<number, number>();
static getInstance(): AutomationEngine {
if (!AutomationEngine.instance) {
AutomationEngine.instance = new AutomationEngine();
}
return AutomationEngine.instance;
}
isRunning(automationId: number): boolean {
return this.running.has(automationId);
}
async run(request: RunRequest): Promise<RunOutcome> {
const repository = createCurrentAutomationRepository();
const automation = await repository.findById(request.automationId);
if (!automation) {
return { runId: null, status: "failed", error: "Automation not found" };
}
const depth = request.depth ?? 0;
const ancestry = request.ancestry ?? [];
// Refuse recursion before anything is recorded, so a cycle cannot spin.
if (depth > MAX_AUTOMATION_DEPTH) {
return {
runId: null,
status: "failed",
error: `Maximum automation depth of ${MAX_AUTOMATION_DEPTH} exceeded`,
};
}
if (ancestry.includes(automation.id)) {
return {
runId: null,
status: "failed",
error: `Automation ${automation.id} is already running in this chain`,
};
}
let definition: AutomationDefinition;
try {
definition = JSON.parse(automation.definition) as AutomationDefinition;
} catch {
return {
runId: null,
status: "failed",
error: "Automation definition is not valid JSON",
};
}
// A second trigger while a run is in flight is recorded as skipped rather
// than dropped silently, so the history explains what happened.
//
// The slot has to be claimed in the same tick as the check. It used to be
// claimed several awaits later, so two triggers arriving together both
// passed this test and both ran.
let claimed = false;
if (this.running.has(automation.id)) {
const policy = automation.concurrencyPolicy;
if (policy === "skip") {
const run = await repository.createRun({
automationId: automation.id,
userId: automation.userId,
triggerType: request.triggerType,
triggerContext: JSON.stringify(request.triggerContext ?? {}),
status: "skipped",
});
await repository.finishRun(run.id, {
status: "skipped",
error: "A previous run was still in progress",
durationMs: 0,
});
return { runId: run.id, status: "skipped" };
}
if (policy === "queue") {
const depthNow = this.queued.get(automation.id) ?? 0;
if (depthNow >= 5) {
return { runId: null, status: "skipped", error: "Queue is full" };
}
this.queued.set(automation.id, depthNow + 1);
try {
await this.waitUntilFree(automation.id);
} finally {
this.queued.set(
automation.id,
(this.queued.get(automation.id) ?? 1) - 1,
);
}
// waitUntilFree gives up on its own deadline, so the slot may still be
// taken. Only claim it when it is genuinely free.
if (!this.running.has(automation.id)) {
this.running.add(automation.id);
claimed = true;
}
}
} else {
this.running.add(automation.id);
claimed = true;
}
const dryRun = request.dryRun ?? automation.dryRun;
const maxRunSeconds = automation.maxRunSeconds || DEFAULT_MAX_RUN_SECONDS;
const startedAt = Date.now();
let run: { id: number };
try {
run = await repository.createRun({
automationId: automation.id,
userId: automation.userId,
triggerType: request.triggerType,
triggerContext: JSON.stringify(request.triggerContext ?? {}),
status: "running",
dryRun,
parentRunId: request.parentRunId ?? null,
});
} catch (err) {
// The slot is already claimed at this point, so it has to be given back
// here; the finally below is only reached once a run row exists.
if (claimed) this.running.delete(automation.id);
return {
runId: null,
status: "failed",
error: err instanceof Error ? err.message : String(err),
};
}
if (!claimed) this.running.add(automation.id);
const template: TemplateContext = {
trigger: request.triggerContext ?? {},
steps: {},
vars: {},
run: {
id: run.id,
automationId: automation.id,
startedAt: new Date(startedAt).toISOString(),
},
};
const context: StepExecutionContext = {
userId: automation.userId,
automationId: automation.id,
runId: run.id,
dryRun,
template,
triggerHostId: request.triggerHostId,
ancestry: [...ancestry, automation.id],
depth,
deadlineAt: startedAt + maxRunSeconds * 1000,
};
let status: RunStatus = "success";
let error: string | undefined;
try {
const result = await this.runSteps(definition.steps ?? [], context, {
index: 0,
});
if (result.halted?.status === "failed") {
status = "failed";
error = "Stopped by a stop step";
} else if (result.failed) {
status = "failed";
error = result.error;
}
if (Date.now() >= context.deadlineAt) {
status = "timeout";
error = `Run exceeded ${maxRunSeconds}s`;
}
} catch (err) {
status = "failed";
error = err instanceof Error ? err.message : String(err);
} finally {
this.running.delete(automation.id);
}
await repository.finishRun(run.id, {
status,
error: error ?? null,
durationMs: Date.now() - startedAt,
});
if (status === "failed") {
statsLogger.warn(`Automation "${automation.name}" failed`, {
operation: "automation_run_failed",
automationId: automation.id,
runId: run.id,
error,
});
// An automation_failed handler that itself fails must not re-announce
// its own failure, so the event is not emitted for runs that this event
// already started.
if (request.triggerType !== "internal_event") {
import("../hosts/metrics/automation-bridge.js")
.then(({ notifyAutomationInternalEvent }) =>
notifyAutomationInternalEvent(
"automation_failed",
automation.userId,
undefined,
{
automationId: automation.id,
automationName: automation.name,
runId: run.id,
error: error ?? null,
},
),
)
.catch(() => undefined);
}
}
return { runId: run.id, status, error };
}
/**
* Runs a list of steps in order, descending into if/else. Returns as soon as
* a stop step halts the run or a failing step's policy says to stop.
*/
private async runSteps(
steps: Step[],
context: StepExecutionContext,
cursor: { index: number },
): Promise<{
failed: boolean;
error?: string;
halted?: { status: "success" | "failed" };
}> {
const repository = createCurrentAutomationRepository();
for (const step of steps) {
if (step.enabled === false) continue;
if (Date.now() >= context.deadlineAt) {
return { failed: true, error: "Run deadline exceeded" };
}
const stepIndex = cursor.index++;
if (step.type === "if") {
const left = renderTemplate(step.condition.left, context.template);
const right = renderTemplate(
step.condition.right ?? "",
context.template,
);
const matched = compare(left, step.condition.operator, right);
const rowId = await repository.createRunStep({
runId: context.runId,
stepIndex,
stepId: step.id,
stepType: "if",
status: "running",
});
await repository.finishRunStep(rowId, {
status: "success",
output: `Condition ${matched ? "matched" : "did not match"}: ${left} ${step.condition.operator} ${right}`,
});
const branch = matched ? step.then : (step.else ?? []);
const result = await this.runSteps(branch, context, cursor);
if (result.halted) return result;
if (result.failed) return result;
continue;
}
if (step.type === "run_automation") {
const rowId = await repository.createRunStep({
runId: context.runId,
stepIndex,
stepId: step.id,
stepType: step.type,
status: "running",
});
const nested = await this.run({
automationId: step.automationId,
triggerType: "run_automation",
triggerContext: { parentAutomationId: context.automationId },
triggerHostId: context.triggerHostId,
dryRun: context.dryRun,
parentRunId: context.runId,
ancestry: context.ancestry,
depth: context.depth + 1,
});
const nestedOk = nested.status === "success";
await repository.finishRunStep(rowId, {
status: nestedOk ? "success" : "failed",
output: `Nested run ${nested.runId ?? "not started"}: ${nested.status}`,
error: nested.error ?? null,
});
if (!nestedOk && (step.onError ?? "stop") === "stop") {
return { failed: true, error: nested.error ?? "Nested run failed" };
}
continue;
}
const rowId = await repository.createRunStep({
runId: context.runId,
stepIndex,
stepId: step.id,
stepType: step.type,
status: "running",
});
let result: StepResult;
try {
result = await executeStep(step, context);
} catch (err) {
result = {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
const { text, truncated } = truncate(result.output);
await repository.finishRunStep(rowId, {
status: result.success ? "success" : "failed",
output: text,
error: result.error ?? null,
truncated,
});
// Later steps read earlier output through {{steps.<id>.stdout}}.
context.template.steps = {
...context.template.steps,
[step.id]: {
stdout: result.output ?? "",
code: result.success ? 0 : 1,
},
};
if (result.vars) {
context.template.vars = { ...context.template.vars, ...result.vars };
}
if (result.halt) return { failed: false, halted: result.halt };
if (!result.success) {
const policy = step.onError ?? "stop";
if (policy === "stop") {
return { failed: true, error: result.error };
}
}
}
return { failed: false };
}
private async waitUntilFree(automationId: number): Promise<void> {
const started = Date.now();
while (this.running.has(automationId)) {
if (Date.now() - started > 60_000) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
}
/** Keeps a single step's output from bloating the database. */
function truncate(output: string | undefined): {
text: string | null;
truncated: boolean;
} {
if (!output) return { text: null, truncated: false };
if (Buffer.byteLength(output, "utf8") <= MAX_STEP_OUTPUT_BYTES) {
return { text: output, truncated: false };
}
return {
text: output.slice(0, MAX_STEP_OUTPUT_BYTES) + "\n... (truncated)",
truncated: true,
};
}
+105
View File
@@ -0,0 +1,105 @@
import { statsLogger } from "../utils/logger.js";
import { listAutomationWatchedHosts } from "./triggers.js";
/**
* Keeps metric collection running for hosts an automation watches.
*
* Heavy metric collection is normally started by a UI viewer and stops when
* the last one leaves, which means threshold rules only ever evaluated while
* somebody had the host open. Automations register a synthetic viewer instead
* of bypassing that mechanism, so a real viewer arriving or leaving still
* behaves exactly as before.
*
* The catch is `cleanupInactiveViewers`, which drops any viewer whose
* heartbeat is older than 120s. Without the heartbeat below, headless polling
* would quietly stop two minutes after it started.
*/
export interface ViewerRegistry {
registerViewer(hostId: number, sessionId: string, userId: string): void;
unregisterViewer(hostId: number, sessionId: string): void;
updateHeartbeat(sessionId: string): boolean;
}
const SESSION_PREFIX = "automation:";
let registry: ViewerRegistry | null = null;
const registered = new Map<number, string>();
export function setViewerRegistry(next: ViewerRegistry | null): void {
registry = next;
}
export function automationSessionId(hostId: number): string {
return `${SESSION_PREFIX}${hostId}`;
}
/**
* Brings the set of synthetic viewers in line with what the enabled
* automations currently watch, and heartbeats the ones that stay.
*/
export async function reconcileHeadlessViewers(): Promise<{
added: number;
removed: number;
active: number;
}> {
if (!registry) return { added: 0, removed: 0, active: 0 };
let watched: Map<number, string>;
try {
watched = await listAutomationWatchedHosts();
} catch {
return { added: 0, removed: 0, active: registered.size };
}
let added = 0;
let removed = 0;
for (const [hostId, userId] of watched) {
const sessionId = automationSessionId(hostId);
if (registered.has(hostId)) {
// Refresh before the 120s reaper would take it.
registry.updateHeartbeat(sessionId);
continue;
}
try {
registry.registerViewer(hostId, sessionId, userId);
registered.set(hostId, userId);
added++;
} catch (error) {
statsLogger.warn("Could not start headless metrics for a host", {
operation: "automation_headless_register_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
for (const hostId of [...registered.keys()]) {
if (watched.has(hostId)) continue;
try {
registry.unregisterViewer(hostId, automationSessionId(hostId));
} catch {
// Already gone; drop it either way.
}
registered.delete(hostId);
removed++;
}
return { added, removed, active: registered.size };
}
/** Drops every synthetic viewer, for shutdown and tests. */
export function releaseHeadlessViewers(): void {
if (registry) {
for (const hostId of registered.keys()) {
try {
registry.unregisterViewer(hostId, automationSessionId(hostId));
} catch {
// Nothing useful to do during teardown.
}
}
}
registered.clear();
}
+77
View File
@@ -0,0 +1,77 @@
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
/**
* Outbound HTTP for automation steps and notification channels.
*
* safeOutboundFetch refuses private and loopback addresses, which is the right
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
* on a LAN that many installs actually use. Rather than weaken the guard
* globally, a destination can opt in explicitly; everything else about the
* guard (scheme, embedded credentials, no redirects) still applies.
*/
export interface AutomationFetchOptions {
method?: string;
headers?: Record<string, string>;
body?: string;
allowPrivateNetwork?: boolean;
timeoutMs?: number;
}
export async function automationFetch(
url: string,
options: AutomationFetchOptions = {},
): Promise<Response> {
const {
method = "GET",
headers,
body,
allowPrivateNetwork,
timeoutMs = 30_000,
} = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 1));
const init: RequestInit = {
method,
headers: body
? { "Content-Type": "application/json", ...(headers ?? {}) }
: headers,
body,
signal: controller.signal,
};
try {
if (allowPrivateNetwork) {
return await privateNetworkFetch(url, init);
}
return await safeOutboundFetch(url, init);
} finally {
clearTimeout(timer);
}
}
/**
* The opt-in path. Keeps the parts of the guard that are always right and
* drops only the address blocklist.
*/
async function privateNetworkFetch(
rawUrl: string,
init: RequestInit,
): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error("Invalid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Only http and https URLs are allowed");
}
if (parsed.username || parsed.password) {
throw new Error("URLs with embedded credentials are not allowed");
}
return fetch(rawUrl, { ...init, redirect: "error" });
}
+183
View File
@@ -0,0 +1,183 @@
import { statsLogger } from "../utils/logger.js";
import { automationFetch } from "./http.js";
import type { TemplateContext } from "./template.js";
/**
* Notification delivery for automations.
*
* The alert engine special-cased Discord because its dispatcher only knew
* webhook and ntfy; here every transport goes through one switch, so adding a
* channel type is a single edit.
*/
export interface AutomationChannel {
id: number;
type: string;
config: string;
}
export interface AutomationNotification {
title: string;
body: string;
severity: "info" | "warning" | "critical";
context?: TemplateContext;
}
const NTFY_PRIORITY: Record<string, number> = {
info: 2,
warning: 3,
critical: 5,
};
const NTFY_TAGS: Record<string, string> = {
info: "information_source",
warning: "warning",
critical: "rotating_light",
};
const DISCORD_COLORS: Record<string, number> = {
info: 3066993,
warning: 16753920,
critical: 15158332,
};
export async function sendAutomationNotification(
channel: AutomationChannel,
notification: AutomationNotification,
): Promise<void> {
let config: Record<string, unknown>;
try {
config = JSON.parse(channel.config) as Record<string, unknown>;
} catch {
throw new Error("Channel configuration is not valid JSON");
}
const allowPrivateNetwork = config.allowPrivateNetwork === true;
switch (channel.type) {
case "webhook":
return sendWebhook(config, notification, allowPrivateNetwork);
case "ntfy":
return sendNtfy(config, notification, allowPrivateNetwork);
case "discord":
return sendDiscord(config, notification, allowPrivateNetwork);
default:
throw new Error(`Unsupported channel type: ${channel.type}`);
}
}
function requireUrl(config: Record<string, unknown>): string {
const url = typeof config.url === "string" ? config.url.trim() : "";
if (!url) throw new Error("Channel is missing a URL");
return url;
}
async function sendWebhook(
config: Record<string, unknown>,
notification: AutomationNotification,
allowPrivateNetwork: boolean,
): Promise<void> {
const url = requireUrl(config);
const method = config.method === "PUT" ? "PUT" : "POST";
const headers =
config.headers && typeof config.headers === "object"
? (config.headers as Record<string, string>)
: {};
const response = await automationFetch(url, {
method,
headers,
body: JSON.stringify({
title: notification.title,
message: notification.body,
severity: notification.severity,
timestamp: new Date().toISOString(),
}),
allowPrivateNetwork,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
}
async function sendNtfy(
config: Record<string, unknown>,
notification: AutomationNotification,
allowPrivateNetwork: boolean,
): Promise<void> {
const base = requireUrl(config).replace(/\/$/, "");
const topic = typeof config.topic === "string" ? config.topic.trim() : "";
if (!topic) throw new Error("ntfy channel is missing a topic");
const headers: Record<string, string> = {
Title: notification.title || "Termix automation",
Priority: String(NTFY_PRIORITY[notification.severity] ?? 3),
Tags: NTFY_TAGS[notification.severity] ?? "information_source",
};
if (typeof config.token === "string" && config.token) {
headers.Authorization = `Bearer ${config.token}`;
}
const response = await automationFetch(`${base}/${topic}`, {
method: "POST",
headers,
body: notification.body || notification.title,
allowPrivateNetwork,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
}
async function sendDiscord(
config: Record<string, unknown>,
notification: AutomationNotification,
allowPrivateNetwork: boolean,
): Promise<void> {
const url = requireUrl(config);
const payload: Record<string, unknown> = {
embeds: [
{
title: notification.title || "Termix automation",
description: notification.body || undefined,
color: DISCORD_COLORS[notification.severity] ?? 3447003,
timestamp: new Date().toISOString(),
},
],
};
if (typeof config.username === "string" && config.username) {
payload.username = config.username;
}
if (typeof config.avatar_url === "string" && config.avatar_url) {
payload.avatar_url = config.avatar_url;
}
const response = await automationFetch(url, {
method: "POST",
body: JSON.stringify(payload),
allowPrivateNetwork,
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`HTTP ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`,
);
}
}
/** Fire-and-forget wrapper for callers that must not block on delivery. */
export function sendAutomationNotificationSafely(
channel: AutomationChannel,
notification: AutomationNotification,
): void {
sendAutomationNotification(channel, notification).catch((error) => {
statsLogger.warn("Automation notification failed", {
operation: "automation_notification_error",
channelId: channel.id,
type: channel.type,
error: error instanceof Error ? error.message : String(error),
});
});
}
+207
View File
@@ -0,0 +1,207 @@
import type { AutomationDefinition } from "../../types/automations.js";
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { statsLogger } from "../utils/logger.js";
import { computeNextDueAt } from "./cron.js";
import { hasDwelled, isCoolingDown } from "./conditions.js";
import { pollDockerEvents } from "./docker-watcher.js";
import { AutomationEngine } from "./engine.js";
import { reconcileHeadlessViewers } from "./headless-viewer.js";
/**
* The one timer the automations feature owns.
*
* Every other periodic job in the backend is its own module-level setInterval;
* this deliberately is not one per automation. A single tick handles due
* schedules, dwell windows that need re-checking without a fresh sample, the
* synthetic viewer heartbeat, and history pruning.
*/
const TICK_MS = 15_000;
const STARTUP_DELAY_MS = 30_000;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const RUN_RETENTION_DAYS = 30;
/** A "running" row older than this belongs to a process that is gone. */
const STALE_RUN_MS = 6 * 60 * 60 * 1000;
let tickTimer: NodeJS.Timeout | null = null;
let startupTimer: NodeJS.Timeout | null = null;
let lastPruneAt = 0;
let ticking = false;
export function startAutomationScheduler(): void {
if (tickTimer) return;
startupTimer = setTimeout(() => {
void tick();
}, STARTUP_DELAY_MS);
startupTimer.unref?.();
tickTimer = setInterval(() => {
void tick();
}, TICK_MS);
tickTimer.unref?.();
}
export function stopAutomationScheduler(): void {
if (tickTimer) clearInterval(tickTimer);
if (startupTimer) clearTimeout(startupTimer);
tickTimer = null;
startupTimer = null;
}
/** Exposed for tests; the interval calls this. */
export async function tick(now: Date = new Date()): Promise<void> {
// A slow tick must not overlap the next one.
if (ticking) return;
ticking = true;
try {
await reconcileHeadlessViewers().catch(() => undefined);
await runDueSchedules(now);
await recheckOpenBreaches(now);
await pollDockerEvents(now.getTime()).catch(() => undefined);
await pruneIfDue(now);
} catch (error) {
statsLogger.warn("Automation scheduler tick failed", {
operation: "automation_scheduler_tick_error",
error: error instanceof Error ? error.message : String(error),
});
} finally {
ticking = false;
}
}
async function runDueSchedules(now: Date): Promise<void> {
const repository = createCurrentAutomationRepository();
const due = await repository.listDueSchedules(now.toISOString());
for (const schedule of due) {
const automation = await repository.findById(schedule.automationId);
if (!automation) continue;
// Background work can only touch a user's data while their key resolves.
if (!canAccess(automation.userId)) {
statsLogger.warn("Skipping scheduled automation: data key unavailable", {
operation: "automation_schedule_locked",
automationId: automation.id,
});
continue;
}
const nextDueAt = computeNextDueAt(
{
cron: schedule.cron,
intervalSeconds: schedule.intervalSeconds,
timezone: schedule.timezone,
},
now,
);
await repository.markScheduleTicked(
schedule.automationId,
nextDueAt,
now.toISOString(),
);
AutomationEngine.getInstance()
.run({
automationId: schedule.automationId,
triggerType: "schedule",
triggerContext: { scheduledFor: now.toISOString() },
})
.catch((error) => {
statsLogger.warn("Scheduled automation failed to start", {
operation: "automation_schedule_error",
automationId: schedule.automationId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
/**
* Fires sustained breaches whose window has elapsed.
*
* Without this a dwell window only completes when another sample happens to
* arrive, so a breach that starts just before polling stops would never fire.
*/
async function recheckOpenBreaches(now: Date): Promise<void> {
const repository = createCurrentAutomationRepository();
const open = await repository.listOpenBreaches();
const nowMs = now.getTime();
for (const state of open) {
const automation = await repository.findById(state.automationId);
if (!automation || !automation.enabled) continue;
if (!canAccess(automation.userId)) continue;
let definition: AutomationDefinition;
try {
definition = JSON.parse(automation.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!trigger.forSeconds) continue;
if (!hasDwelled(state.breachStartedAt, trigger.forSeconds, nowMs)) continue;
if (isCoolingDown(state.lastFiredAt, trigger.cooldownMinutes, nowMs)) {
continue;
}
const hostId = Number(state.stateKey.split(":")[0]);
await repository.upsertTriggerState({
automationId: automation.id,
stateKey: state.stateKey,
lastFiredAt: now.toISOString(),
});
AutomationEngine.getInstance()
.run({
automationId: automation.id,
triggerType: "metric_threshold",
triggerContext: {
hostId,
value: state.lastValue,
threshold: trigger.value,
metric: trigger.metric.path,
sustained: true,
},
triggerHostId: Number.isFinite(hostId) ? hostId : undefined,
})
.catch(() => undefined);
}
}
async function pruneIfDue(now: Date): Promise<void> {
if (now.getTime() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = now.getTime();
const repository = createCurrentAutomationRepository();
try {
await repository.failStaleRunningRuns(
new Date(now.getTime() - STALE_RUN_MS).toISOString(),
);
const deleted = await repository.pruneRunsOlderThan(RUN_RETENTION_DAYS);
if (deleted > 0) {
statsLogger.info(`Pruned ${deleted} old automation run(s)`, {
operation: "automation_run_prune",
deleted,
});
}
} catch (error) {
statsLogger.warn("Automation run pruning failed", {
operation: "automation_run_prune_error",
error: error instanceof Error ? error.message : String(error),
});
}
}
function canAccess(userId: string): boolean {
try {
return DataCrypto.canUserAccessData(userId);
} catch {
return false;
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Variable substitution for automation steps.
*
* Templates read from the run context: {{host.name}}, {{trigger.value}},
* {{steps.<stepId>.stdout}}, {{vars.myVar}}. Resolution always produces a
* plain string and never shell syntax; callers that build a command are
* responsible for quoting the result (see shellSingleQuote in
* hosts/metrics/managers/exec-elevated.ts). Nothing here escapes anything,
* precisely so there is one obvious place where quoting happens.
*/
export interface TemplateContext {
host?: {
id?: number;
name?: string;
ip?: string;
username?: string;
port?: number;
};
trigger?: Record<string, unknown>;
steps?: Record<string, { stdout?: string; stderr?: string; code?: number }>;
vars?: Record<string, string>;
run?: { id?: number; automationId?: number; startedAt?: string };
}
const TOKEN = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
function readPath(context: TemplateContext, path: string): unknown {
const parts = path.split(".");
let current: unknown = context;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
function stringify(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
/**
* Replaces every {{token}} it can resolve. An unresolvable token is left
* as-is so a typo shows up in the run output rather than silently becoming an
* empty string, which is the difference between a visible mistake and a
* command that quietly does the wrong thing.
*/
export function renderTemplate(
input: string,
context: TemplateContext,
): string {
if (!input || !input.includes("{{")) return input;
return input.replace(TOKEN, (match, path: string) => {
const value = readPath(context, path);
return value === undefined ? match : stringify(value);
});
}
/** Renders every string in a flat record, leaving keys untouched. */
export function renderRecord(
input: Record<string, string> | undefined,
context: TemplateContext,
): Record<string, string> | undefined {
if (!input) return undefined;
const output: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
output[key] = renderTemplate(value, context);
}
return output;
}
/** True when a template still has unresolved tokens after rendering. */
export function hasUnresolvedTokens(rendered: string): boolean {
TOKEN.lastIndex = 0;
return TOKEN.test(rendered);
}
const SECRET_KEY = /(authorization|token|password|secret|api[-_]?key|cookie)/i;
/**
* Masks values whose key looks like a credential, for anything written to run
* history or returned by the API.
*/
export function redactSecrets(
input: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!input) return undefined;
const output: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
output[key] = SECRET_KEY.test(key) ? "***" : value;
}
return output;
}
+387
View File
@@ -0,0 +1,387 @@
import type {
AutomationDefinition,
HostSelector,
Trigger,
} from "../../types/automations.js";
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
import type { AutomationEngineRow } from "../database/repositories/automation-repository.js";
import { statsLogger } from "../utils/logger.js";
import {
compare,
extractMetricValue,
hasDwelled,
isCoolingDown,
metricStateKey,
severityForValue,
type MetricsSnapshot,
} from "./conditions.js";
import { AutomationEngine } from "./engine.js";
/**
* Matches events against automation triggers and decides what fires.
*
* Dwell windows and cooldowns live in automation_trigger_state rather than in
* memory, so a restart mid-breach neither loses the window nor re-fires an
* alert that already went out.
*/
export interface MetricEvent {
hostId: number;
ownerUserId: string;
metrics: MetricsSnapshot;
}
export interface StatusEvent {
hostId: number;
ownerUserId: string;
online: boolean;
}
export interface HealthEvent {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export interface DockerEvent {
hostId: number;
ownerUserId: string;
container: string;
event: "exited" | "started" | "unhealthy" | "restarting";
}
export interface InternalEvent {
event: string;
userId: string;
hostId?: number;
details?: Record<string, unknown>;
}
interface LoadedAutomation {
row: AutomationEngineRow;
definition: AutomationDefinition;
}
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
try {
const rows =
await createCurrentAutomationRepository().listEnabledForUser(userId);
const loaded: LoadedAutomation[] = [];
for (const row of rows) {
try {
loaded.push({
row,
definition: JSON.parse(row.definition) as AutomationDefinition,
});
} catch {
// A malformed definition should not stop the others from evaluating.
}
}
return loaded;
} catch {
return [];
}
}
/** Whether a selector covers a host. Ownership is checked by the caller. */
function selectorCoversHost(
selector: HostSelector | undefined,
hostId: number,
): boolean {
if (!selector) return true;
switch (selector.kind) {
case "all":
case "trigger":
return true;
case "host":
return selector.hostId === hostId;
case "hosts":
return selector.hostIds.includes(hostId);
case "fleet":
// Fleet membership is resolved at execution time; evaluate optimistically
// so a fleet-scoped trigger still reaches the engine.
return true;
default:
return false;
}
}
async function fire(
automation: AutomationEngineRow,
stateKey: string,
triggerType: string,
triggerContext: Record<string, unknown>,
hostId?: number,
): Promise<void> {
const repository = createCurrentAutomationRepository();
await repository.upsertTriggerState({
automationId: automation.id,
stateKey,
lastFiredAt: new Date().toISOString(),
});
AutomationEngine.getInstance()
.run({
automationId: automation.id,
triggerType,
triggerContext,
triggerHostId: hostId,
})
.catch((error) => {
statsLogger.warn("Automation run failed to start", {
operation: "automation_trigger_error",
automationId: automation.id,
error: error instanceof Error ? error.message : String(error),
});
});
}
/**
* Metric samples. Called for every polled host, including hosts polled only
* because an automation asked for them.
*/
export async function onMetrics(event: MetricEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "metric_threshold") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const value = extractMetricValue(event.metrics, trigger.metric);
if (value === null) continue;
const stateKey = metricStateKey(event.hostId, trigger.metric);
const state = await repository.getTriggerState(row.id, stateKey);
const breaching = compare(value, trigger.operator, trigger.value);
if (!breaching) {
if (state?.breachStartedAt) {
await repository.clearBreach(row.id, stateKey);
}
continue;
}
// Open the dwell window on the first breaching sample.
if (!state?.breachStartedAt) {
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
breachStartedAt: new Date(now).toISOString(),
lastValue: value,
});
if (trigger.forSeconds) continue;
}
const breachStartedAt =
state?.breachStartedAt ?? new Date(now).toISOString();
if (!hasDwelled(breachStartedAt, trigger.forSeconds, now)) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"metric_threshold",
{
hostId: event.hostId,
value,
threshold: trigger.value,
operator: trigger.operator,
metric: trigger.metric.path,
mount: "mount" in trigger.metric ? trigger.metric.mount : undefined,
severity: severityForValue(value, trigger.severity),
},
event.hostId,
);
}
}
/** Host reachability transitions. Only edges fire, never steady state. */
export async function onStatus(event: StatusEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
const observed = event.online ? "online" : "offline";
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "host_status") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
const stateKey = String(event.hostId);
const state = await repository.getTriggerState(row.id, stateKey);
if (state?.lastObservedState === observed) continue;
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
// The first observation establishes a baseline rather than firing, so a
// restart does not announce every host as though it just changed.
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"host_status",
{ hostId: event.hostId, status: observed },
event.hostId,
);
}
}
export async function onHealthCheck(event: HealthEvent): Promise<void> {
const automations = await loadEnabledFor(event.userId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
const observed = event.ok ? "recovered" : "failing";
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "health_check") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.checkId && trigger.checkId !== event.checkId) continue;
const stateKey = `${event.hostId}:${event.checkId}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (state?.lastObservedState === observed) continue;
await repository.upsertTriggerState({
automationId: row.id,
stateKey,
lastObservedState: observed,
});
if (!state?.lastObservedState) continue;
if (trigger.to !== observed) continue;
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"health_check",
{
hostId: event.hostId,
checkId: event.checkId,
state: observed,
detail: event.detail,
},
event.hostId,
);
}
}
export async function onDockerEvent(event: DockerEvent): Promise<void> {
const automations = await loadEnabledFor(event.ownerUserId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "docker_event") continue;
if (!selectorCoversHost(trigger.hostSelector, event.hostId)) continue;
if (trigger.container && trigger.container !== event.container) continue;
if (trigger.event !== event.event) continue;
const stateKey = `${event.hostId}:${event.container}`;
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"docker_event",
{
hostId: event.hostId,
container: event.container,
event: event.event,
},
event.hostId,
);
}
}
export async function onInternalEvent(event: InternalEvent): Promise<void> {
const automations = await loadEnabledFor(event.userId);
const repository = createCurrentAutomationRepository();
const now = Date.now();
for (const { row, definition } of automations) {
const trigger = definition.trigger;
if (trigger?.kind !== "internal_event") continue;
if (trigger.event !== event.event) continue;
if (
event.hostId !== undefined &&
!selectorCoversHost(trigger.hostSelector, event.hostId)
) {
continue;
}
const stateKey = event.hostId ? String(event.hostId) : "global";
const state = await repository.getTriggerState(row.id, stateKey);
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
continue;
await fire(
row,
stateKey,
"internal_event",
{ event: event.event, hostId: event.hostId, ...(event.details ?? {}) },
event.hostId,
);
}
}
/**
* Hosts that an enabled automation watches, so the poller knows to collect
* metrics for them even when nobody is looking.
*/
export async function listAutomationWatchedHosts(): Promise<
Map<number, string>
> {
const watched = new Map<number, string>();
try {
const rows = await createCurrentAutomationRepository().listAllEnabled();
for (const row of rows) {
let definition: AutomationDefinition;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
continue;
}
const trigger: Trigger | undefined = definition.trigger;
// Only metric thresholds need heavy collection; status triggers are
// already served by the cheap reachability probe.
if (trigger?.kind !== "metric_threshold") continue;
const selector = trigger.hostSelector;
if (selector?.kind === "host") {
watched.set(selector.hostId, row.userId);
} else if (selector?.kind === "hosts") {
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
}
// Fleet and "all" selectors are resolved by the scheduler, which can
// expand them without blocking this call.
}
} catch {
return watched;
}
return watched;
}
+75 -8
View File
@@ -1,5 +1,7 @@
import { getErrorMessage } from "../utils/error-message.js";
import express from "express";
import http from "http";
import https from "https";
import bodyParser from "body-parser";
import multer from "multer";
import cookieParser from "cookie-parser";
@@ -8,6 +10,8 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import fleetRoutes from "./routes/fleet-routes.js";
import workspaceRoutes from "./routes/workspaces.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
@@ -17,14 +21,20 @@ import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import proxmoxRoutes from "./routes/proxmox.js";
import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js";
import aiRoutes from "../ai/index.js";
import automationsRoutes from "./routes/automations.js";
import syncRoutes from "./routes/sync.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs";
import path from "path";
import os from "os";
@@ -68,6 +78,7 @@ app.set("trust proxy", true);
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
type SettingData = {
@@ -482,7 +493,7 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => {
});
res.status(500).json({
error: "Failed to generate RSS format",
details: error instanceof Error ? error.message : "Unknown error",
details: getErrorMessage(error),
});
}
});
@@ -1137,7 +1148,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
});
res.status(500).json({
error: "Failed to export user data",
details: error instanceof Error ? error.message : "Unknown error",
details: getErrorMessage(error),
});
}
});
@@ -1603,7 +1614,7 @@ app.post(
});
res.status(500).json({
error: "Failed to import SQLite data",
details: error instanceof Error ? error.message : "Unknown error",
details: getErrorMessage(error),
});
}
},
@@ -1664,7 +1675,7 @@ app.post("/database/export/preview", authenticateJWT, async (req, res) => {
});
res.status(500).json({
error: "Failed to generate export preview",
details: error instanceof Error ? error.message : "Unknown error",
details: getErrorMessage(error),
});
}
});
@@ -1725,7 +1736,7 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
});
res.status(500).json({
error: "Database restore failed",
details: error instanceof Error ? error.message : "Unknown error",
details: getErrorMessage(error),
});
}
});
@@ -1735,6 +1746,8 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/fleets", fleetRoutes);
app.use("/workspaces", workspaceRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
@@ -1744,11 +1757,18 @@ app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes);
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
app.use("/proxmox", proxmoxRoutes);
app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
// Before the alert routes, which are mounted at the root and would otherwise
// have first claim on the path.
app.use("/automations", automationsRoutes);
app.use("/ai", aiRoutes);
app.use("/", alertRulesRoutes);
app.use("/sync", syncRoutes);
@@ -1913,7 +1933,7 @@ app.get(
});
res.status(500).json({
error: "Failed to get migration status",
details: error instanceof Error ? error.message : "Unknown error",
details: getErrorMessage(error),
});
}
},
@@ -1991,7 +2011,7 @@ app.get(
});
res.status(500).json({
error: "Failed to get migration history",
details: error instanceof Error ? error.message : "Unknown error",
details: getErrorMessage(error),
});
}
},
@@ -2029,7 +2049,54 @@ const sslConfig = AutoSSLSetup.getSSLConfig();
if (sslConfig.enabled) {
databaseLogger.info(`SSL is enabled`, {
operation: "ssl_info",
nginx_https_port: sslConfig.port,
ssl_port: sslConfig.port,
backend_http_port: HTTP_PORT,
});
try {
const httpsServer = https.createServer(
{
cert: fs.readFileSync(sslConfig.certPath),
key: fs.readFileSync(sslConfig.keyPath),
},
app,
);
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
databaseLogger.error(
`SSL port ${sslConfig.port} is already in use. Kill the existing process and retry.`,
err,
{
operation: "https_server_port_conflict",
port: sslConfig.port,
},
);
return;
}
databaseLogger.error("HTTPS server error", err, {
operation: "https_server_error",
});
});
httpsServer.listen(sslConfig.port, () => {
databaseLogger.success(
`Backend is now also listening for HTTPS directly`,
{
operation: "https_server_started",
port: sslConfig.port,
},
);
});
} catch (error) {
databaseLogger.error(
"Failed to start HTTPS server with configured SSL certificate",
error,
{
operation: "https_server_start_failed",
cert_path: sslConfig.certPath,
key_path: sslConfig.keyPath,
},
);
}
}
-71
View File
@@ -1,71 +0,0 @@
import * as sqlite from "drizzle-orm/sqlite-core";
import * as pg from "drizzle-orm/pg-core";
import * as mysql from "drizzle-orm/mysql-core";
/**
* Per-dialect column constructors, so a table can be declared once instead of
* three times.
*
* The existing schema only uses three column types (text, integer, real) plus
* an integer-backed boolean, which is what makes this tractable — the surface
* to abstract is small and closed. Anything a dialect cannot express the same
* way is spelled out here rather than at 52 call sites.
*
* Notable differences this papers over:
* - booleans are integers in SQLite, native in Postgres and tinyint in MySQL
* - autoincrement keys are `integer primary key autoincrement`, `serial`, and
* `int auto_increment` respectively
* - MySQL cannot index an unbounded TEXT, so keyed/indexed strings must be
* varchar; `shortText` exists for columns used as keys or in unique indexes
*/
export interface ColumnKit {
table: typeof sqlite.sqliteTable | typeof pg.pgTable | typeof mysql.mysqlTable;
/** Free-form string; unbounded where the engine allows it. */
text: (name: string) => AnyColumnBuilder;
/** String used as a key, unique or indexed — bounded so MySQL can index it. */
shortText: (name: string, length?: number) => AnyColumnBuilder;
int: (name: string) => AnyColumnBuilder;
/** Auto-incrementing surrogate primary key. */
serial: (name: string) => AnyColumnBuilder;
bool: (name: string) => AnyColumnBuilder;
real: (name: string) => AnyColumnBuilder;
}
// drizzle's builders are heavily generic; the schema modules keep their own
// precise types, so this alias only exists to describe the kit's shape.
type AnyColumnBuilder = ReturnType<typeof sqlite.text>;
const DEFAULT_KEY_LENGTH = 255;
export const sqliteKit = {
table: sqlite.sqliteTable,
text: (name: string) => sqlite.text(name),
shortText: (name: string) => sqlite.text(name),
int: (name: string) => sqlite.integer(name),
serial: (name: string) =>
sqlite.integer(name).primaryKey({ autoIncrement: true }),
bool: (name: string) => sqlite.integer(name, { mode: "boolean" }),
real: (name: string) => sqlite.real(name),
} as const;
export const pgKit = {
table: pg.pgTable,
text: (name: string) => pg.text(name),
shortText: (name: string, length = DEFAULT_KEY_LENGTH) =>
pg.varchar(name, { length }),
int: (name: string) => pg.integer(name),
serial: (name: string) => pg.serial(name).primaryKey(),
bool: (name: string) => pg.boolean(name),
real: (name: string) => pg.doublePrecision(name),
} as const;
export const mysqlKit = {
table: mysql.mysqlTable,
text: (name: string) => mysql.text(name),
shortText: (name: string, length = DEFAULT_KEY_LENGTH) =>
mysql.varchar(name, { length }),
int: (name: string) => mysql.int(name),
serial: (name: string) => mysql.int(name).autoincrement().primaryKey(),
bool: (name: string) => mysql.boolean(name),
real: (name: string) => mysql.double(name),
} as const;
+59 -2
View File
@@ -2,6 +2,50 @@ import type { DatabaseDialect } from "./dialect.js";
import type { PortableDatabase } from "../repositories/database-context.js";
export const DATABASE_URL_ENV = "DATABASE_URL";
export const DATABASE_POOL_MAX_ENV = "DATABASE_POOL_MAX";
export const DATABASE_SSL_ENV = "DATABASE_SSL";
const DEFAULT_POOL_MAX = 10;
/**
* Pool size. Both drivers default to 10; this exists so an install that runs
* several replicas against one server can keep the total connection count under
* the server's own limit, which is the usual thing to hit first.
*/
export function poolMax(env: NodeJS.ProcessEnv = process.env): number {
const raw = env[DATABASE_POOL_MAX_ENV]?.trim();
if (!raw) return DEFAULT_POOL_MAX;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(
`${DATABASE_POOL_MAX_ENV} must be a positive integer, got "${raw}".`,
);
}
return parsed;
}
/**
* TLS mode.
*
* "require" verifies the server certificate; "no-verify" encrypts without
* checking it, which is what a self-signed certificate on a private network
* needs. Unset means no TLS, preserving the behaviour of every install that
* predates this setting.
*/
export function sslOption(
env: NodeJS.ProcessEnv = process.env,
): false | { rejectUnauthorized: boolean } {
const raw = env[DATABASE_SSL_ENV]?.trim().toLowerCase();
if (!raw || raw === "false" || raw === "disable") return false;
if (raw === "true" || raw === "require") return { rejectUnauthorized: true };
if (raw === "no-verify") return { rejectUnauthorized: false };
throw new Error(
`Unsupported ${DATABASE_SSL_ENV}: "${raw}". Expected require, no-verify, or disable.`,
);
}
/**
* Opens a connection to a client-server engine.
@@ -60,15 +104,28 @@ export async function connectRemoteDatabase(
assertUrlMatchesDialect(url, dialect);
const max = poolMax(env);
const ssl = sslOption(env);
// No `schema` option: it only feeds drizzle's relational query API
// (`db.query.*`), which nothing here uses. The query builder takes its table
// names and value encoders from the table objects the repositories import —
// see the note in schema.pg.ts on why the generated schemas are DDL-only.
if (dialect === "postgres") {
const { drizzle } = await import("drizzle-orm/node-postgres");
return drizzle(url) as unknown as PortableDatabase;
return drizzle({
connection: { connectionString: url, max, ...(ssl ? { ssl } : {}) },
}) as unknown as PortableDatabase;
}
// mysql2 names the pool limit differently and wants no `ssl` key at all when
// TLS is off — passing false is not the same as omitting it.
const { drizzle } = await import("drizzle-orm/mysql2");
return drizzle(url) as unknown as PortableDatabase;
return drizzle({
connection: {
uri: url,
connectionLimit: max,
...(ssl ? { ssl } : {}),
},
}) as unknown as PortableDatabase;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,352 @@
import { databaseLogger } from "../../utils/logger.js";
/**
* Indexes for the columns the app filters, joins and sorts on.
*
* Most tables here are scoped per user or per host and were previously reached
* with a full table scan on every read: the foreign keys existed, but SQLite
* does not index the child side of a foreign key on its own. That is fine for a
* home install with a handful of hosts and invisible in testing; on an install
* with thousands of hosts and a large audit trail it is the dominant cost of a
* request.
*
* Ordering within a composite matters: the equality column comes first and the
* range/sort column second, so the same index serves both the filter and the
* ORDER BY.
*/
export interface PerformanceIndex {
name: string;
table: string;
columns: string;
/** Enforces a constraint as well as serving reads, e.g. one row per key. */
unique?: boolean;
}
export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
// Host list: the single hottest read in the app.
{ name: "idx_ssh_data_user_id", table: "ssh_data", columns: "user_id" },
{
name: "idx_ssh_data_parent_host",
table: "ssh_data",
columns: "parent_host_id",
},
{
name: "idx_ssh_data_credential",
table: "ssh_data",
columns: "credential_id",
},
// Sharing: resolved for every host list request and every permission check.
{ name: "idx_host_access_user_id", table: "host_access", columns: "user_id" },
{ name: "idx_host_access_role_id", table: "host_access", columns: "role_id" },
{ name: "idx_host_access_host_id", table: "host_access", columns: "host_id" },
{
name: "idx_host_access_expires_at",
table: "host_access",
columns: "expires_at",
},
// Audit log: grows without bound and is always read newest-first.
{
name: "idx_audit_logs_timestamp",
table: "audit_logs",
columns: "timestamp",
},
{
name: "idx_audit_logs_user_ts",
table: "audit_logs",
columns: "user_id, timestamp",
},
{
name: "idx_audit_logs_action_ts",
table: "audit_logs",
columns: "action, timestamp",
},
{
name: "idx_audit_logs_resource_ts",
table: "audit_logs",
columns: "resource_type, timestamp",
},
// Auth hot path: touched on every authenticated request.
{ name: "idx_sessions_user_id", table: "sessions", columns: "user_id" },
{ name: "idx_sessions_expires_at", table: "sessions", columns: "expires_at" },
{ name: "idx_user_roles_user_id", table: "user_roles", columns: "user_id" },
{ name: "idx_user_roles_role_id", table: "user_roles", columns: "role_id" },
{
name: "idx_trusted_devices_user_id",
table: "trusted_devices",
columns: "user_id",
},
{ name: "idx_api_keys_user_id", table: "api_keys", columns: "user_id" },
// Credentials and folders.
{
name: "idx_ssh_credentials_user_id",
table: "ssh_credentials",
columns: "user_id",
},
{ name: "idx_ssh_folders_user_id", table: "ssh_folders", columns: "user_id" },
{
name: "idx_ssh_credential_usage_credential",
table: "ssh_credential_usage",
columns: "credential_id",
},
{
name: "idx_ssh_credential_usage_user",
table: "ssh_credential_usage",
columns: "user_id",
},
// Snippets.
{ name: "idx_snippets_user_id", table: "snippets", columns: "user_id" },
{
name: "idx_snippet_access_user_id",
table: "snippet_access",
columns: "user_id",
},
{
name: "idx_snippet_access_snippet_id",
table: "snippet_access",
columns: "snippet_id",
},
{
name: "idx_snippet_access_role_id",
table: "snippet_access",
columns: "role_id",
},
// Per-user history and file manager surfaces.
{
name: "idx_recent_activity_user_ts",
table: "recent_activity",
columns: "user_id, timestamp",
},
{
name: "idx_command_history_user_host",
table: "command_history",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_recent_user",
table: "file_manager_recent",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_pinned_user",
table: "file_manager_pinned",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_shortcuts_user",
table: "file_manager_shortcuts",
columns: "user_id, host_id",
},
{
name: "idx_transfer_recent_user",
table: "transfer_recent",
columns: "user_id",
},
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
columns: "user_id",
},
{
name: "idx_user_workspaces_user_id",
table: "user_workspaces",
columns: "user_id",
},
{
name: "idx_homepage_items_user_id",
table: "homepage_items",
columns: "user_id",
},
{
name: "idx_dismissed_alerts_user_id",
table: "dismissed_alerts",
columns: "user_id",
},
// Recordings and live session sharing.
{
name: "idx_session_recordings_user_started",
table: "session_recordings",
columns: "user_id, started_at",
},
{
name: "idx_session_recordings_host",
table: "session_recordings",
columns: "host_id",
},
{
name: "idx_session_shares_session_id",
table: "session_shares",
columns: "session_id",
},
{
name: "idx_session_shares_host_id",
table: "session_shares",
columns: "host_id",
},
// Fleets.
{
name: "idx_fleet_members_fleet",
table: "fleet_members",
columns: "fleet_id",
},
{
name: "idx_fleet_members_host",
table: "fleet_members",
columns: "host_id",
},
{
name: "idx_fleet_inventory_user",
table: "fleet_inventory",
columns: "user_id",
},
// Alerting.
{
name: "idx_alert_firings_rule",
table: "alert_firings",
columns: "rule_id, fired_at",
},
{
name: "idx_alert_firings_host",
table: "alert_firings",
columns: "host_id",
},
// Automations.
{
name: "idx_automations_user",
table: "automations",
columns: "user_id, enabled",
},
{
name: "idx_automation_trigger_state_key",
table: "automation_trigger_state",
columns: "automation_id, state_key",
unique: true,
},
{
name: "idx_automation_schedules_automation",
table: "automation_schedules",
columns: "automation_id",
unique: true,
},
{
name: "idx_automation_schedules_due",
table: "automation_schedules",
columns: "next_due_at",
},
{
name: "idx_automation_runs_automation",
table: "automation_runs",
columns: "automation_id, started_at",
},
{
name: "idx_automation_runs_user",
table: "automation_runs",
columns: "user_id, started_at",
},
{
name: "idx_automation_run_steps_run",
table: "automation_run_steps",
columns: "run_id, step_index",
},
{
name: "idx_automation_channels_pair",
table: "automation_channels",
columns: "automation_id, channel_id",
unique: true,
},
// AI assistant.
{
name: "idx_ai_providers_user_label",
table: "ai_providers",
columns: "user_id, label",
unique: true,
},
{
name: "idx_ai_conversations_user",
table: "ai_conversations",
columns: "user_id, updated_at",
},
{
name: "idx_ai_messages_conversation",
table: "ai_messages",
columns: "conversation_id, created_at",
},
{
name: "idx_ai_proposals_user",
table: "ai_proposals",
columns: "user_id, status",
},
{
name: "idx_ai_proposals_conversation",
table: "ai_proposals",
columns: "conversation_id",
},
];
interface IndexableDatabase {
exec(sql: string): unknown;
}
export interface IndexCreationSummary {
created: number;
skipped: number;
failed: number;
}
/**
* Creates any missing index, leaving existing ones untouched.
*
* A failure here is logged and skipped rather than thrown: an index is a pure
* optimisation, and a table that a given install has not created yet (or a
* column an older schema is missing) must not stop the server from booting.
*/
export function createPerformanceIndexes(
db: IndexableDatabase,
indexes: PerformanceIndex[] = PERFORMANCE_INDEXES,
): IndexCreationSummary {
const summary: IndexCreationSummary = { created: 0, skipped: 0, failed: 0 };
const startedAt = Date.now();
for (const index of indexes) {
try {
db.exec(
`CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${index.name} ON ${index.table}(${index.columns})`,
);
summary.created++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A missing table or column means this install does not have that
// feature's schema; nothing to index and nothing to warn loudly about.
if (/no such table|no such column/i.test(message)) {
summary.skipped++;
continue;
}
summary.failed++;
databaseLogger.warn(`Could not create index ${index.name}: ${message}`, {
operation: "performance_index_create",
index: index.name,
table: index.table,
});
}
}
databaseLogger.info(
`Performance indexes ready in ${Date.now() - startedAt}ms`,
{
operation: "performance_index_create",
...summary,
},
);
return summary;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,407 @@
import { and, desc, eq } from "drizzle-orm";
import {
aiConversations,
aiMessages,
aiProposals,
aiProviders,
} from "../db/schema.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
import { formatSqlTimestamp } from "./sql-timestamp.js";
const now = (): string => formatSqlTimestamp(new Date());
export type AiProviderRecord = typeof aiProviders.$inferSelect;
export type AiConversationRecord = typeof aiConversations.$inferSelect;
export type AiMessageRecord = typeof aiMessages.$inferSelect;
export type AiProposalRecord = typeof aiProposals.$inferSelect;
export interface AiProviderInput {
userId: string;
providerType: string;
label: string;
baseUrl?: string | null;
apiKey?: string | null;
defaultModel?: string | null;
enabled?: boolean;
}
/**
* Keeps the first few characters so the UI can tell two keys apart without
* ever receiving the key itself.
*/
export function apiKeyPrefix(apiKey: string | null | undefined): string | null {
if (!apiKey) return null;
return apiKey.slice(0, 6);
}
export class AiRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
/**
* Provider API keys are field-encrypted like any other credential. The key is
* derived from the row id, which does not exist until after the insert, so a
* new provider is written once and then re-encrypted in place with its real
* id -- the same approach AlertRepository uses for channel configs.
*/
private userDataKey(userId: string): Buffer | null {
try {
return DataCrypto.getUserDataKey(userId);
} catch {
// Crypto is not initialized (tests, early boot); leave the value as is.
return null;
}
}
private encryptApiKey(
apiKey: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return apiKey;
return DataCrypto.encryptRecord(
"ai_providers",
{ id: recordId, apiKey },
userId,
userDataKey,
).apiKey;
}
private decryptApiKey(
apiKey: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return apiKey;
try {
return DataCrypto.decryptRecord(
"ai_providers",
{ id: recordId, apiKey },
userId,
userDataKey,
).apiKey;
} catch {
// Rows written before encryption was enabled are still plaintext.
return apiKey;
}
}
// --- providers ---
/** Never includes the key material; callers get the masked prefix only. */
async listProviders(userId: string): Promise<AiProviderRecord[]> {
const rows = await this.context.drizzle
.select()
.from(aiProviders)
.where(eq(aiProviders.userId, userId))
.orderBy(aiProviders.id);
return rows.map((row) => ({ ...row, apiKey: null }));
}
async findProvider(
id: number,
userId: string,
): Promise<AiProviderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiProviders)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
.limit(1);
if (!rows[0]) return null;
return { ...rows[0], apiKey: null };
}
/**
* The one path that returns usable key material. Only the provider adapters
* call this, immediately before an outbound request.
*/
async findProviderWithSecret(
id: number,
userId: string,
): Promise<AiProviderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiProviders)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
.limit(1);
const row = rows[0];
if (!row) return null;
if (!row.apiKey) return row;
return {
...row,
apiKey: this.decryptApiKey(row.apiKey, userId, row.id),
};
}
async createProvider(input: AiProviderInput): Promise<AiProviderRecord> {
const [created] = await insertReturning(this.context, aiProviders, {
userId: input.userId,
providerType: input.providerType,
label: input.label,
baseUrl: input.baseUrl ?? null,
apiKey: input.apiKey ?? null,
apiKeyPrefix: apiKeyPrefix(input.apiKey),
defaultModel: input.defaultModel ?? null,
enabled: input.enabled ?? true,
});
if (input.apiKey) {
const encrypted = this.encryptApiKey(
input.apiKey,
input.userId,
created.id,
);
if (encrypted !== input.apiKey) {
await this.context.drizzle
.update(aiProviders)
.set({ apiKey: encrypted })
.where(eq(aiProviders.id, created.id));
}
}
await this.afterWrite();
return { ...created, apiKey: null };
}
async updateProvider(
id: number,
userId: string,
input: Partial<Omit<AiProviderInput, "userId">>,
): Promise<AiProviderRecord | null> {
const existing = await this.findProvider(id, userId);
if (!existing) return null;
const updates: Record<string, unknown> = { updatedAt: now() };
if (input.providerType !== undefined)
updates.providerType = input.providerType;
if (input.label !== undefined) updates.label = input.label;
if (input.baseUrl !== undefined) updates.baseUrl = input.baseUrl;
if (input.defaultModel !== undefined)
updates.defaultModel = input.defaultModel;
if (input.enabled !== undefined) updates.enabled = input.enabled;
// An empty string clears the key; undefined leaves it untouched.
if (input.apiKey !== undefined) {
if (input.apiKey) {
updates.apiKey = this.encryptApiKey(input.apiKey, userId, id);
updates.apiKeyPrefix = apiKeyPrefix(input.apiKey);
} else {
updates.apiKey = null;
updates.apiKeyPrefix = null;
}
}
await this.context.drizzle
.update(aiProviders)
.set(updates)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
await this.afterWrite();
return this.findProvider(id, userId);
}
async deleteProvider(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(aiProviders)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
const deleted = rowsAffected(result) > 0;
if (deleted) await this.afterWrite();
return deleted;
}
// --- conversations ---
async listConversations(
userId: string,
limit = 50,
): Promise<AiConversationRecord[]> {
return this.context.drizzle
.select()
.from(aiConversations)
.where(eq(aiConversations.userId, userId))
.orderBy(desc(aiConversations.updatedAt))
.limit(limit);
}
async findConversation(
id: number,
userId: string,
): Promise<AiConversationRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiConversations)
.where(
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async createConversation(input: {
userId: string;
title?: string | null;
providerId?: number | null;
model?: string | null;
}): Promise<AiConversationRecord> {
const [created] = await insertReturning(this.context, aiConversations, {
userId: input.userId,
title: input.title ?? null,
providerId: input.providerId ?? null,
model: input.model ?? null,
});
await this.afterWrite();
return created;
}
async touchConversation(id: number, title?: string | null): Promise<void> {
const updates: Record<string, unknown> = { updatedAt: now() };
if (title) updates.title = title;
await this.context.drizzle
.update(aiConversations)
.set(updates)
.where(eq(aiConversations.id, id));
await this.afterWrite();
}
async deleteConversation(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(aiConversations)
.where(
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
);
const deleted = rowsAffected(result) > 0;
if (deleted) await this.afterWrite();
return deleted;
}
// --- messages ---
async listMessages(conversationId: number): Promise<AiMessageRecord[]> {
return this.context.drizzle
.select()
.from(aiMessages)
.where(eq(aiMessages.conversationId, conversationId))
.orderBy(aiMessages.id);
}
async appendMessage(input: {
conversationId: number;
role: string;
content: string;
toolCalls?: string | null;
}): Promise<AiMessageRecord> {
const [created] = await insertReturning(this.context, aiMessages, {
conversationId: input.conversationId,
role: input.role,
content: input.content,
toolCalls: input.toolCalls ?? null,
});
await this.afterWrite();
return created;
}
// --- proposals ---
async listProposals(
userId: string,
conversationId?: number,
): Promise<AiProposalRecord[]> {
const where = conversationId
? and(
eq(aiProposals.userId, userId),
eq(aiProposals.conversationId, conversationId),
)
: eq(aiProposals.userId, userId);
return this.context.drizzle
.select()
.from(aiProposals)
.where(where)
.orderBy(desc(aiProposals.id));
}
async findProposal(
id: number,
userId: string,
): Promise<AiProposalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiProposals)
.where(and(eq(aiProposals.id, id), eq(aiProposals.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async createProposal(input: {
conversationId: number;
userId: string;
kind: string;
summary?: string | null;
payload: string;
}): Promise<AiProposalRecord> {
const [created] = await insertReturning(this.context, aiProposals, {
conversationId: input.conversationId,
userId: input.userId,
kind: input.kind,
summary: input.summary ?? null,
payload: input.payload,
status: "pending",
});
await this.afterWrite();
return created;
}
async setProposalStatus(
id: number,
userId: string,
status: "applied" | "rejected" | "expired",
resultSummary?: string | null,
): Promise<boolean> {
const result = await this.context.drizzle
.update(aiProposals)
.set({
status,
appliedAt: status === "applied" ? now() : null,
resultSummary: resultSummary ?? null,
})
.where(
and(
eq(aiProposals.id, id),
eq(aiProposals.userId, userId),
eq(aiProposals.status, "pending"),
),
);
const updated = rowsAffected(result) > 0;
if (updated) await this.afterWrite();
return updated;
}
// --- account deletion ---
async deleteByUserId(userId: string): Promise<void> {
// ai_messages and ai_proposals cascade from ai_conversations.
await this.context.drizzle
.delete(aiConversations)
.where(eq(aiConversations.userId, userId));
await this.context.drizzle
.delete(aiProviders)
.where(eq(aiProviders.userId, userId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -6,6 +6,7 @@ import {
hosts,
notificationChannels,
} from "../db/schema.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
import { rowsAffected } from "./mutation-result.js";
@@ -83,6 +84,56 @@ export class AlertRepository {
private readonly onWrite?: () => void | Promise<void>,
) {}
/**
* Channel configs carry ntfy tokens and webhook auth headers, so they are
* field-encrypted like any other secret. The key is derived from the row id,
* which does not exist until after the insert, so a new channel is written
* once and then re-encrypted in place with its real id.
*/
private userDataKey(userId: string): Buffer | null {
try {
return DataCrypto.getUserDataKey(userId);
} catch {
// Crypto is not initialized (tests, early boot); leave the value as is.
return null;
}
}
private encryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
return DataCrypto.encryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
}
private decryptConfig(
config: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return config;
try {
return DataCrypto.decryptRecord(
"notification_channels",
{ id: recordId, config },
userId,
userDataKey,
).config;
} catch {
// Rows written before channel configs were encrypted are still plaintext.
return config;
}
}
async listNotificationChannels(
userId: string,
): Promise<NotificationChannelRow[]> {
@@ -92,7 +143,11 @@ export class AlertRepository {
.where(eq(notificationChannels.userId, userId))
.orderBy(notificationChannels.id);
return rows.map(mapChannelRow);
return rows.map((row) => {
const mapped = mapChannelRow(row);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
});
}
async findNotificationChannelForUser(
@@ -110,7 +165,10 @@ export class AlertRepository {
)
.limit(1);
return rows[0] ? mapChannelRow(rows[0]) : null;
if (!rows[0]) return null;
const mapped = mapChannelRow(rows[0]);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async createNotificationChannel(input: {
@@ -132,8 +190,22 @@ export class AlertRepository {
},
);
const encrypted = this.encryptConfig(
input.config,
input.userId,
created.id,
);
if (encrypted !== input.config) {
await this.context.drizzle
.update(notificationChannels)
.set({ config: encrypted })
.where(eq(notificationChannels.id, created.id));
}
await this.afterWrite();
return mapChannelRow(created);
const mapped = mapChannelRow(created);
mapped.config = input.config;
return mapped;
}
async updateNotificationChannel(
@@ -150,10 +222,15 @@ export class AlertRepository {
return this.findNotificationChannelForUser(id, userId);
}
const values =
input.config !== undefined
? { ...input, config: this.encryptConfig(input.config, userId, id) }
: input;
const [updated] = await updateReturning(
this.context,
notificationChannels,
input,
values,
and(
eq(notificationChannels.id, id),
eq(notificationChannels.userId, userId),
@@ -162,7 +239,9 @@ export class AlertRepository {
if (!updated) return null;
await this.afterWrite();
return mapChannelRow(updated);
const mapped = mapChannelRow(updated);
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
return mapped;
}
async deleteNotificationChannel(
@@ -358,17 +437,25 @@ export class AlertRepository {
await this.afterWrite();
}
// A rule with host_id IS NULL means "all my hosts", not "all hosts on the
// server". Without joining the host back to its owner, every user's wildcard
// rule fired for every polled host and leaked other users' host names into
// their alerts.
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
const rows = await this.context.drizzle
.select()
.select({ rule: alertRules })
.from(alertRules)
.innerJoin(hosts, eq(hosts.id, hostId))
.where(
and(
eq(alertRules.enabled, true),
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
or(
eq(alertRules.hostId, hostId),
and(isNull(alertRules.hostId), eq(alertRules.userId, hosts.userId)),
),
),
);
return rows.map(mapEngineRule);
return rows.map((row) => mapEngineRule(row.rule));
}
async listEnabledRulesForHostUser(
@@ -489,6 +576,7 @@ export class AlertRepository {
const rows = await this.context.drizzle
.select({
id: notificationChannels.id,
userId: notificationChannels.userId,
type: notificationChannels.type,
config: notificationChannels.config,
enabled: notificationChannels.enabled,
@@ -505,7 +593,11 @@ export class AlertRepository {
),
);
return rows;
// The engine sends without a user in scope, so decrypt against the owner.
return rows.map(({ userId, ...channel }) => ({
...channel,
config: this.decryptConfig(channel.config, userId, channel.id),
}));
}
async getHostDisplayName(hostId: number): Promise<string | null> {
@@ -49,6 +49,19 @@ export function auditMaxEntries(env: NodeJS.ProcessEnv = process.env): number {
}
export class AuditLogRepository {
/**
* Cached row count backing the cap check. Static because the factory builds
* a repository per call, so a per-instance count would never survive to be
* reused. Null means "unknown, re-read" which is also how any path that
* deletes rows invalidates it.
*/
private static cachedCount: number | null = null;
/** Drops the cached count so a test starts from a known state. */
static resetPruneThrottleForTests(): void {
AuditLogRepository.cachedCount = null;
}
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
@@ -56,7 +69,7 @@ export class AuditLogRepository {
async create(entry: NewAuditLogRecord): Promise<void> {
await this.context.drizzle.insert(auditLogs).values(entry);
await this.pruneIfNeeded();
await this.pruneIfDue();
await this.afterWrite();
}
@@ -140,6 +153,8 @@ export class AuditLogRepository {
}
async deleteByUserId(userId: string): Promise<number> {
// Row count changed outside the insert path; force a re-read.
AuditLogRepository.cachedCount = null;
const result = await this.context.drizzle
.delete(auditLogs)
.where(eq(auditLogs.userId, userId));
@@ -172,9 +187,73 @@ export class AuditLogRepository {
return conditions.length > 0 ? and(...conditions) : undefined;
}
private async pruneIfNeeded(): Promise<void> {
/**
* Keeps the two prune passes off the per-write hot path without letting the
* row cap go unenforced.
*
* Both passes used to run inline on every insert: a retention DELETE plus a
* COUNT over the whole table, thousands of times an hour on a busy install,
* almost always to find nothing to do and audit writes sit in the request
* path of the actions they record.
*
* They are split by what they cost and what they guarantee. Retention is
* time-based, so nothing is lost by checking it on an interval. The row cap
* is a disk-space guard that has to react to inserts, so it is still checked
* on the write that crosses it but against a cached count, so the common
* case is an integer compare rather than a COUNT.
*/
private async pruneIfDue(): Promise<void> {
try {
// Retention only costs anything on installs that configure it, and the
// DELETE is driven by idx_audit_logs_timestamp, so it stays on the write
// path where its "nothing older than N days survives" guarantee holds.
await this.pruneExpired();
await this.pruneOverflowIfOverCap();
} catch (error) {
// Pruning is maintenance; never fail the write that triggered it.
databaseLogger.warn("Audit log prune failed", {
operation: "audit_prune_failed",
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Enforces the cap using a cached row count, so a steady stream of writes
* costs one COUNT to prime and then nothing until the cap is next reached.
*/
private async pruneOverflowIfOverCap(): Promise<void> {
const max = auditMaxEntries();
if (AuditLogRepository.cachedCount === null) {
AuditLogRepository.cachedCount = await this.countAll();
} else {
AuditLogRepository.cachedCount += 1;
}
if (AuditLogRepository.cachedCount < max) return;
// At the cap: re-read for real, since the cached value can drift if rows
// were deleted by another path (user deletion, manual cleanup).
AuditLogRepository.cachedCount = await this.countAll();
if (AuditLogRepository.cachedCount < max) return;
await this.pruneOverflow();
AuditLogRepository.cachedCount = await this.countAll();
}
private async countAll(): Promise<number> {
const result = await this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs);
return countValue(result[0]?.count);
}
/** Runs the prune regardless of the interval. Exposed for tests and startup. */
async pruneNow(): Promise<void> {
await this.pruneExpired();
await this.pruneOverflow();
AuditLogRepository.cachedCount = null;
}
/** Drops entries past the configured retention window. */
@@ -0,0 +1,784 @@
import { and, asc, desc, eq, inArray, lt, lte, sql } from "drizzle-orm";
import {
automationChannels,
automationRunSteps,
automationRuns,
automationSchedules,
automationTriggerState,
automations,
notificationChannels,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
type AutomationRecord = typeof automations.$inferSelect;
type AutomationRunRecord = typeof automationRuns.$inferSelect;
type AutomationRunStepRecord = typeof automationRunSteps.$inferSelect;
type TriggerStateRecord = typeof automationTriggerState.$inferSelect;
type ScheduleRecord = typeof automationSchedules.$inferSelect;
export interface AutomationRow {
id: number;
user_id: string;
name: string;
description: string | null;
enabled: number;
definition: string;
definition_version: number;
concurrency_policy: string;
max_run_seconds: number;
dry_run: number;
last_run_at: string | null;
last_run_status: string | null;
created_at: string;
updated_at: string;
channels: number[];
}
export interface AutomationRunRow {
id: number;
automation_id: number;
user_id: string;
trigger_type: string;
trigger_context: string | null;
status: string;
started_at: string;
finished_at: string | null;
duration_ms: number | null;
error: string | null;
dry_run: number;
parent_run_id: number | null;
automation_name?: string | null;
}
export interface AutomationRunStepRow {
id: number;
run_id: number;
step_index: number;
step_id: string;
step_type: string;
status: string;
started_at: string;
finished_at: string | null;
output: string | null;
error: string | null;
truncated: number;
}
/** The shape the engine loads; camelCase and already parsed where useful. */
export interface AutomationEngineRow {
id: number;
userId: string;
name: string;
enabled: boolean;
definition: string;
concurrencyPolicy: string;
maxRunSeconds: number;
dryRun: boolean;
}
export interface TriggerStateRow {
automationId: number;
stateKey: string;
breachStartedAt: string | null;
lastFiredAt: string | null;
lastValue: number | null;
lastObservedState: string | null;
}
export interface DueScheduleRow {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}
export class AutomationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async list(userId: string): Promise<AutomationRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.userId, userId))
.orderBy(asc(automations.name));
if (rows.length === 0) return [];
// One query for every automation's channels rather than one per row.
const links = await this.context.drizzle
.select({
automationId: automationChannels.automationId,
channelId: automationChannels.channelId,
})
.from(automationChannels)
.where(
inArray(
automationChannels.automationId,
rows.map((row) => row.id),
),
);
const byAutomation = new Map<number, number[]>();
for (const link of links) {
const list = byAutomation.get(link.automationId) ?? [];
list.push(link.channelId);
byAutomation.set(link.automationId, list);
}
return rows.map((row) => mapAutomationRow(row, byAutomation.get(row.id)));
}
async findForUser(id: number, userId: string): Promise<AutomationRow | null> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)))
.limit(1);
if (!rows[0]) return null;
return mapAutomationRow(rows[0], await this.listChannelIds(id));
}
async findById(id: number): Promise<AutomationEngineRow | null> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.id, id))
.limit(1);
return rows[0] ? mapEngineRow(rows[0]) : null;
}
async create(input: {
userId: string;
name: string;
description?: string | null;
enabled?: boolean;
definition: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
now?: string;
}): Promise<AutomationRow> {
const now = input.now ?? new Date().toISOString();
const [created] = await insertReturning(this.context, automations, {
userId: input.userId,
name: input.name,
description: input.description ?? null,
enabled: input.enabled ?? true,
definition: input.definition,
concurrencyPolicy: input.concurrencyPolicy ?? "skip",
maxRunSeconds: input.maxRunSeconds ?? 300,
dryRun: input.dryRun ?? false,
createdAt: now,
updatedAt: now,
});
const channels = await this.replaceChannels(
created.id,
input.userId,
input.channels ?? [],
);
await this.afterWrite();
return mapAutomationRow(created, channels);
}
async update(
id: number,
userId: string,
input: {
name?: string;
description?: string | null;
enabled?: boolean;
definition?: string;
concurrencyPolicy?: string;
maxRunSeconds?: number;
dryRun?: boolean;
channels?: number[];
now?: string;
},
): Promise<AutomationRow | null> {
const { channels, now, ...fields } = input;
const values: Record<string, unknown> = { ...fields };
if (Object.keys(values).length > 0) {
values.updatedAt = now ?? new Date().toISOString();
const [updated] = await updateReturning(
this.context,
automations,
values,
and(eq(automations.id, id), eq(automations.userId, userId)),
);
if (!updated) return null;
} else {
const existing = await this.findForUser(id, userId);
if (!existing) return null;
}
if (channels) {
await this.replaceChannels(id, userId, channels);
}
await this.afterWrite();
return this.findForUser(id, userId);
}
async delete(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(automations)
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
const deleted = rowsAffected(result) > 0;
if (deleted) await this.afterWrite();
return deleted;
}
async setEnabled(
id: number,
userId: string,
enabled: boolean,
): Promise<boolean> {
const result = await this.context.drizzle
.update(automations)
.set({ enabled, updatedAt: new Date().toISOString() })
.where(and(eq(automations.id, id), eq(automations.userId, userId)));
const changed = rowsAffected(result) > 0;
if (changed) await this.afterWrite();
return changed;
}
/**
* Enabled automations owned by whoever owns the given host. Wildcard targets
* must never reach across users, which is the bug the alert engine shipped
* with.
*/
async listEnabledForUser(userId: string): Promise<AutomationEngineRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(
and(eq(automations.enabled, true), eq(automations.userId, userId)),
);
return rows.map(mapEngineRow);
}
async listAllEnabled(): Promise<AutomationEngineRow[]> {
const rows = await this.context.drizzle
.select()
.from(automations)
.where(eq(automations.enabled, true));
return rows.map(mapEngineRow);
}
// --- trigger state ---
async getTriggerState(
automationId: number,
stateKey: string,
): Promise<TriggerStateRow | null> {
const rows = await this.context.drizzle
.select()
.from(automationTriggerState)
.where(
and(
eq(automationTriggerState.automationId, automationId),
eq(automationTriggerState.stateKey, stateKey),
),
)
.limit(1);
return rows[0] ? mapTriggerStateRow(rows[0]) : null;
}
async upsertTriggerState(input: {
automationId: number;
stateKey: string;
breachStartedAt?: string | null;
lastFiredAt?: string | null;
lastValue?: number | null;
lastObservedState?: string | null;
}): Promise<void> {
const existing = await this.getTriggerState(
input.automationId,
input.stateKey,
);
const updatedAt = new Date().toISOString();
if (existing) {
const values: Record<string, unknown> = { updatedAt };
if (input.breachStartedAt !== undefined)
values.breachStartedAt = input.breachStartedAt;
if (input.lastFiredAt !== undefined)
values.lastFiredAt = input.lastFiredAt;
if (input.lastValue !== undefined) values.lastValue = input.lastValue;
if (input.lastObservedState !== undefined)
values.lastObservedState = input.lastObservedState;
await this.context.drizzle
.update(automationTriggerState)
.set(values)
.where(
and(
eq(automationTriggerState.automationId, input.automationId),
eq(automationTriggerState.stateKey, input.stateKey),
),
);
} else {
await this.context.drizzle.insert(automationTriggerState).values({
automationId: input.automationId,
stateKey: input.stateKey,
breachStartedAt: input.breachStartedAt ?? null,
lastFiredAt: input.lastFiredAt ?? null,
lastValue: input.lastValue ?? null,
lastObservedState: input.lastObservedState ?? null,
updatedAt,
});
}
await this.afterWrite();
}
async clearBreach(automationId: number, stateKey: string): Promise<void> {
await this.context.drizzle
.update(automationTriggerState)
.set({ breachStartedAt: null, updatedAt: new Date().toISOString() })
.where(
and(
eq(automationTriggerState.automationId, automationId),
eq(automationTriggerState.stateKey, stateKey),
),
);
await this.afterWrite();
}
/** Dwell windows the scheduler has to re-check without a fresh sample. */
async listOpenBreaches(): Promise<TriggerStateRow[]> {
const rows = await this.context.drizzle
.select()
.from(automationTriggerState)
.where(sql`${automationTriggerState.breachStartedAt} IS NOT NULL`);
return rows.map(mapTriggerStateRow);
}
// --- schedules ---
async upsertSchedule(input: {
automationId: number;
cron: string | null;
intervalSeconds: number | null;
timezone: string | null;
nextDueAt: string | null;
}): Promise<void> {
const existing = await this.context.drizzle
.select({ id: automationSchedules.id })
.from(automationSchedules)
.where(eq(automationSchedules.automationId, input.automationId))
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(automationSchedules)
.set({
cron: input.cron,
intervalSeconds: input.intervalSeconds,
timezone: input.timezone,
nextDueAt: input.nextDueAt,
})
.where(eq(automationSchedules.automationId, input.automationId));
} else {
await this.context.drizzle.insert(automationSchedules).values(input);
}
await this.afterWrite();
}
async deleteSchedule(automationId: number): Promise<void> {
await this.context.drizzle
.delete(automationSchedules)
.where(eq(automationSchedules.automationId, automationId));
await this.afterWrite();
}
/** Schedules due at or before `now`, joined to their enabled automation. */
async listDueSchedules(now: string): Promise<DueScheduleRow[]> {
const rows = await this.context.drizzle
.select({
automationId: automationSchedules.automationId,
cron: automationSchedules.cron,
intervalSeconds: automationSchedules.intervalSeconds,
timezone: automationSchedules.timezone,
nextDueAt: automationSchedules.nextDueAt,
})
.from(automationSchedules)
.innerJoin(
automations,
eq(automations.id, automationSchedules.automationId),
)
.where(
and(
eq(automations.enabled, true),
lte(automationSchedules.nextDueAt, now),
),
);
return rows;
}
async markScheduleTicked(
automationId: number,
nextDueAt: string | null,
lastTickAt: string,
): Promise<void> {
await this.context.drizzle
.update(automationSchedules)
.set({ nextDueAt, lastTickAt })
.where(eq(automationSchedules.automationId, automationId));
await this.afterWrite();
}
// --- runs ---
async createRun(input: {
automationId: number;
userId: string;
triggerType: string;
triggerContext?: string | null;
status: string;
dryRun?: boolean;
parentRunId?: number | null;
now?: string;
}): Promise<AutomationRunRow> {
const [created] = await insertReturning(this.context, automationRuns, {
automationId: input.automationId,
userId: input.userId,
triggerType: input.triggerType,
triggerContext: input.triggerContext ?? null,
status: input.status,
startedAt: input.now ?? new Date().toISOString(),
dryRun: input.dryRun ?? false,
parentRunId: input.parentRunId ?? null,
});
await this.afterWrite();
return mapRunRow(created);
}
async finishRun(
runId: number,
input: {
status: string;
error?: string | null;
finishedAt?: string;
durationMs?: number | null;
},
): Promise<void> {
const finishedAt = input.finishedAt ?? new Date().toISOString();
await this.context.drizzle
.update(automationRuns)
.set({
status: input.status,
error: input.error ?? null,
finishedAt,
durationMs: input.durationMs ?? null,
})
.where(eq(automationRuns.id, runId));
const run = await this.context.drizzle
.select({
automationId: automationRuns.automationId,
startedAt: automationRuns.startedAt,
})
.from(automationRuns)
.where(eq(automationRuns.id, runId))
.limit(1);
if (run[0]) {
await this.context.drizzle
.update(automations)
.set({ lastRunAt: run[0].startedAt, lastRunStatus: input.status })
.where(eq(automations.id, run[0].automationId));
}
await this.afterWrite();
}
async listRuns(
userId: string,
options: { automationId?: number; limit?: number; offset?: number } = {},
): Promise<AutomationRunRow[]> {
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
const offset = Math.max(options.offset ?? 0, 0);
const where = options.automationId
? and(
eq(automationRuns.userId, userId),
eq(automationRuns.automationId, options.automationId),
)
: eq(automationRuns.userId, userId);
const rows = await this.context.drizzle
.select({
run: automationRuns,
automationName: automations.name,
})
.from(automationRuns)
.leftJoin(automations, eq(automations.id, automationRuns.automationId))
.where(where)
.orderBy(desc(automationRuns.startedAt), desc(automationRuns.id))
.limit(limit)
.offset(offset);
return rows.map((row) => ({
...mapRunRow(row.run),
automation_name: row.automationName ?? null,
}));
}
async findRunForUser(
runId: number,
userId: string,
): Promise<AutomationRunRow | null> {
const rows = await this.context.drizzle
.select()
.from(automationRuns)
.where(
and(eq(automationRuns.id, runId), eq(automationRuns.userId, userId)),
)
.limit(1);
return rows[0] ? mapRunRow(rows[0]) : null;
}
async countRunningFor(automationId: number): Promise<number> {
const rows = await this.context.drizzle
.select({ id: automationRuns.id })
.from(automationRuns)
.where(
and(
eq(automationRuns.automationId, automationId),
eq(automationRuns.status, "running"),
),
);
return rows.length;
}
// --- run steps ---
async createRunStep(input: {
runId: number;
stepIndex: number;
stepId: string;
stepType: string;
status: string;
now?: string;
}): Promise<number> {
const [created] = await insertReturning(this.context, automationRunSteps, {
runId: input.runId,
stepIndex: input.stepIndex,
stepId: input.stepId,
stepType: input.stepType,
status: input.status,
startedAt: input.now ?? new Date().toISOString(),
});
await this.afterWrite();
return created.id;
}
async finishRunStep(
stepRowId: number,
input: {
status: string;
output?: string | null;
error?: string | null;
truncated?: boolean;
finishedAt?: string;
},
): Promise<void> {
await this.context.drizzle
.update(automationRunSteps)
.set({
status: input.status,
output: input.output ?? null,
error: input.error ?? null,
truncated: input.truncated ?? false,
finishedAt: input.finishedAt ?? new Date().toISOString(),
})
.where(eq(automationRunSteps.id, stepRowId));
await this.afterWrite();
}
async listRunSteps(runId: number): Promise<AutomationRunStepRow[]> {
const rows = await this.context.drizzle
.select()
.from(automationRunSteps)
.where(eq(automationRunSteps.runId, runId))
.orderBy(asc(automationRunSteps.stepIndex));
return rows.map(mapRunStepRow);
}
/**
* Trims run history. Called from the scheduler's daily sweep rather than on
* every write, which is what made the alert engine's pruning expensive.
*/
async pruneRunsOlderThan(days: number): Promise<number> {
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
const result = await this.context.drizzle
.delete(automationRuns)
.where(lt(automationRuns.startedAt, cutoff));
const deleted = rowsAffected(result);
if (deleted > 0) await this.afterWrite();
return deleted;
}
/** Marks runs left behind by a crash so they do not block concurrency. */
async failStaleRunningRuns(olderThanIso: string): Promise<number> {
const result = await this.context.drizzle
.update(automationRuns)
.set({
status: "failed",
error: "Interrupted by a server restart",
finishedAt: new Date().toISOString(),
})
.where(
and(
eq(automationRuns.status, "running"),
lt(automationRuns.startedAt, olderThanIso),
),
);
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(automations)
.where(eq(automations.userId, userId));
const deleted = rowsAffected(result);
if (deleted > 0) await this.afterWrite();
return deleted;
}
private async listChannelIds(automationId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ channelId: automationChannels.channelId })
.from(automationChannels)
.where(eq(automationChannels.automationId, automationId));
return rows.map((row) => row.channelId);
}
private async replaceChannels(
automationId: number,
userId: string,
channelIds: number[],
): Promise<number[]> {
await this.context.drizzle
.delete(automationChannels)
.where(eq(automationChannels.automationId, automationId));
if (channelIds.length === 0) return [];
// One lookup for the whole set instead of a query per channel.
const owned = await this.context.drizzle
.select({ id: notificationChannels.id })
.from(notificationChannels)
.where(
and(
eq(notificationChannels.userId, userId),
inArray(notificationChannels.id, channelIds),
),
);
const linked = owned.map((row) => row.id);
if (linked.length > 0) {
await this.context.drizzle
.insert(automationChannels)
.values(linked.map((channelId) => ({ automationId, channelId })));
}
return linked;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function mapAutomationRow(
row: AutomationRecord,
channels: number[] = [],
): AutomationRow {
return {
id: row.id,
user_id: row.userId,
name: row.name,
description: row.description ?? null,
enabled: row.enabled ? 1 : 0,
definition: row.definition,
definition_version: row.definitionVersion,
concurrency_policy: row.concurrencyPolicy,
max_run_seconds: row.maxRunSeconds,
dry_run: row.dryRun ? 1 : 0,
last_run_at: row.lastRunAt ?? null,
last_run_status: row.lastRunStatus ?? null,
created_at: row.createdAt,
updated_at: row.updatedAt,
channels,
};
}
function mapEngineRow(row: AutomationRecord): AutomationEngineRow {
return {
id: row.id,
userId: row.userId,
name: row.name,
enabled: !!row.enabled,
definition: row.definition,
concurrencyPolicy: row.concurrencyPolicy,
maxRunSeconds: row.maxRunSeconds,
dryRun: !!row.dryRun,
};
}
function mapTriggerStateRow(row: TriggerStateRecord): TriggerStateRow {
return {
automationId: row.automationId,
stateKey: row.stateKey,
breachStartedAt: row.breachStartedAt ?? null,
lastFiredAt: row.lastFiredAt ?? null,
lastValue: row.lastValue ?? null,
lastObservedState: row.lastObservedState ?? null,
};
}
function mapRunRow(row: AutomationRunRecord): AutomationRunRow {
return {
id: row.id,
automation_id: row.automationId,
user_id: row.userId,
trigger_type: row.triggerType,
trigger_context: row.triggerContext ?? null,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
duration_ms: row.durationMs ?? null,
error: row.error ?? null,
dry_run: row.dryRun ? 1 : 0,
parent_run_id: row.parentRunId ?? null,
};
}
function mapRunStepRow(row: AutomationRunStepRecord): AutomationRunStepRow {
return {
id: row.id,
run_id: row.runId,
step_index: row.stepIndex,
step_id: row.stepId,
step_type: row.stepType,
status: row.status,
started_at: row.startedAt,
finished_at: row.finishedAt ?? null,
output: row.output ?? null,
error: row.error ?? null,
truncated: row.truncated ? 1 : 0,
};
}
@@ -212,6 +212,56 @@ export class CredentialRepository {
return this.decryptOne(rows[0] ?? null, userId);
}
/**
* Manual drag-to-reorder write path. Updates sortOrder for each id one row
* at a time inside a transaction rather than a single set-for-all-matching
* -ids statement, matching HostRepository.reorderForUser.
*/
async reorderForUser(
userId: string,
positions: { id: number; sortOrder: number }[],
): Promise<number> {
if (positions.length === 0) return 0;
let affected: number;
if (this.context.dialect === "sqlite") {
affected = this.context.drizzle.transaction((tx) => {
let count = 0;
for (const { id, sortOrder } of positions) {
const result = tx
.update(sshCredentials)
.set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)),
)
.run();
count += rowsAffected(result);
}
return count;
});
} else {
affected = await this.context.drizzle.transaction(async (tx) => {
let count = 0;
for (const { id, sortOrder } of positions) {
const result = await tx
.update(sshCredentials)
.set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)),
);
count += rowsAffected(result);
}
return count;
});
}
if (affected > 0) {
await this.afterWrite();
}
return affected;
}
async deleteForUser(
userId: string,
credentialId: number,
@@ -0,0 +1,71 @@
import { eq } from "drizzle-orm";
import { credentialSidebarPreferences } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturningWhere, updateReturning } from "./returning.js";
export type CredentialSidebarPreferenceRecord =
typeof credentialSidebarPreferences.$inferSelect;
export class CredentialSidebarPreferenceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserId(
userId: string,
): Promise<CredentialSidebarPreferenceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(credentialSidebarPreferences)
.where(eq(credentialSidebarPreferences.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async upsert(
userId: string,
data: string,
now = new Date().toISOString(),
): Promise<CredentialSidebarPreferenceRecord> {
const existing = await this.findByUserId(userId);
if (!existing) {
const rows = await insertReturningWhere(
this.context,
credentialSidebarPreferences,
{ userId, data, updatedAt: now },
eq(credentialSidebarPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
const rows = await updateReturning(
this.context,
credentialSidebarPreferences,
{ data, updatedAt: now },
eq(credentialSidebarPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(credentialSidebarPreferences)
.where(eq(credentialSidebarPreferences.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+101 -1
View File
@@ -4,7 +4,9 @@ import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AiRepository } from "./ai-repository.js";
import { AlertRepository } from "./alert-repository.js";
import { AutomationRepository } from "./automation-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
@@ -13,14 +15,20 @@ import { CredentialRepository } from "./credential-repository.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
import { FleetRepository } from "./fleet-repository.js";
import { FleetInventoryRepository } from "./fleet-inventory-repository.js";
import { HomepageItemRepository } from "./homepage-item-repository.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
import { HostHealthRepository } from "./host-health-repository.js";
import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
import { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
import { HostRepository } from "./host-repository.js";
import { HostResolutionRepository } from "./host-resolution-repository.js";
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { NetworkTopologyRepository } from "./network-topology-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
@@ -47,6 +55,7 @@ import { UserPreferenceRepository } from "./user-preference-repository.js";
import { UserRepository } from "./user-repository.js";
import { VaultProfileRepository } from "./vault-profile-repository.js";
import { VaultTokenRepository } from "./vault-token-repository.js";
import { WorkspaceRepository } from "./workspace-repository.js";
/**
* The context every repository runs against.
@@ -82,6 +91,23 @@ export function createCurrentRepositoryWriteHook(
return () => DatabaseSaveTrigger.forceSave(reason);
}
/**
* Post-write hook for high-frequency, non-critical writes (telemetry
* inserts/cleanup, informational timestamp touches).
*
* Marks the in-memory database dirty and lets DatabaseSaveTrigger's existing
* debounce coalesce the actual serialize+encrypt, instead of forcing one on
* every single sample. A lost 2-second window of telemetry on crash is
* acceptable; blocking the event loop that serves SSH traffic on every metric
* sample is not.
*/
export function createCurrentRepositoryLazyWriteHook(
reason: string,
): (() => Promise<void>) | undefined {
if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined;
return () => DatabaseSaveTrigger.triggerSave(reason);
}
/**
* Raw driver handle for the few synchronous call sites that cannot await
* getCurrentSettingValue below, and settings reads during startup. Repositories
@@ -118,6 +144,13 @@ export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialR
);
}
export function createCurrentAiRepository(): AiRepository {
return new AiRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("ai_repository_write"),
);
}
export function createCurrentAlertRepository(): AlertRepository {
return new AlertRepository(
createCurrentRepositoryContext(),
@@ -125,6 +158,13 @@ export function createCurrentAlertRepository(): AlertRepository {
);
}
export function createCurrentAutomationRepository(): AutomationRepository {
return new AutomationRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("automation_repository_write"),
);
}
export function createCurrentApiKeyRepository(): ApiKeyRepository {
return new ApiKeyRepository(
createCurrentRepositoryContext(),
@@ -188,6 +228,20 @@ export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmar
);
}
export function createCurrentFleetRepository(): FleetRepository {
return new FleetRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("fleet_repository_write"),
);
}
export function createCurrentFleetInventoryRepository(): FleetInventoryRepository {
return new FleetInventoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("fleet_inventory_repository_write"),
);
}
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
return new HomepageItemRepository(
createCurrentRepositoryContext(),
@@ -219,7 +273,9 @@ export function createCurrentHostHealthRepository(): HostHealthRepository {
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
return new HostMetricsHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_metrics_history_repository_write"),
createCurrentRepositoryLazyWriteHook(
"host_metrics_history_repository_write",
),
);
}
@@ -232,6 +288,15 @@ export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPrefe
);
}
export function createCurrentProxmoxNodeHistoryRepository(): ProxmoxNodeHistoryRepository {
return new ProxmoxNodeHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryLazyWriteHook(
"proxmox_node_history_repository_write",
),
);
}
export function createCurrentHostRepository(): HostRepository {
return new HostRepository(
createCurrentRepositoryContext(),
@@ -243,6 +308,34 @@ export function createCurrentHostResolutionRepository(): HostResolutionRepositor
return new HostResolutionRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_resolution_repository_write"),
createCurrentRepositoryLazyWriteHook(
"host_resolution_repository_lazy_write",
),
);
}
export function createCurrentHostSidebarPreferenceRepository(): HostSidebarPreferenceRepository {
return new HostSidebarPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"host_sidebar_preference_repository_write",
),
);
}
export function createCurrentCredentialSidebarPreferenceRepository(): CredentialSidebarPreferenceRepository {
return new CredentialSidebarPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"credential_sidebar_preference_repository_write",
),
);
}
export function createCurrentUiPreferenceRepository(): UiPreferenceRepository {
return new UiPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("ui_preference_repository_write"),
);
}
@@ -420,6 +513,13 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository {
);
}
export function createCurrentWorkspaceRepository(): WorkspaceRepository {
return new WorkspaceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("workspace_repository_write"),
);
}
/**
* Loads the settings cache. Must run during startup on engines without a
* synchronous read, before anything calls getCurrentSettingValue.
@@ -0,0 +1,90 @@
import { and, eq, inArray } from "drizzle-orm";
import { fleetInventory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning, updateReturning } from "./returning.js";
export type FleetInventoryRecord = typeof fleetInventory.$inferSelect;
export interface FleetInventoryInput {
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
}
export class FleetInventoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(
userId: string,
hostId: number,
input: FleetInventoryInput,
now = new Date().toISOString(),
): Promise<FleetInventoryRecord> {
const existing = await this.findOne(userId, hostId);
if (existing) {
const [updated] = await updateReturning(
this.context,
fleetInventory,
{ ...input, collectedAt: now },
eq(fleetInventory.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await insertReturning(this.context, fleetInventory, {
userId,
hostId,
...input,
collectedAt: now,
});
await this.afterWrite();
return created;
}
async findOne(
userId: string,
hostId: number,
): Promise<FleetInventoryRecord | null> {
const rows = await this.context.drizzle
.select()
.from(fleetInventory)
.where(
and(
eq(fleetInventory.userId, userId),
eq(fleetInventory.hostId, hostId),
),
)
.limit(1);
return rows[0] ?? null;
}
async listForHosts(
userId: string,
hostIds: number[],
): Promise<FleetInventoryRecord[]> {
if (hostIds.length === 0) return [];
return this.context.drizzle
.select()
.from(fleetInventory)
.where(
and(
eq(fleetInventory.userId, userId),
inArray(fleetInventory.hostId, hostIds),
),
);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -0,0 +1,235 @@
import { and, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { fleets, fleetMembers, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type FleetRecord = typeof fleets.$inferSelect;
export type FleetMemberHostRecord = typeof hosts.$inferSelect;
export interface FleetCreateInput {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetUpdateInput {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
function parseTagRules(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((t): t is string => typeof t === "string")
: [];
} catch {
return [];
}
}
export class FleetRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUser(userId: string): Promise<FleetRecord[]> {
return this.context.drizzle
.select()
.from(fleets)
.where(eq(fleets.userId, userId));
}
async findById(userId: string, fleetId: number): Promise<FleetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(fleets)
.where(and(eq(fleets.id, fleetId), eq(fleets.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async create(
userId: string,
input: FleetCreateInput,
now = new Date().toISOString(),
): Promise<FleetRecord> {
const [created] = await insertReturning(this.context, fleets, {
userId,
name: input.name,
description: input.description ?? null,
color: input.color ?? null,
icon: input.icon ?? null,
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
syncId: randomUUID(),
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
}
async update(
userId: string,
fleetId: number,
input: FleetUpdateInput,
now = new Date().toISOString(),
): Promise<FleetRecord | null> {
const existing = await this.findById(userId, fleetId);
if (!existing) return null;
const [updated] = await updateReturning(
this.context,
fleets,
{
name: input.name ?? existing.name,
description:
input.description === undefined
? existing.description
: input.description,
color: input.color === undefined ? existing.color : input.color,
icon: input.icon === undefined ? existing.icon : input.icon,
tagRules:
input.tagRules === undefined
? existing.tagRules
: JSON.stringify(input.tagRules),
updatedAt: now,
},
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
);
await this.afterWrite();
return updated ?? null;
}
async delete(userId: string, fleetId: number): Promise<boolean> {
const deleted = await deleteReturning(
this.context,
fleets,
and(eq(fleets.id, fleetId), eq(fleets.userId, userId)),
);
if (deleted.length > 0) {
await this.afterWrite();
return true;
}
return false;
}
async addMember(
fleetId: number,
hostId: number,
now = new Date().toISOString(),
): Promise<void> {
const existing = await this.context.drizzle
.select({ id: fleetMembers.id })
.from(fleetMembers)
.where(
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
)
.limit(1);
if (existing.length > 0) return;
await this.context.drizzle.insert(fleetMembers).values({
fleetId,
hostId,
addedAt: now,
});
await this.afterWrite();
}
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
const result = await this.context.drizzle
.delete(fleetMembers)
.where(
and(eq(fleetMembers.fleetId, fleetId), eq(fleetMembers.hostId, hostId)),
);
const affected = rowsAffected(result) > 0;
if (affected) await this.afterWrite();
return affected;
}
async listStaticMemberIds(fleetId: number): Promise<number[]> {
const rows = await this.context.drizzle
.select({ hostId: fleetMembers.hostId })
.from(fleetMembers)
.where(eq(fleetMembers.fleetId, fleetId));
return rows.map((r) => r.hostId);
}
/**
* Effective membership = static fleetMembers rows union hosts whose
* comma-separated tags string intersects any tag in the fleet's tagRules.
* Both sets are scoped to the fleet owner's hosts and deduplicated by id.
*/
async listEffectiveMembers(
userId: string,
fleetId: number,
): Promise<FleetMemberHostRecord[]> {
const fleet = await this.findById(userId, fleetId);
if (!fleet) return [];
const staticIds = await this.listStaticMemberIds(fleetId);
const tagRules = parseTagRules(fleet.tagRules);
const ownedHosts = await this.context.drizzle
.select()
.from(hosts)
.where(eq(hosts.userId, userId));
const byId = new Map<number, FleetMemberHostRecord>();
for (const host of ownedHosts) {
if (staticIds.includes(host.id)) {
byId.set(host.id, host);
continue;
}
if (tagRules.length === 0) continue;
const hostTags = (host.tags ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (hostTags.some((tag) => tagRules.includes(tag))) {
byId.set(host.id, host);
}
}
// Static members can reference hosts the fleet owner no longer owns
// (rare, but the FK cascade only fires on host delete, not transfer) -
// filter those out rather than surface a partial/foreign row.
return Array.from(byId.values());
}
async deleteByUserId(userId: string): Promise<number> {
const userFleets = await this.listByUser(userId);
const result = await this.context.drizzle
.delete(fleets)
.where(eq(fleets.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return userFleets.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -127,6 +127,84 @@ export class HostFolderRepository {
return { folder: created, created: true };
}
/**
* Sets a distinct manual sortOrder per sibling folder (drag-to-reorder).
* Folders with no existing sshFolders row are created first (matching the
* empty-folder-persists behavior elsewhere) so the order survives even for
* folders that only ever existed implicitly via host paths.
*/
async reorderFolders(
userId: string,
positions: { name: string; sortOrder: number }[],
now = new Date().toISOString(),
): Promise<number> {
if (positions.length === 0) return 0;
let affected: number;
if (this.context.dialect === "sqlite") {
affected = this.context.drizzle.transaction((tx) => {
let count = 0;
for (const { name, sortOrder } of positions) {
const result = tx
.update(sshFolders)
.set({ sortOrder, updatedAt: now })
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
)
.run();
if (rowsAffected(result) > 0) {
count += rowsAffected(result);
continue;
}
tx.insert(sshFolders)
.values({
syncId: randomUUID(),
userId,
name,
sortOrder,
createdAt: now,
updatedAt: now,
})
.run();
count += 1;
}
return count;
});
} else {
affected = await this.context.drizzle.transaction(async (tx) => {
let count = 0;
for (const { name, sortOrder } of positions) {
const result = await tx
.update(sshFolders)
.set({ sortOrder, updatedAt: now })
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
);
if (rowsAffected(result) > 0) {
count += rowsAffected(result);
continue;
}
await tx.insert(sshFolders).values({
syncId: randomUUID(),
userId,
name,
sortOrder,
createdAt: now,
updatedAt: now,
});
count += 1;
}
return count;
});
}
if (affected > 0) {
await this.afterWrite();
}
return affected;
}
async listHostsInFolder(
userId: string,
folderName: string,
@@ -234,6 +234,52 @@ export class HostRepository {
return rowsAffected(result);
}
/**
* Sets a distinct manual sortOrder per host (drag-to-reorder). Unlike
* updateManyForUser, each id gets its own value, so this is one UPDATE per
* row rather than a single set-for-all-matching-ids statement.
*/
async reorderForUser(
userId: string,
positions: { id: number; sortOrder: number }[],
): Promise<number> {
if (positions.length === 0) return 0;
let affected: number;
if (this.context.dialect === "sqlite") {
affected = this.context.drizzle.transaction((tx) => {
let count = 0;
for (const { id, sortOrder } of positions) {
const result = tx
.update(hosts)
.set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, id), eq(hosts.userId, userId)))
.run();
count += rowsAffected(result);
}
return count;
});
} else {
affected = await this.context.drizzle.transaction(async (tx) => {
let count = 0;
for (const { id, sortOrder } of positions) {
const result = await tx
.update(hosts)
.set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, id), eq(hosts.userId, userId)));
count += rowsAffected(result);
}
return count;
});
}
if (affected > 0) {
await this.afterWrite();
}
return affected;
}
async deleteForUser(
userId: string,
hostId: number,
@@ -20,6 +20,8 @@ export interface HostUpdateStateRecord {
telnetCredentialId: number | null;
vaultProfileId: number | null;
authType: string;
parentHostId: number | null;
folder: string | null;
}
export interface HostListAccessEntry {
hostId: number;
@@ -59,6 +61,11 @@ export class HostResolutionRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
// Informational only -- touchHostKeyLastVerified fires on every SSH
// connect and does not need an immediate encrypted-file rewrite the way
// storeHostKey/updateHostKey do. Keeping it separate from onWrite lets
// those two stay on the immediate forceSave path.
private readonly onLazyWrite?: () => void | Promise<void>,
) {}
async findHostById(
@@ -74,6 +81,24 @@ export class HostResolutionRepository {
return this.decryptOne("ssh_data", rows[0], userId);
}
/**
* Translates a sync identity into this database's own row id.
*
* Deliberately not scoped to a user: `sync_id` is unique across the table,
* and a host shared with the caller belongs to someone else. Whether the
* caller may reach the row is decided by the permission check that follows,
* not here.
*/
async findHostIdBySyncId(syncId: string): Promise<number | null> {
const rows = await this.context.drizzle
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.syncId, syncId))
.limit(1);
return rows[0]?.id ?? null;
}
async findHostByIdForUser(
hostId: number,
userId: string,
@@ -99,6 +124,8 @@ export class HostResolutionRepository {
telnetCredentialId: hosts.telnetCredentialId,
vaultProfileId: hosts.vaultProfileId,
authType: hosts.authType,
parentHostId: hosts.parentHostId,
folder: hosts.folder,
})
.from(hosts)
.where(eq(hosts.id, hostId))
@@ -107,6 +134,20 @@ export class HostResolutionRepository {
return rows[0] ?? null;
}
/**
* Minimal (id, parentHostId) rows for every host a user owns, used to walk
* ancestor chains when validating a sub-host parent assignment for cycles.
* No decryption needed -- parentHostId is a plain, unencrypted integer.
*/
async listOwnHostParentLinks(
userId: string,
): Promise<{ id: number; parentHostId: number | null }[]> {
return this.context.drizzle
.select({ id: hosts.id, parentHostId: hosts.parentHostId })
.from(hosts)
.where(eq(hosts.userId, userId));
}
async findHostsByUserId(userId: string): Promise<HostResolutionHostRecord[]> {
const rows = await this.context.drizzle
.select()
@@ -182,6 +223,22 @@ export class HostResolutionRepository {
return rows[0]?.ownerId ?? null;
}
/**
* Ids of the hosts this user owns, as a set.
*
* Callers that need to check ownership of many hosts at once (the status
* poll being the hot one) would otherwise issue isHostOwnedByUser per host,
* which is a query each and repeats on every poll.
*/
async listOwnedHostIds(userId: string): Promise<Set<number>> {
const rows = await this.context.drizzle
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.userId, userId));
return new Set(rows.map((row) => row.id));
}
async isHostOwnedByUser(hostId: number, userId: string): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: hosts.id })
@@ -290,7 +347,7 @@ export class HostResolutionRepository {
.update(hosts)
.set({ hostKeyLastVerified: now })
.where(eq(hosts.id, hostId));
await this.afterWrite();
await (this.onLazyWrite?.() ?? this.afterWrite());
}
async findCredentialByIdForUser(
@@ -311,6 +368,43 @@ export class HostResolutionRepository {
return this.decryptOne("ssh_credentials", rows[0], userId);
}
/**
* Batch form of findCredentialByIdForUser.
*
* The host list resolves a credential for every host it returns; issued one
* id at a time that is a query and a decrypt per host, which is the dominant
* cost of the list once an install has more than a few hundred of them.
*/
async listCredentialsByIdsForUser(
credentialIds: number[],
userId: string,
): Promise<Map<number, HostResolutionCredentialRecord>> {
const unique = Array.from(new Set(credentialIds));
if (unique.length === 0) return new Map();
const rows = await this.context.drizzle
.select()
.from(sshCredentials)
.where(
and(
inArray(sshCredentials.id, unique),
eq(sshCredentials.userId, userId),
),
);
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return new Map();
const byId = new Map<number, HostResolutionCredentialRecord>();
for (const row of rows) {
byId.set(
row.id,
DataCrypto.decryptRecord("ssh_credentials", row, userId, userDataKey),
);
}
return byId;
}
async findCredentialByIdForOwnerDecryptedAs(
credentialId: number,
ownerUserId: string,
@@ -0,0 +1,71 @@
import { eq } from "drizzle-orm";
import { hostSidebarPreferences } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturningWhere, updateReturning } from "./returning.js";
export type HostSidebarPreferenceRecord =
typeof hostSidebarPreferences.$inferSelect;
export class HostSidebarPreferenceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserId(
userId: string,
): Promise<HostSidebarPreferenceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(hostSidebarPreferences)
.where(eq(hostSidebarPreferences.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async upsert(
userId: string,
data: string,
now = new Date().toISOString(),
): Promise<HostSidebarPreferenceRecord> {
const existing = await this.findByUserId(userId);
if (!existing) {
const rows = await insertReturningWhere(
this.context,
hostSidebarPreferences,
{ userId, data, updatedAt: now },
eq(hostSidebarPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
const rows = await updateReturning(
this.context,
hostSidebarPreferences,
{ data, updatedAt: now },
eq(hostSidebarPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(hostSidebarPreferences)
.where(eq(hostSidebarPreferences.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -6,7 +6,7 @@ import { rowsAffected } from "./mutation-result.js";
export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
export type OpenTabUpdate = Partial<
Pick<NewOpenTabRecord, "label" | "tabOrder" | "backendSessionId">
Pick<NewOpenTabRecord, "hostId" | "label" | "tabOrder" | "backendSessionId">
>;
export type OpenTabUpsertInput = Pick<
@@ -0,0 +1,68 @@
import { and, asc, eq, gte, lt, lte } from "drizzle-orm";
import { proxmoxNodeHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
export type ProxmoxNodeHistoryRecord = typeof proxmoxNodeHistory.$inferSelect;
export interface ProxmoxNodeHistoryCreateInput {
hostId: number;
cpuPercent?: number | null;
memPercent?: number | null;
diskPercent?: number | null;
netRxBytes?: number | null;
netTxBytes?: number | null;
}
export class ProxmoxNodeHistoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(input: ProxmoxNodeHistoryCreateInput): Promise<void> {
await this.context.drizzle.insert(proxmoxNodeHistory).values({
hostId: input.hostId,
cpuPercent: input.cpuPercent,
memPercent: input.memPercent,
diskPercent: input.diskPercent,
netRxBytes: input.netRxBytes,
netTxBytes: input.netTxBytes,
});
await this.afterWrite();
}
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
await this.context.drizzle
.delete(proxmoxNodeHistory)
.where(
and(
eq(proxmoxNodeHistory.hostId, hostId),
lt(proxmoxNodeHistory.ts, sqlTimestampDaysAgo(retentionDays)),
),
);
}
async listRange(
hostId: number,
fromTs: string,
toTs: string,
): Promise<ProxmoxNodeHistoryRecord[]> {
return this.context.drizzle
.select()
.from(proxmoxNodeHistory)
.where(
and(
eq(proxmoxNodeHistory.hostId, hostId),
gte(proxmoxNodeHistory.ts, fromTs),
lte(proxmoxNodeHistory.ts, toTs),
),
)
.orderBy(asc(proxmoxNodeHistory.ts));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -59,6 +59,7 @@ export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
order: number;
createdAt: string;
updatedAt: string;
isNote: boolean;
}
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
@@ -438,6 +439,7 @@ export class RbacAccessRepository {
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
@@ -474,6 +476,7 @@ export class RbacAccessRepository {
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
hostFilter: snippets.hostFilter,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
@@ -7,6 +7,8 @@ import { insertReturning } from "./returning.js";
export type SessionRecord = typeof sessions.$inferSelect;
export type NewSessionRecord = typeof sessions.$inferInsert;
const SESSION_ACTIVITY_PERSIST_INTERVAL_MS = 60_000;
export class SessionRepository {
constructor(
private readonly context: DatabaseContext,
@@ -50,12 +52,19 @@ export class SessionRepository {
async touch(
id: string,
lastActiveAt = new Date().toISOString(),
): Promise<void> {
await this.context.drizzle
minIntervalMs = SESSION_ACTIVITY_PERSIST_INTERVAL_MS,
): Promise<boolean> {
const cutoff = new Date(
new Date(lastActiveAt).getTime() - minIntervalMs,
).toISOString();
const result = await this.context.drizzle
.update(sessions)
.set({ lastActiveAt })
.where(eq(sessions.id, id));
.where(and(eq(sessions.id, id), lte(sessions.lastActiveAt, cutoff)));
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async updateToken(
@@ -11,6 +11,12 @@
* rarely and are read constantly, so they are cached in full. Writes go through
* SettingsRepository, which updates the cache in the same call, and the cache is
* primed once at startup.
*
* Known limitation with more than one instance: a write only updates the cache
* of the process that made it. Other instances keep serving the old value until
* their own refresh comes round (SETTINGS_CACHE_REFRESH_SECONDS, 30s default),
* so a settings change takes up to that long to apply fleet-wide. Fixing it
* properly needs cross-instance invalidation, which is not in place yet.
*/
let cache: Map<string, string> | null = null;
@@ -51,6 +51,48 @@ export class SettingsRepository {
await this.afterWrite();
}
async setMany(entries: Array<{ key: string; value: string }>): Promise<void> {
if (this.context.dialect !== "sqlite") {
await this.context.drizzle.transaction(async (tx) => {
for (const { key, value } of entries) {
const existing = await tx
.select({ value: settings.value })
.from(settings)
.where(eq(settings.key, key))
.limit(1);
if (existing[0] === undefined) {
await tx.insert(settings).values({ key, value });
} else {
await tx
.update(settings)
.set({ value })
.where(eq(settings.key, key));
}
}
});
} else {
this.context.drizzle.transaction((tx) => {
for (const { key, value } of entries) {
const existing = tx
.select({ value: settings.value })
.from(settings)
.where(eq(settings.key, key))
.limit(1)
.all();
if (existing[0] === undefined) {
tx.insert(settings).values({ key, value }).run();
} else {
tx.update(settings)
.set({ value })
.where(eq(settings.key, key))
.run();
}
}
});
}
for (const { key, value } of entries) updateCachedSetting(key, value);
await this.afterWrite();
}
async upsert(key: string, value: string): Promise<void> {
await this.set(key, value);
}
@@ -26,6 +26,7 @@ export interface NewSnippetInput {
folder?: string | null;
order?: number | null;
hostFilter?: unknown;
isNote?: boolean;
}
export interface SnippetUpdateInput {
name?: string;
@@ -34,6 +35,7 @@ export interface SnippetUpdateInput {
folder?: string | null;
order?: number;
hostFilter?: unknown;
isNote?: boolean;
}
export interface UpdateSnippetResult {
existing: SnippetRecord;
@@ -169,6 +171,7 @@ export class SnippetRepository {
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
isNote: input.isNote ?? false,
});
await this.afterWrite();
@@ -191,6 +194,7 @@ export class SnippetRepository {
folder: string | null;
order: number;
hostFilter: string | null;
isNote: boolean;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
@@ -207,6 +211,7 @@ export class SnippetRepository {
updateFields.hostFilter = input.hostFilter
? JSON.stringify(input.hostFilter)
: null;
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
const rows = await updateReturning(
this.context,
@@ -0,0 +1,68 @@
import { eq } from "drizzle-orm";
import { uiPreferences } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturningWhere, updateReturning } from "./returning.js";
export type UiPreferenceRecord = typeof uiPreferences.$inferSelect;
export class UiPreferenceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserId(userId: string): Promise<UiPreferenceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(uiPreferences)
.where(eq(uiPreferences.userId, userId))
.limit(1);
return rows[0] ?? null;
}
async upsert(
userId: string,
data: string,
now = new Date().toISOString(),
): Promise<UiPreferenceRecord> {
const existing = await this.findByUserId(userId);
if (!existing) {
const rows = await insertReturningWhere(
this.context,
uiPreferences,
{ userId, data, updatedAt: now },
eq(uiPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
const rows = await updateReturning(
this.context,
uiPreferences,
{ data, updatedAt: now },
eq(uiPreferences.userId, userId),
);
await this.afterWrite();
return rows[0];
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(uiPreferences)
.where(eq(uiPreferences.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,7 +1,11 @@
import { eq, inArray } from "drizzle-orm";
import { asc, eq, inArray, like, sql } from "drizzle-orm";
import { users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected, supportsReturning } from "./mutation-result.js";
import {
countValue,
rowsAffected,
supportsReturning,
} from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type UserRecord = typeof users.$inferSelect;
@@ -19,6 +23,42 @@ export class UserRepository {
return this.context.drizzle.select().from(users);
}
/**
* One page of users, optionally filtered by username.
*
* The admin panel used to render every account at once, which is fine at
* home and not fine on a directory-backed install with thousands of them.
* Sorted by username so paging is stable between requests.
*/
async listPage(input: {
search?: string;
limit: number;
offset: number;
}): Promise<{ users: UserRecord[]; total: number }> {
const term = input.search?.trim();
const where = term
? like(sql`lower(${users.username})`, `%${term.toLowerCase()}%`)
: undefined;
const [rows, totalResult] = await Promise.all([
this.context.drizzle
.select()
.from(users)
.where(where)
// Case-insensitive: a plain sort puts every capitalised name ahead of
// every lowercase one, which reads as unordered in the admin list.
.orderBy(asc(sql`lower(${users.username})`))
.limit(input.limit)
.offset(input.offset),
this.context.drizzle
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(where),
]);
return { users: rows, total: countValue(totalResult[0]?.count) };
}
async findById(id: string): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select()
@@ -0,0 +1,266 @@
import { and, eq, ne } from "drizzle-orm";
import { randomUUID } from "crypto";
import { userWorkspaces } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type WorkspaceRecord = typeof userWorkspaces.$inferSelect;
export interface WorkspaceCreateInput {
name: string;
color?: string | null;
icon?: string | null;
payload: string;
}
export interface WorkspaceUpdateInput {
name?: string;
color?: string | null;
icon?: string | null;
}
export class WorkspaceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUser(userId: string): Promise<WorkspaceRecord[]> {
return this.context.drizzle
.select()
.from(userWorkspaces)
.where(eq(userWorkspaces.userId, userId));
}
async findById(userId: string, id: number): Promise<WorkspaceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(userWorkspaces)
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async findLastSession(userId: string): Promise<WorkspaceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(userWorkspaces)
.where(
and(
eq(userWorkspaces.userId, userId),
eq(userWorkspaces.kind, "last_session"),
),
)
.limit(1);
return rows[0] ?? null;
}
async findDefault(userId: string): Promise<WorkspaceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(userWorkspaces)
.where(
and(
eq(userWorkspaces.userId, userId),
eq(userWorkspaces.isDefault, true),
),
)
.limit(1);
return rows[0] ?? null;
}
async upsertLastSession(
userId: string,
payload: string,
now = new Date().toISOString(),
): Promise<WorkspaceRecord> {
const existing = await this.findLastSession(userId);
if (!existing) {
const [created] = await insertReturning(this.context, userWorkspaces, {
userId,
name: "Last Session",
color: null,
icon: null,
kind: "last_session",
isDefault: false,
payload,
syncId: randomUUID(),
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
}
const [updated] = await updateReturning(
this.context,
userWorkspaces,
{ payload, updatedAt: now },
and(
eq(userWorkspaces.id, existing.id),
eq(userWorkspaces.userId, userId),
),
);
await this.afterWrite();
return updated;
}
async create(
userId: string,
input: WorkspaceCreateInput,
now = new Date().toISOString(),
): Promise<WorkspaceRecord> {
const [created] = await insertReturning(this.context, userWorkspaces, {
userId,
name: input.name,
color: input.color ?? null,
icon: input.icon ?? null,
kind: "manual",
isDefault: false,
payload: input.payload,
syncId: randomUUID(),
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
}
async update(
userId: string,
id: number,
input: WorkspaceUpdateInput,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
const existing = await this.findById(userId, id);
if (!existing || existing.kind !== "manual") return null;
const [updated] = await updateReturning(
this.context,
userWorkspaces,
{
name: input.name ?? existing.name,
color: input.color === undefined ? existing.color : input.color,
icon: input.icon === undefined ? existing.icon : input.icon,
updatedAt: now,
},
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
);
await this.afterWrite();
return updated ?? null;
}
async updateContent(
userId: string,
id: number,
payload: string,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
const existing = await this.findById(userId, id);
if (!existing || existing.kind !== "manual") return null;
const [updated] = await updateReturning(
this.context,
userWorkspaces,
{ payload, updatedAt: now },
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
);
await this.afterWrite();
return updated ?? null;
}
async setDefault(
userId: string,
id: number,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
const existing = await this.findById(userId, id);
if (!existing || existing.kind !== "manual") return null;
await this.context.drizzle
.update(userWorkspaces)
.set({ isDefault: false, updatedAt: now })
.where(
and(
eq(userWorkspaces.userId, userId),
eq(userWorkspaces.isDefault, true),
ne(userWorkspaces.id, id),
),
);
const [updated] = await updateReturning(
this.context,
userWorkspaces,
{ isDefault: true, updatedAt: now },
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
);
await this.afterWrite();
return updated ?? null;
}
async unsetDefault(
userId: string,
id: number,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
const existing = await this.findById(userId, id);
if (!existing || existing.kind !== "manual") return null;
const [updated] = await updateReturning(
this.context,
userWorkspaces,
{ isDefault: false, updatedAt: now },
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
);
await this.afterWrite();
return updated ?? null;
}
async touchLastUsed(
userId: string,
id: number,
now = new Date().toISOString(),
): Promise<void> {
const result = await this.context.drizzle
.update(userWorkspaces)
.set({ lastUsedAt: now })
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
}
async delete(userId: string, id: number): Promise<boolean> {
const existing = await this.findById(userId, id);
if (!existing || existing.kind !== "manual") return false;
const result = await this.context.drizzle
.delete(userWorkspaces)
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
if (rowsAffected(result) > 0) {
await this.afterWrite();
return true;
}
return false;
}
async deleteByUserId(userId: string): Promise<number> {
const owned = await this.listByUser(userId);
const result = await this.context.drizzle
.delete(userWorkspaces)
.where(eq(userWorkspaces.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return owned.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+18 -4
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { execFileSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
@@ -5,6 +6,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { reloadNginxWithSSL } from "../../utils/nginx-ssl-reload.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
@@ -382,6 +384,8 @@ export function registerAcmeSSLRoutes(
operation: "acme_cert_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
@@ -394,9 +398,13 @@ export function registerAcmeSSLRoutes(
success: true,
});
res.json({ success: true, ...(await getAcmeSettings()) });
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
const message = getErrorMessage(err);
authLogger.error("ACME certificate request failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
@@ -535,6 +543,8 @@ export function registerAcmeSSLRoutes(
operation: "manual_ssl_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
@@ -547,9 +557,13 @@ export function registerAcmeSSLRoutes(
success: true,
});
res.json({ success: true, ...(await getAcmeSettings()) });
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
const message = getErrorMessage(err);
authLogger.error("Manual SSL certificate upload failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
@@ -4,6 +4,7 @@ import { createCurrentAlertRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
import { sendDiscord } from "../../utils/discord-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -70,8 +71,10 @@ router.post("/notification-channels", async (req, res) => {
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (type !== "webhook" && type !== "ntfy") {
return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
if (type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (!config || typeof config !== "object") {
return res.status(400).json({ error: "config is required" });
@@ -88,6 +91,21 @@ router.post("/notification-channels", async (req, res) => {
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "webhook config requires url" });
}
if (type === "discord") {
const c = config as Record<string, unknown>;
if (!c.url || typeof c.url !== "string")
return res.status(400).json({ error: "discord config requires url" });
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
c.url,
)
) {
return res.status(400).json({
error:
"discord config requires a valid Discord webhook URL (https://discord.com/api/webhooks/...)",
});
}
}
try {
const row = await createCurrentAlertRepository().createNotificationChannel({
@@ -134,10 +152,10 @@ router.put(
);
if (!existing) return res.status(404).json({ error: "Channel not found" });
if (type && type !== "webhook" && type !== "ntfy") {
if (type && type !== "webhook" && type !== "ntfy" && type !== "discord") {
return res
.status(400)
.json({ error: "type must be 'webhook' or 'ntfy'" });
.json({ error: "type must be 'webhook', 'ntfy' or 'discord'" });
}
if (
name === undefined &&
@@ -245,6 +263,11 @@ router.post(
config as unknown as Parameters<typeof sendNtfy>[0],
testPayload,
);
} else if (row.type === "discord") {
await sendDiscord(
config as unknown as Parameters<typeof sendDiscord>[0],
testPayload,
);
}
res.json({ success: true });
} catch (err) {
+2 -1
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type {
AuthenticatedRequest,
CacheEntry,
@@ -87,7 +88,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
} catch (error) {
authLogger.error("Failed to fetch alerts from GitHub", {
operation: "alerts_fetch",
error: error instanceof Error ? error.message : "Unknown error",
error: getErrorMessage(error),
});
return [];
}
+812
View File
@@ -0,0 +1,812 @@
import crypto from "node:crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type {
AutomationDefinition,
Step,
Trigger,
} from "../../../types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import {
getAuditUsername,
getRequestMeta,
logAudit,
} from "../../utils/audit-logger.js";
import { createCurrentAutomationRepository } from "../repositories/factory.js";
import type { AutomationRow } from "../repositories/automation-repository.js";
import { AutomationEngine } from "../../automations/engine.js";
import {
computeNextDueAt,
isValidCron,
isValidTimezone,
} from "../../automations/cron.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const TRIGGER_KINDS = new Set([
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"internal_event",
"webhook",
]);
const STEP_TYPES = new Set([
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"run_automation",
"stop",
]);
const OPERATORS = new Set([
">",
"<",
">=",
"<=",
"==",
"!=",
"contains",
"not_contains",
"changed",
]);
function parseId(raw: unknown): number | null {
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
return Number.isInteger(id) && id > 0 ? id : null;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Validates a definition before it is stored. The engine treats the stored
* blob as trusted, so everything it relies on is checked once here.
*/
export function validateDefinition(value: unknown): {
ok: boolean;
error?: string;
definition?: AutomationDefinition;
} {
if (typeof value !== "object" || value === null) {
return { ok: false, error: "Definition must be an object" };
}
const candidate = value as Partial<AutomationDefinition>;
const trigger = candidate.trigger as Trigger | undefined;
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
return { ok: false, error: "Unknown or missing trigger kind" };
}
if (trigger.kind === "metric_threshold") {
if (!OPERATORS.has(trigger.operator)) {
return { ok: false, error: "Unknown comparison operator" };
}
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
return { ok: false, error: "Threshold value must be a number" };
}
if (!trigger.metric?.path) {
return { ok: false, error: "Trigger is missing a metric" };
}
}
if (trigger.kind === "schedule") {
const hasInterval =
typeof trigger.intervalSeconds === "number" &&
trigger.intervalSeconds > 0;
const hasCron = isNonEmptyString(trigger.cron);
if (!hasInterval && !hasCron) {
return { ok: false, error: "Schedule needs an interval or a cron" };
}
if (hasCron && !isValidCron(trigger.cron as string)) {
return { ok: false, error: "Cron expression is not valid" };
}
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
return { ok: false, error: "Interval must be at least 60 seconds" };
}
if (
isNonEmptyString(trigger.timezone) &&
!isValidTimezone(trigger.timezone)
) {
return { ok: false, error: "Time zone is not valid" };
}
}
const steps = candidate.steps;
if (!Array.isArray(steps)) {
return { ok: false, error: "Definition must include a steps array" };
}
const seen = new Set<string>();
const stepError = validateSteps(steps as Step[], seen);
if (stepError) return { ok: false, error: stepError };
return {
ok: true,
definition: {
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
trigger,
steps: steps as Step[],
},
};
}
function validateSteps(steps: Step[], seen: Set<string>): string | null {
for (const step of steps) {
if (!step || typeof step !== "object") return "Step must be an object";
if (!isNonEmptyString(step.id)) return "Every step needs an id";
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
seen.add(step.id);
if (!STEP_TYPES.has(step.type)) {
return `Unknown step type: ${step.type}`;
}
if (step.type === "if") {
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
return "Condition needs a valid operator";
}
const thenError = validateSteps(step.then ?? [], seen);
if (thenError) return thenError;
const elseError = validateSteps(step.else ?? [], seen);
if (elseError) return elseError;
}
if (step.type === "http" && !isNonEmptyString(step.url)) {
return "HTTP steps need a URL";
}
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
return "Command steps need a command";
}
if (step.type === "wait" && typeof step.seconds !== "number") {
return "Wait steps need a number of seconds";
}
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
return "Variable steps need a name";
}
}
return null;
}
/** Never leak a webhook token hash to the client. */
function serialize(row: AutomationRow) {
let definition: AutomationDefinition | null = null;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
definition = null;
}
if (definition?.trigger?.kind === "webhook") {
definition = {
...definition,
trigger: { ...definition.trigger, tokenHash: "" },
};
}
return { ...row, definition };
}
async function syncSchedule(
automationId: number,
definition: AutomationDefinition,
): Promise<void> {
const repository = createCurrentAutomationRepository();
if (definition.trigger?.kind !== "schedule") {
await repository.deleteSchedule(automationId);
return;
}
const trigger = definition.trigger;
await repository.upsertSchedule({
automationId,
cron: trigger.cron ?? null,
intervalSeconds: trigger.intervalSeconds ?? null,
timezone: trigger.timezone ?? null,
nextDueAt: computeNextDueAt({
cron: trigger.cron,
intervalSeconds: trigger.intervalSeconds,
timezone: trigger.timezone,
}),
});
}
/**
* @openapi
* /automations:
* get:
* summary: List the current user's automations
* description: Returns every automation the caller owns, with its parsed definition and linked notification channels.
* tags:
* - Automations
* responses:
* 200:
* description: List of automations.
* 403:
* description: Missing the automations.view permission.
*/
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await createCurrentAutomationRepository().list(userId);
res.json(rows.map(serialize));
} catch (error) {
databaseLogger.error("Failed to list automations", error, {
operation: "automation_list_error",
userId,
});
res.status(500).json({ error: "Failed to list automations" });
}
},
);
/**
* @openapi
* /automations/{id}:
* get:
* summary: Fetch a single automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The automation.
* 404:
* description: Automation not found.
*/
router.get(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const row = await createCurrentAutomationRepository().findForUser(
id,
userId,
);
if (!row) return res.status(404).json({ error: "Automation not found" });
res.json(serialize(row));
} catch (error) {
databaseLogger.error("Failed to fetch automation", error, {
operation: "automation_get_error",
userId,
});
res.status(500).json({ error: "Failed to fetch automation" });
}
},
);
/**
* @openapi
* /automations:
* post:
* summary: Create an automation
* description: Validates the trigger and every step before storing the definition. A schedule trigger also registers its next due time.
* tags:
* - Automations
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* definition:
* type: object
* responses:
* 201:
* description: The created automation.
* 400:
* description: Validation failed.
*/
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, description, enabled, definition, concurrencyPolicy } =
req.body ?? {};
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Name is required" });
}
const validated = validateDefinition(definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// A webhook trigger's token is shown once here and only stored hashed.
let webhookToken: string | undefined;
if (validated.definition.trigger.kind === "webhook") {
webhookToken = crypto.randomBytes(32).toString("hex");
validated.definition = {
...validated.definition,
trigger: {
kind: "webhook",
tokenHash: hashToken(webhookToken),
},
};
}
try {
const repository = createCurrentAutomationRepository();
const created = await repository.create({
userId,
name: name.trim(),
description: description ?? null,
enabled: enabled !== false,
definition: JSON.stringify(validated.definition),
concurrencyPolicy,
channels: Array.isArray(req.body?.channels) ? req.body.channels : [],
});
await syncSchedule(created.id, validated.definition);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "create_automation",
resourceType: "automation",
resourceId: String(created.id),
resourceName: created.name,
ipAddress,
userAgent,
success: true,
});
res.status(201).json({ ...serialize(created), webhookToken });
} catch (error) {
databaseLogger.error("Failed to create automation", error, {
operation: "automation_create_error",
userId,
});
res.status(500).json({ error: "Failed to create automation" });
}
},
);
/**
* @openapi
* /automations/{id}:
* put:
* summary: Update an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The updated automation.
* 400:
* description: Validation failed.
* 404:
* description: Automation not found.
*/
router.put(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const repository = createCurrentAutomationRepository();
const existing = await repository.findForUser(id, userId);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
const update: Record<string, unknown> = {};
if (req.body?.name !== undefined) {
if (!isNonEmptyString(req.body.name)) {
return res.status(400).json({ error: "Name cannot be empty" });
}
update.name = req.body.name.trim();
}
if (req.body?.description !== undefined) {
update.description = req.body.description;
}
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
if (req.body?.concurrencyPolicy !== undefined) {
update.concurrencyPolicy = req.body.concurrencyPolicy;
}
if (Array.isArray(req.body?.channels)) update.channels = req.body.channels;
let parsedDefinition: AutomationDefinition | null = null;
if (req.body?.definition !== undefined) {
const validated = validateDefinition(req.body.definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// Keep the stored token hash: the raw token is only ever shown once.
if (validated.definition.trigger.kind === "webhook") {
const previous = JSON.parse(
existing.definition,
) as AutomationDefinition;
const previousHash =
previous.trigger?.kind === "webhook"
? previous.trigger.tokenHash
: "";
validated.definition = {
...validated.definition,
trigger: { kind: "webhook", tokenHash: previousHash },
};
}
parsedDefinition = validated.definition;
update.definition = JSON.stringify(validated.definition);
}
try {
const updated = await repository.update(id, userId, update);
if (!updated) {
return res.status(404).json({ error: "Automation not found" });
}
if (parsedDefinition) await syncSchedule(id, parsedDefinition);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "update_automation",
resourceType: "automation",
resourceId: String(id),
resourceName: updated.name,
ipAddress,
userAgent,
success: true,
});
res.json(serialize(updated));
} catch (error) {
databaseLogger.error("Failed to update automation", error, {
operation: "automation_update_error",
userId,
});
res.status(500).json({ error: "Failed to update automation" });
}
},
);
/**
* @openapi
* /automations/{id}:
* delete:
* summary: Delete an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 404:
* description: Automation not found.
*/
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const deleted = await createCurrentAutomationRepository().delete(
id,
userId,
);
if (!deleted) {
return res.status(404).json({ error: "Automation not found" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "delete_automation",
resourceType: "automation",
resourceId: String(id),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to delete automation", error, {
operation: "automation_delete_error",
userId,
});
res.status(500).json({ error: "Failed to delete automation" });
}
},
);
/**
* @openapi
* /automations/{id}/run:
* post:
* summary: Run an automation now
* description: Runs immediately, as the automation's owner. Pass dryRun to record what each step would do without touching anything outside Termix.
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* dryRun:
* type: boolean
* responses:
* 200:
* description: The run outcome.
* 404:
* description: Automation not found.
*/
router.post(
"/:id/run",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const existing = await createCurrentAutomationRepository().findForUser(
id,
userId,
);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
try {
const outcome = await AutomationEngine.getInstance().run({
automationId: id,
triggerType: "manual",
triggerContext: { manual: true, requestedBy: userId },
dryRun: req.body?.dryRun === true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "run_automation",
resourceType: "automation",
resourceId: String(id),
resourceName: existing.name,
details: JSON.stringify({
status: outcome.status,
dryRun: req.body?.dryRun === true,
}),
ipAddress,
userAgent,
success: outcome.status === "success",
});
res.json(outcome);
} catch (error) {
databaseLogger.error("Failed to run automation", error, {
operation: "automation_run_error",
userId,
});
res.status(500).json({ error: "Failed to run automation" });
}
},
);
/**
* @openapi
* /automations/runs:
* get:
* summary: List automation runs
* tags:
* - Automations
* parameters:
* - in: query
* name: automationId
* schema:
* type: integer
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Recent runs, newest first.
*/
router.get(
"/runs/history",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const runs = await createCurrentAutomationRepository().listRuns(userId, {
automationId: parseId(req.query.automationId) ?? undefined,
limit: Number(req.query.limit) || 50,
offset: Number(req.query.offset) || 0,
});
res.json(runs);
} catch (error) {
databaseLogger.error("Failed to list automation runs", error, {
operation: "automation_runs_error",
userId,
});
res.status(500).json({ error: "Failed to list runs" });
}
},
);
/**
* @openapi
* /automations/runs/{runId}/steps:
* get:
* summary: Step-by-step results for a run
* tags:
* - Automations
* parameters:
* - in: path
* name: runId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The run's steps in order.
* 404:
* description: Run not found.
*/
router.get(
"/runs/:runId/steps",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const runId = parseId(req.params.runId);
if (runId === null) return res.status(400).json({ error: "Invalid id" });
try {
const repository = createCurrentAutomationRepository();
const run = await repository.findRunForUser(runId, userId);
if (!run) return res.status(404).json({ error: "Run not found" });
res.json(await repository.listRunSteps(runId));
} catch (error) {
databaseLogger.error("Failed to list run steps", error, {
operation: "automation_run_steps_error",
userId,
});
res.status(500).json({ error: "Failed to list run steps" });
}
},
);
/**
* @openapi
* /automations/webhook/{token}:
* post:
* summary: Trigger an automation from an external system
* description: Unauthenticated by design; the 32-byte token in the path is the credential and is compared against a stored hash in constant time.
* tags:
* - Automations
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* responses:
* 202:
* description: The run was accepted.
* 404:
* description: No automation matches that token.
*/
router.post("/webhook/:token", async (req: Request, res: Response) => {
const token = req.params.token;
if (!isNonEmptyString(token) || token.length < 32) {
return res.status(404).json({ error: "Not found" });
}
try {
const repository = createCurrentAutomationRepository();
const candidates = await repository.listAllEnabled();
const wanted = hashToken(token);
const match = candidates.find((row) => {
try {
const definition = JSON.parse(row.definition) as AutomationDefinition;
if (definition.trigger?.kind !== "webhook") return false;
return timingSafeEqual(definition.trigger.tokenHash, wanted);
} catch {
return false;
}
});
if (!match) return res.status(404).json({ error: "Not found" });
const outcome = await AutomationEngine.getInstance().run({
automationId: match.id,
triggerType: "webhook",
triggerContext: {
body: req.body ?? {},
receivedAt: new Date().toISOString(),
},
});
res.status(202).json({ runId: outcome.runId, status: outcome.status });
} catch (error) {
databaseLogger.error("Webhook automation failed", error, {
operation: "automation_webhook_error",
});
res.status(500).json({ error: "Failed to run automation" });
}
});
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
function timingSafeEqual(a: string, b: string): boolean {
const left = Buffer.from(a || "", "utf8");
const right = Buffer.from(b || "", "utf8");
if (left.length !== right.length) return false;
return crypto.timingSafeEqual(left, right);
}
export default router;
@@ -2,8 +2,7 @@ import type {
AuthenticatedRequest,
TunnelConnection,
} from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { C2sTunnelPresetRecord } from "../repositories/c2s-tunnel-preset-repository.js";
@@ -0,0 +1,88 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { createCurrentCredentialRepository } from "../repositories/factory.js";
export function registerCredentialBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /credentials/reorder:
* put:
* summary: Reorder credentials
* description: Sets a manual sortOrder for multiple credentials within the same folder, used by drag-to-reorder in the sidebar's manual sort mode.
* tags:
* - Credentials
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* positions:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* sortOrder:
* type: integer
* responses:
* 200:
* description: Credentials reordered successfully.
* 400:
* description: Invalid positions array.
* 500:
* description: Failed to reorder credentials.
*/
router.put(
"/reorder",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
positions?: { id?: unknown; sortOrder?: unknown }[];
};
if (!Array.isArray(positions)) {
return res.status(400).json({ error: "positions array is required" });
}
const normalized: { id: number; sortOrder: number }[] = [];
for (const entry of positions) {
if (
typeof entry?.id !== "number" ||
!Number.isInteger(entry.id) ||
typeof entry.sortOrder !== "number" ||
!Number.isFinite(entry.sortOrder)
) {
return res.status(400).json({
error:
"Each position requires an integer id and a numeric sortOrder",
});
}
normalized.push({ id: entry.id, sortOrder: entry.sortOrder });
}
if (normalized.length === 0) {
return res.status(400).json({ error: "positions array is required" });
}
try {
const updated =
await createCurrentCredentialRepository().reorderForUser(
userId,
normalized,
);
return res.json({ updated });
} catch (error) {
authLogger.error("Failed to reorder credentials:", error);
return res.status(500).json({ error: "Failed to reorder credentials" });
}
},
);
}
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type {
AuthenticatedRequest,
CredentialBackend,
@@ -234,7 +235,7 @@ async function deploySSHKeyToHost(
conn.end();
resolve({
success: false,
error: error instanceof Error ? error.message : "Deployment failed",
error: getErrorMessage(error, "Deployment failed"),
});
}
});
@@ -331,7 +332,7 @@ async function deploySSHKeyToHost(
clearTimeout(connectionTimeout);
resolve({
success: false,
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
error: `Invalid SSH key format: ${getErrorMessage(keyError)}`,
});
return;
}
@@ -349,7 +350,7 @@ async function deploySSHKeyToHost(
clearTimeout(connectionTimeout);
resolve({
success: false,
error: error instanceof Error ? error.message : "Connection failed",
error: getErrorMessage(error, "Connection failed"),
});
}
});
@@ -527,8 +528,7 @@ export function registerCredentialDeployRoutes(
} catch (error) {
res.status(500).json({
success: false,
error:
error instanceof Error ? error.message : "Failed to deploy SSH key",
error: getErrorMessage(error, "Failed to deploy SSH key"),
});
}
},
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Response, Router } from "express";
import crypto from "crypto";
import ssh2Pkg from "ssh2";
@@ -54,8 +55,7 @@ function generateSSHKeyPair(
} catch (error) {
return {
success: false,
error:
error instanceof Error ? error.message : "SSH key generation failed",
error: getErrorMessage(error, "SSH key generation failed"),
};
}
}
@@ -116,10 +116,7 @@ export function registerCredentialKeyRoutes(
} catch (error) {
authLogger.error("Failed to detect key type", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to detect key type",
error: getErrorMessage(error, "Failed to detect key type"),
});
}
},
@@ -174,10 +171,7 @@ export function registerCredentialKeyRoutes(
} catch (error) {
authLogger.error("Failed to detect public key type", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to detect public key type",
error: getErrorMessage(error, "Failed to detect public key type"),
});
}
},
@@ -245,10 +239,7 @@ export function registerCredentialKeyRoutes(
} catch (error) {
authLogger.error("Failed to validate key pair", error);
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to validate key pair",
error: getErrorMessage(error, "Failed to validate key pair"),
});
}
},
@@ -312,10 +303,7 @@ export function registerCredentialKeyRoutes(
authLogger.error("Failed to generate key pair", error);
res.status(500).json({
success: false,
error:
error instanceof Error
? error.message
: "Failed to generate key pair",
error: getErrorMessage(error, "Failed to generate key pair"),
});
}
},
@@ -501,10 +489,7 @@ export function registerCredentialKeyRoutes(
authLogger.error("Failed to generate public key", error);
res.status(500).json({
success: false,
error:
error instanceof Error
? error.message
: "Failed to generate public key",
error: getErrorMessage(error, "Failed to generate public key"),
});
}
},
@@ -0,0 +1,115 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { createCurrentCredentialSidebarPreferenceRepository } from "../repositories/factory.js";
import {
defaultCredentialSidebarPreferences,
sanitizeCredentialSidebarPreferences,
} from "../../../types/credential-sidebar-preferences.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /credential-sidebar/preferences:
* get:
* summary: Get the credential sidebar preferences for the current user
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here credentials never had exploded preference columns on userPreferences so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
* tags:
* - Credential Sidebar
* responses:
* 200:
* description: The current user's credential sidebar preferences.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const existing =
await createCurrentCredentialSidebarPreferenceRepository().findByUserId(
userId,
);
if (existing) {
const preferences = sanitizeCredentialSidebarPreferences(
JSON.parse(existing.data),
);
return res.json({ preferences });
}
return res.json({ preferences: defaultCredentialSidebarPreferences() });
} catch (e) {
databaseLogger.error("Failed to get credential sidebar preferences", e, {
operation: "get_credential_sidebar_preferences",
userId,
});
return res
.status(500)
.json({ error: "Failed to get credential sidebar preferences" });
}
});
/**
* @openapi
* /credential-sidebar/preferences:
* put:
* summary: Update the credential sidebar preferences for the current user
* description: Persists the current user's credential sidebar preferences (sort, filters, open folders, display settings) as a single JSON document.
* tags:
* - Credential Sidebar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Preferences updated successfully.
* 400:
* description: Invalid preferences payload.
*/
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!req.body || typeof req.body !== "object") {
return res.status(400).json({ error: "Invalid preferences payload" });
}
try {
const existing =
await createCurrentCredentialSidebarPreferenceRepository().findByUserId(
userId,
);
const base = existing
? sanitizeCredentialSidebarPreferences(JSON.parse(existing.data))
: defaultCredentialSidebarPreferences();
const merged = sanitizeCredentialSidebarPreferences({
...base,
...req.body,
display: { ...base.display, ...(req.body.display ?? {}) },
sort: { ...base.sort, ...(req.body.sort ?? {}) },
filters: { ...base.filters, ...(req.body.filters ?? {}) },
});
await createCurrentCredentialSidebarPreferenceRepository().upsert(
userId,
JSON.stringify(merged),
);
return res.json({ success: true, preferences: merged });
} catch (e) {
databaseLogger.error("Failed to update credential sidebar preferences", e, {
operation: "update_credential_sidebar_preferences",
userId,
});
return res
.status(500)
.json({ error: "Failed to update credential sidebar preferences" });
}
});
export default router;
+16 -18
View File
@@ -1,11 +1,12 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
import {
logAudit,
getAuditUsername,
@@ -224,8 +225,7 @@ router.post(
username,
});
res.status(500).json({
error:
err instanceof Error ? err.message : "Failed to create credential",
error: getErrorMessage(err, "Failed to create credential"),
});
}
},
@@ -308,6 +308,11 @@ router.get(
},
);
// Registered here (before the PUT /:id route below) so the literal
// "/reorder" path segment is matched before Express falls through to the
// PUT /:id param route and treats "reorder" as an id.
registerCredentialBulkRoutes(router, authenticateJWT);
/**
* @openapi
* /credentials/{id}:
@@ -377,8 +382,7 @@ router.get(
} catch (err) {
authLogger.error("Failed to fetch credential", err);
res.status(500).json({
error:
err instanceof Error ? err.message : "Failed to fetch credential",
error: getErrorMessage(err, "Failed to fetch credential"),
});
}
},
@@ -551,8 +555,7 @@ router.put(
} catch (err) {
authLogger.error("Failed to update credential", err);
res.status(500).json({
error:
err instanceof Error ? err.message : "Failed to update credential",
error: getErrorMessage(err, "Failed to update credential"),
});
}
},
@@ -678,8 +681,7 @@ router.delete(
} catch (err) {
authLogger.error("Failed to delete credential", err);
res.status(500).json({
error:
err instanceof Error ? err.message : "Failed to delete credential",
error: getErrorMessage(err, "Failed to delete credential"),
});
}
},
@@ -766,10 +768,7 @@ router.post(
} catch (err) {
authLogger.error("Failed to apply credential to host", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to apply credential to host",
error: getErrorMessage(err, "Failed to apply credential to host"),
});
}
},
@@ -822,10 +821,7 @@ router.get(
} catch (err) {
authLogger.error("Failed to fetch hosts using credential", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to fetch hosts using credential",
error: getErrorMessage(err, "Failed to fetch hosts using credential"),
});
}
},
@@ -845,6 +841,8 @@ function formatCredentialOutput(
? credential.tags.split(",").filter(Boolean)
: []
: [],
pin: !!credential.pin,
sortOrder: credential.sortOrder ?? null,
authType: credential.authType,
username: credential.username || null,
publicKey: credential.publicKey,
@@ -1,9 +1,8 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { dashboardLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { isNonEmptyString } from "./host-normalizers.js";
import express from "express";
import {
createCurrentDashboardServiceLinkRepository,
createCurrentSyncTombstoneRepository,
@@ -1,5 +1,6 @@
import { authLogger } from "../../utils/logger.js";
import {
createCurrentAiRepository,
createCurrentAlertRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
@@ -15,6 +16,9 @@ import {
createCurrentHostFolderRepository,
createCurrentHostMetricsPreferenceRepository,
createCurrentHostRepository,
createCurrentHostSidebarPreferenceRepository,
createCurrentCredentialSidebarPreferenceRepository,
createCurrentUiPreferenceRepository,
createCurrentNetworkTopologyRepository,
createCurrentOpksshTokenRepository,
createCurrentOpenTabRepository,
@@ -57,6 +61,7 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAiRepository().deleteByUserId(userId);
await createCurrentAlertRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
@@ -77,6 +82,11 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
await createCurrentHostHealthRepository().deleteByUserId(userId);
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
await createCurrentCredentialSidebarPreferenceRepository().deleteByUserId(
userId,
);
await createCurrentUiPreferenceRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
@@ -2,7 +2,17 @@ import type { Request } from "express";
import type { UserRecord } from "../repositories/user-repository.js";
export function isLoopbackRequest(req: Request): boolean {
const ip = req.ip || req.socket?.remoteAddress || "";
// Requests relayed by the bundled nginx always carry X-Real-IP, which
// nginx overwrites with the actual client address -- so its presence
// means the caller reached the backend through the reverse proxy and is
// not a local process, whatever the TCP peer address says (it is nginx
// itself on loopback). Everything else is judged by the TCP peer
// address, which no client-supplied header can influence: with
// `trust proxy = true`, req.ip comes from X-Forwarded-For and would let
// any remote caller claim to be loopback.
if (req.headers["x-real-ip"]) return false;
const ip = req.socket?.remoteAddress || "";
return (
ip === "127.0.0.1" ||
ip === "::1" ||
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import express from "express";
import https from "https";
import http from "http";
@@ -1,11 +1,10 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
createCurrentHomepageItemRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import express from "express";
export const homepageItemsRouter = express.Router();
@@ -1,7 +1,6 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import express from "express";
import { createCurrentHomepageLayoutRepository } from "../repositories/factory.js";
export const homepageLayoutRouter = express.Router();
@@ -1,8 +1,6 @@
import type { Request, Response } from "express";
import express from "express";
import https from "https";
import http from "http";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
export const homepagePingRouter = express.Router();
@@ -17,54 +15,37 @@ const pingCache = new Map<string, PingCacheEntry>();
const CACHE_SIZE = 200;
const FETCH_TIMEOUT_MS = 5000;
function pingUrl(
async function requestStatus(
url: string,
method: "HEAD" | "GET",
): Promise<number | null> {
const res = await safeOutboundFetch(url, {
method,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
// Discard the body without buffering it.
await res.body?.cancel().catch(() => {});
return res.status ?? null;
}
async function pingUrl(
url: string,
): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
return new Promise((resolve) => {
const start = performance.now();
const mod = url.startsWith("https") ? https : http;
const done = (ok: boolean, statusCode: number | null) => {
resolve({
ok,
statusCode,
latencyMs: Math.round(performance.now() - start),
});
const start = performance.now();
const elapsed = () => Math.round(performance.now() - start);
try {
let code = await requestStatus(url, "HEAD");
if (code === 405) {
code = await requestStatus(url, "GET");
}
return {
ok: code !== null && code < 400,
statusCode: code,
latencyMs: elapsed(),
};
const tryGet = () => {
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
res.resume();
const code = res.statusCode ?? null;
done(code !== null && code < 400, code);
});
req.on("error", () => done(false, null));
req.on("timeout", () => {
req.destroy();
done(false, null);
});
};
const req = mod.request(
url,
{ method: "HEAD", timeout: FETCH_TIMEOUT_MS },
(res) => {
res.resume();
const code = res.statusCode ?? null;
if (code === 405) {
tryGet();
} else {
done(code !== null && code < 400, code);
}
},
);
req.on("error", () => done(false, null));
req.on("timeout", () => {
req.destroy();
done(false, null);
});
req.end();
});
} catch {
return { ok: false, statusCode: null, latencyMs: elapsed() };
}
}
/**
@@ -1,5 +1,5 @@
import type { Request, Response } from "express";
import express from "express";
import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Request, type Response } from "express";
import https from "https";
import http from "http";
import { lookup } from "dns/promises";
@@ -132,7 +132,7 @@ homepageProxyRouter.get("/", async (req: Request, res: Response) => {
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
res.json(data);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
const msg = getErrorMessage(err);
homepageLogger.warn("Proxy fetch failed", { targetUrl, msg });
if (msg.includes("not valid JSON")) {
return res.status(400).json({ error: "Response is not valid JSON" });
@@ -1,8 +1,6 @@
import type { Request, Response } from "express";
import express from "express";
import https from "https";
import http from "http";
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
export const homepageRssRouter = express.Router();
@@ -19,19 +17,11 @@ interface RssItem {
}
function fetchXml(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
res.on("error", reject);
});
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("RSS fetch timeout"));
});
return safeOutboundFetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}).then(async (res) => {
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
return res.text();
});
}
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -148,7 +149,7 @@ export function registerHostAutostartRoutes(
} catch (error) {
sshLogger.warn("Failed to update tunnel connections", {
operation: "tunnel_connections_update_failed",
error: error instanceof Error ? error.message : "Unknown error",
error: getErrorMessage(error),
});
}
}
+126 -6
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
@@ -6,6 +7,7 @@ import {
createCurrentHostRepository,
createCurrentHostResolutionRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
isNonEmptyString,
isValidPort,
@@ -154,6 +156,13 @@ export function registerHostBulkRoutes(
* type: number
* updates:
* type: object
* description: Partial fields to apply. Setting folder clears parentHostId and vice versa, since a host is either in a folder or nested under a parent host.
* properties:
* folder:
* type: string
* parentHostId:
* type: integer
* nullable: true
* responses:
* 200:
* description: Bulk update completed.
@@ -215,8 +224,39 @@ export function registerHostBulkRoutes(
const simpleUpdates: Record<string, unknown> = {};
if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin;
if (typeof updates.folder === "string")
if (typeof updates.folder === "string") {
simpleUpdates.folder = updates.folder || null;
// Folder placement and parent-host placement are mutually
// exclusive -- assigning a folder (including moving to root, an
// empty folder) clears any parent host, matching the single-host
// update route's behavior.
simpleUpdates.parentHostId = null;
}
if (updates.parentHostId !== undefined) {
if (updates.parentHostId === null) {
simpleUpdates.parentHostId = null;
} else {
const numericParentHostId = Number(updates.parentHostId);
if (!Number.isInteger(numericParentHostId)) {
return res.status(400).json({ error: "Invalid parent host" });
}
// A bulk move can only ever target one parent host at a time
// (the caller drags a selection onto one drop target), so every
// id in the batch is checked against the same candidate parent.
for (const id of ownedIds) {
const parentError = await validateParentHostId(
userId,
id,
numericParentHostId,
);
if (parentError) {
return res.status(400).json({ error: parentError });
}
}
simpleUpdates.parentHostId = numericParentHostId;
simpleUpdates.folder = null;
}
}
if (typeof updates.enableTerminal === "boolean")
simpleUpdates.enableTerminal = updates.enableTerminal;
if (typeof updates.enableTunnel === "boolean")
@@ -227,6 +267,8 @@ export function registerHostBulkRoutes(
simpleUpdates.enableDocker = updates.enableDocker;
if (typeof updates.enableTmuxMonitor === "boolean")
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
if (typeof updates.enableTerminalToolbar === "boolean")
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
// Disabling Proxmox is a plain flag flip; enabling is handled per-host
// below so each host can default to its own stored credential.
if (updates.enableProxmox === false)
@@ -299,6 +341,84 @@ export function registerHostBulkRoutes(
},
);
/**
* @openapi
* /host/reorder:
* put:
* summary: Reorder hosts
* description: Sets a manual sortOrder for multiple hosts within the same folder, used by drag-to-reorder in the sidebar's manual sort mode.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* positions:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* sortOrder:
* type: integer
* responses:
* 200:
* description: Hosts reordered successfully.
* 400:
* description: Invalid positions array.
* 500:
* description: Failed to reorder hosts.
*/
router.put(
"/reorder",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
positions?: { id?: unknown; sortOrder?: unknown }[];
};
if (!Array.isArray(positions)) {
return res.status(400).json({ error: "positions array is required" });
}
const normalized: { id: number; sortOrder: number }[] = [];
for (const entry of positions) {
if (
typeof entry?.id !== "number" ||
!Number.isInteger(entry.id) ||
typeof entry.sortOrder !== "number" ||
!Number.isFinite(entry.sortOrder)
) {
return res.status(400).json({
error:
"Each position requires an integer id and a numeric sortOrder",
});
}
normalized.push({ id: entry.id, sortOrder: entry.sortOrder });
}
if (normalized.length === 0) {
return res.status(400).json({ error: "positions array is required" });
}
try {
const updated = await createCurrentHostRepository().reorderForUser(
userId,
normalized,
);
return res.json({ updated });
} catch (error) {
sshLogger.error("Failed to reorder hosts:", error);
return res.status(500).json({ error: "Failed to reorder hosts" });
}
},
);
router.post(
"/bulk-import",
authenticateJWT,
@@ -391,7 +511,7 @@ export function registerHostBulkRoutes(
}
} catch (error) {
results.errors.push(
`Credential placeholders: ${error instanceof Error ? error.message : "failed to prepare credential aliases"}`,
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
);
}
@@ -553,6 +673,7 @@ export function registerHostBulkRoutes(
enableDocker: hostData.enableDocker || false,
enableProxmox: hostData.enableProxmox || false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
@@ -665,9 +786,7 @@ export function registerHostBulkRoutes(
}
} catch (error) {
results.failed++;
results.errors.push(
`Host ${i + 1}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
}
}
@@ -821,6 +940,7 @@ export function registerHostBulkRoutes(
enableDocker: false,
enableProxmox: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
@@ -872,7 +992,7 @@ export function registerHostBulkRoutes(
} catch (error) {
results.failed++;
results.errors.push(
`Host "${parsed[i].name}": ${error instanceof Error ? error.message : "Unknown error"}`,
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
);
}
}
@@ -240,6 +240,87 @@ export function registerHostFolderRoutes(
},
);
/**
* @openapi
* /host/folders/reorder:
* put:
* summary: Reorder folders
* description: Sets a manual sortOrder for multiple sibling folders, used by drag-to-reorder in the sidebar's manual sort mode. Folders with no existing metadata row are created.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* positions:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* sortOrder:
* type: integer
* responses:
* 200:
* description: Folders reordered successfully.
* 400:
* description: Invalid positions array.
* 500:
* description: Failed to reorder folders.
*/
router.put(
"/folders/reorder",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
positions?: { name?: unknown; sortOrder?: unknown }[];
};
if (!isNonEmptyString(userId) || !Array.isArray(positions)) {
return res.status(400).json({ error: "positions array is required" });
}
const normalized: { name: string; sortOrder: number }[] = [];
for (const entry of positions) {
if (
typeof entry?.name !== "string" ||
!entry.name ||
typeof entry.sortOrder !== "number" ||
!Number.isFinite(entry.sortOrder)
) {
return res.status(400).json({
error: "Each position requires a name and a numeric sortOrder",
});
}
normalized.push({ name: entry.name, sortOrder: entry.sortOrder });
}
if (normalized.length === 0) {
return res.status(400).json({ error: "positions array is required" });
}
try {
const updated =
await createCurrentHostFolderRepository().reorderFolders(
userId,
normalized,
);
res.json({ updated });
} catch (err) {
sshLogger.error("Failed to reorder folders", err, {
operation: "folders_reorder",
userId,
});
res.status(500).json({ error: "Failed to reorder folders" });
}
},
);
/**
* @openapi
* /host/folders/{name}/hosts:
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
@@ -83,7 +84,7 @@ export function registerHostNetworkRoutes(
});
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: getErrorMessage(error),
});
}
},
@@ -132,10 +133,7 @@ export function registerHostNetworkRoutes(
hostId,
});
res.status(500).json({
error:
error instanceof Error
? error.message
: "Failed to send WoL packet",
error: getErrorMessage(error, "Failed to send WoL packet"),
});
}
},
@@ -154,6 +154,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
enableDocker?: unknown;
enableProxmox?: unknown;
enableTmuxMonitor?: unknown;
enableTerminalToolbar?: unknown;
showTerminalInSidebar?: unknown;
showFileManagerInSidebar?: unknown;
showTunnelInSidebar?: unknown;
@@ -167,6 +168,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
statsConfig?: unknown;
dockerConfig?: unknown;
proxmoxConfig?: unknown;
enableProxmoxStats?: unknown;
proxmoxStatsConfig?: unknown;
terminalConfig?: unknown;
forceKeyboardInteractive?: unknown;
notes?: unknown;
@@ -273,10 +276,17 @@ export function stripSensitiveFields(
host: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...host };
const terminalConfigForSudo =
host.terminalConfig &&
typeof host.terminalConfig === "object" &&
!Array.isArray(host.terminalConfig)
? (host.terminalConfig as Record<string, unknown>)
: undefined;
result.hasKey = !!host.key;
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
result.hasSudoPassword = !!host.sudoPassword;
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasRdpPassword = !!host.rdpPassword;
result.hasVncPassword = !!host.vncPassword;
result.hasTelnetPassword = !!host.telnetPassword;
@@ -322,7 +332,9 @@ const CONNECT_LEVEL_FIELDS = new Set([
"enableFileManager",
"enableDocker",
"enableProxmox",
"enableProxmoxStats",
"enableTmuxMonitor",
"enableTerminalToolbar",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
@@ -356,6 +368,10 @@ export function sanitizeHostForRecipient(
const stripped = stripSensitiveFields(host);
delete stripped.credentialId;
delete stripped.overrideCredentialUsername;
// Sub-host nesting is per-owner tree structure; a recipient generally
// can't see (or share permission on) the parent host row, so a shared
// host always renders at root rather than leaking another host's id.
delete stripped.parentHostId;
if (
stripped.terminalConfig &&
typeof stripped.terminalConfig === "object" &&
@@ -417,7 +433,9 @@ export function transformHostResponse(
enableFileManager: host.enableFileManager !== false,
enableDocker: !!host.enableDocker,
enableProxmox: !!host.enableProxmox,
enableProxmoxStats: !!host.enableProxmoxStats,
enableTmuxMonitor: !!host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
@@ -469,6 +487,9 @@ export function transformHostResponse(
proxmoxConfig: host.proxmoxConfig
? JSON.parse(host.proxmoxConfig as string)
: undefined,
proxmoxStatsConfig: host.proxmoxStatsConfig
? JSON.parse(host.proxmoxStatsConfig as string)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
useWarpgate: !!host.useWarpgate,
socks5ProxyChain: host.socks5ProxyChain
@@ -0,0 +1,47 @@
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
/**
* Validates a proposed parentHostId for a host owned by `userId`.
*
* Rejects a parent that doesn't exist/isn't owned by the same user, a
* self-reference, and any assignment that would create a cycle (the
* candidate parent's own ancestor chain already contains the host being
* assigned). Walks parentHostId in-app rather than via a recursive SQL CTE,
* matching the existing ancestor-walk convention in
* findFolderCredentialId (host-resolution-repository.ts).
*
* `hostId` is null when validating a create (the host doesn't have an id
* yet, so only self-reference/cycle-with-itself is impossible to hit).
*/
export async function validateParentHostId(
userId: string,
hostId: number | null,
parentHostId: number,
): Promise<string | null> {
if (hostId !== null && parentHostId === hostId) {
return "A host cannot be its own parent";
}
const links =
await createCurrentHostResolutionRepository().listOwnHostParentLinks(
userId,
);
const linksById = new Map(links.map((link) => [link.id, link.parentHostId]));
if (!linksById.has(parentHostId)) {
return "Parent host not found";
}
let current: number | null = parentHostId;
const visited = new Set<number>();
while (current !== null) {
if (hostId !== null && current === hostId) {
return "That host is a descendant of this host, and cannot be its parent";
}
if (visited.has(current)) break;
visited.add(current);
current = linksById.get(current) ?? null;
}
return null;
}
@@ -0,0 +1,144 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import {
createCurrentHostSidebarPreferenceRepository,
createCurrentUserPreferenceRepository,
} from "../repositories/factory.js";
import {
defaultHostSidebarPreferences,
sanitizeHostSidebarPreferences,
} from "../../../types/host-sidebar-preferences.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /host-sidebar/preferences:
* get:
* summary: Get the host sidebar preferences for the current user
* description: Returns the current user's saved sidebar preferences (sort, group, filters, open folders, display settings). On first access, seeds the preferences from the legacy per-column user-preferences fields (showHostTags, hostTrayOnClick, compactHostView, statusColorScheme) so existing settings are not lost.
* tags:
* - Host Sidebar
* responses:
* 200:
* description: The current user's sidebar preferences.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const existing =
await createCurrentHostSidebarPreferenceRepository().findByUserId(userId);
if (existing) {
const preferences = sanitizeHostSidebarPreferences(
JSON.parse(existing.data),
);
return res.json({ preferences });
}
const legacy =
await createCurrentUserPreferenceRepository().findByUserId(userId);
const defaults = defaultHostSidebarPreferences();
const seeded = sanitizeHostSidebarPreferences({
...defaults,
display: {
...defaults.display,
showTags: legacy?.showHostTags ?? defaults.display.showTags,
trayTrigger:
legacy?.hostTrayOnClick == null
? defaults.display.trayTrigger
: legacy.hostTrayOnClick
? "click"
: "hover",
density:
legacy?.compactHostView == null
? defaults.display.density
: legacy.compactHostView
? "compact"
: "comfortable",
statusColorScheme:
legacy?.statusColorScheme ?? defaults.display.statusColorScheme,
},
});
await createCurrentHostSidebarPreferenceRepository().upsert(
userId,
JSON.stringify(seeded),
);
return res.json({ preferences: seeded });
} catch (e) {
databaseLogger.error("Failed to get host sidebar preferences", e, {
operation: "get_host_sidebar_preferences",
userId,
});
return res
.status(500)
.json({ error: "Failed to get host sidebar preferences" });
}
});
/**
* @openapi
* /host-sidebar/preferences:
* put:
* summary: Update the host sidebar preferences for the current user
* description: Persists the current user's sidebar preferences (sort, group, filters, open folders, display settings) as a single JSON document.
* tags:
* - Host Sidebar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: Preferences updated successfully.
* 400:
* description: Invalid preferences payload.
*/
router.put("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!req.body || typeof req.body !== "object") {
return res.status(400).json({ error: "Invalid preferences payload" });
}
try {
const existing =
await createCurrentHostSidebarPreferenceRepository().findByUserId(userId);
const base = existing
? sanitizeHostSidebarPreferences(JSON.parse(existing.data))
: defaultHostSidebarPreferences();
const merged = sanitizeHostSidebarPreferences({
...base,
...req.body,
display: { ...base.display, ...(req.body.display ?? {}) },
sort: { ...base.sort, ...(req.body.sort ?? {}) },
filters: { ...base.filters, ...(req.body.filters ?? {}) },
});
await createCurrentHostSidebarPreferenceRepository().upsert(
userId,
JSON.stringify(merged),
);
return res.json({ success: true, preferences: merged });
} catch (e) {
databaseLogger.error("Failed to update host sidebar preferences", e, {
operation: "update_host_sidebar_preferences",
userId,
});
return res
.status(500)
.json({ error: "Failed to update host sidebar preferences" });
}
});
export default router;
+173 -30
View File
@@ -1,6 +1,6 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import axios from "axios";
import multer from "multer";
import { sshLogger, databaseLogger } from "../../utils/logger.js";
@@ -12,6 +12,7 @@ import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../hosts/credential-username.js";
import { notifyAutomationInternalEvent } from "../../hosts/metrics/automation-bridge.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
@@ -39,6 +40,7 @@ import {
stripSensitiveFields,
transformHostResponse,
} from "./host-normalizers.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
@@ -56,7 +58,10 @@ import {
getAuditUsername,
getRequestMeta,
} from "../../utils/audit-logger.js";
import type { HostResolutionHostRecord } from "../repositories/host-resolution-repository.js";
import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
@@ -162,6 +167,7 @@ router.post(
connectionType,
name,
folder,
parentHostId,
tags,
ip,
port,
@@ -186,6 +192,7 @@ router.post(
enableDocker,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
allowSessionSharing,
showTerminalInSidebar,
showFileManagerInSidebar,
@@ -199,6 +206,8 @@ router.post(
statsConfig,
dockerConfig,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -264,6 +273,23 @@ router.post(
return res.status(400).json({ error: "Invalid SSH data" });
}
let validatedParentHostId: number | null = null;
if (parentHostId !== undefined && parentHostId !== null) {
const numericParentHostId = Number(parentHostId);
if (!Number.isInteger(numericParentHostId)) {
return res.status(400).json({ error: "Invalid parent host" });
}
const parentError = await validateParentHostId(
userId,
null,
numericParentHostId,
);
if (parentError) {
return res.status(400).json({ error: parentError });
}
validatedParentHostId = numericParentHostId;
}
const effectiveConnectionType = connectionType || "ssh";
const effectiveAuthType =
authType ||
@@ -277,7 +303,10 @@ router.post(
userId: userId,
connectionType: effectiveConnectionType,
name: effectiveName,
folder: folder || null,
// A host is either placed in a folder or nested under a parent host,
// never both -- setting one clears the other.
folder: validatedParentHostId ? null : folder || null,
parentHostId: validatedParentHostId,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
ip,
port,
@@ -286,7 +315,8 @@ router.post(
useWarpgate: useWarpgate ? 1 : 0,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId: vaultProfileId || null,
vaultProfileId:
effectiveAuthType === "vault" ? vaultProfileId || null : null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
@@ -304,6 +334,7 @@ router.post(
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
@@ -326,6 +357,12 @@ router.post(
? proxmoxConfig
: JSON.stringify(proxmoxConfig)
: null,
enableProxmoxStats: enableProxmoxStats ? 1 : 0,
proxmoxStatsConfig: proxmoxStatsConfig
? typeof proxmoxStatsConfig === "string"
? proxmoxStatsConfig
: JSON.stringify(proxmoxStatsConfig)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -485,7 +522,14 @@ router.post(
success: true,
});
res.json(resolvedHost);
notifyAutomationInternalEvent(
"host_added",
userId,
createdHost.id as number,
{ name: String(name ?? ip) },
);
res.json(stripSensitiveFields(resolvedHost));
notifyStatsHostUpdated(
createdHost.id as number,
req.headers,
@@ -710,7 +754,9 @@ router.post(
enableFileManager: true,
enableDocker: false,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
@@ -813,6 +859,7 @@ router.put(
connectionType,
name,
folder,
parentHostId,
tags,
ip,
port,
@@ -837,6 +884,7 @@ router.put(
enableDocker,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
allowSessionSharing,
showTerminalInSidebar,
showFileManagerInSidebar,
@@ -850,6 +898,8 @@ router.put(
statsConfig,
dockerConfig,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -917,6 +967,27 @@ router.put(
return res.status(400).json({ error: "Invalid SSH data" });
}
let validatedParentHostId: number | null | undefined = undefined;
if (parentHostId !== undefined) {
if (parentHostId === null) {
validatedParentHostId = null;
} else {
const numericParentHostId = Number(parentHostId);
if (!Number.isInteger(numericParentHostId)) {
return res.status(400).json({ error: "Invalid parent host" });
}
const parentError = await validateParentHostId(
userId,
Number(hostId),
numericParentHostId,
);
if (parentError) {
return res.status(400).json({ error: parentError });
}
validatedParentHostId = numericParentHostId;
}
}
const effectiveAuthType = authType || authMethod;
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "";
@@ -925,7 +996,10 @@ router.put(
const sshDataObj: Record<string, unknown> = {
connectionType: connectionType || "ssh",
name: effectiveName,
folder,
// A host is either placed in a folder or nested under a parent host,
// never both. When the caller is assigning a parent, clear folder;
// when the caller is assigning a folder, clear parentHostId.
folder: validatedParentHostId ? null : folder,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
ip,
port,
@@ -934,7 +1008,8 @@ router.put(
useWarpgate: useWarpgate ? 1 : 0,
shareSshAuth: shareSshAuth === true ? 1 : 0,
credentialId: credentialId || null,
vaultProfileId: vaultProfileId || null,
vaultProfileId:
effectiveAuthType === "vault" ? vaultProfileId || null : null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
@@ -952,6 +1027,7 @@ router.put(
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
@@ -974,6 +1050,12 @@ router.put(
? proxmoxConfig
: JSON.stringify(proxmoxConfig)
: null,
enableProxmoxStats: enableProxmoxStats ? 1 : 0,
proxmoxStatsConfig: proxmoxStatsConfig
? typeof proxmoxStatsConfig === "string"
? proxmoxStatsConfig
: JSON.stringify(proxmoxStatsConfig)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -1100,6 +1182,15 @@ router.put(
if (vncPassword) sshDataObj.vncPassword = vncPassword;
if (telnetPassword) sshDataObj.telnetPassword = telnetPassword;
if (validatedParentHostId !== undefined) {
sshDataObj.parentHostId = validatedParentHostId;
} else if (folder !== undefined) {
// Caller is assigning a folder (including clearing it back to root)
// without touching parentHostId -- folder placement replaces
// parent-host placement either way.
sshDataObj.parentHostId = null;
}
try {
const accessInfo = await permissionManager.canAccessHost(
userId,
@@ -1244,6 +1335,33 @@ router.put(
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
delete sshDataObj[field];
}
} else if (
sshDataObj.terminalConfig &&
(hostData.terminalConfig as Record<string, unknown> | undefined)
?.sudoPassword === undefined
) {
// The editor omits sudoPassword entirely when the user hasn't
// touched the field, so preserve whatever is already stored instead
// of letting the wholesale terminalConfig replacement below wipe it.
const existingHost =
await createCurrentHostResolutionRepository().findHostById(
Number(hostId),
ownerId,
);
const existingTerminalConfig = existingHost?.terminalConfig
? (JSON.parse(existingHost.terminalConfig as string) as Record<
string,
unknown
>)
: undefined;
if (existingTerminalConfig?.sudoPassword !== undefined) {
const incomingTerminalConfig = JSON.parse(
sshDataObj.terminalConfig as string,
) as Record<string, unknown>;
incomingTerminalConfig.sudoPassword =
existingTerminalConfig.sudoPassword;
sshDataObj.terminalConfig = JSON.stringify(incomingTerminalConfig);
}
}
await createCurrentHostRepository().updateEncryptedForUser(
@@ -1262,10 +1380,7 @@ router.put(
sshLogger.warn("Failed to resync shared host secrets after update", {
operation: "host_update_resync",
hostId: parseInt(hostId),
error:
resyncError instanceof Error
? resyncError.message
: "Unknown error",
error: getErrorMessage(resyncError),
});
}
@@ -1307,7 +1422,7 @@ router.put(
success: true,
});
res.json(resolvedHost);
res.json(stripSensitiveFields(resolvedHost));
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
} catch (err) {
sshLogger.error("Failed to update SSH host in database", err, {
@@ -1387,31 +1502,40 @@ router.get(
operation: "host_fetch_own_decrypt_failed",
userId,
hostId: host.id,
error:
decryptError instanceof Error
? decryptError.message
: "Unknown error",
error: getErrorMessage(decryptError),
});
}
}
}
// One lookup for every owner rather than one per shared host.
const ownerUsernames = new Map<string, string>();
const userRepository = createCurrentUserRepository();
for (const sharedHost of sharedHosts) {
const ownerId = sharedHost.userId as string;
if (!ownerUsernames.has(ownerId)) {
try {
const owner = await userRepository.findById(ownerId);
ownerUsernames.set(ownerId, owner?.username ?? "");
} catch {
ownerUsernames.set(ownerId, "");
const ownerIds = Array.from(
new Set(sharedHosts.map((host) => host.userId as string)),
);
if (ownerIds.length > 0) {
try {
const owners =
await createCurrentUserRepository().listByIds(ownerIds);
for (const owner of owners) {
ownerUsernames.set(owner.id, owner.username ?? "");
}
} catch {
// Falls through to an undefined ownerUsername below.
}
}
const data = [...decryptedOwnHosts, ...sharedHosts];
// Own hosts all resolve against the caller's own credentials, so they can
// be fetched and decrypted in one batch instead of once per host.
const ownCredentialIds = decryptedOwnHosts
.map((host) => host.credentialId)
.filter((id): id is number => typeof id === "number");
const credentialsById = await createCurrentHostResolutionRepository()
.listCredentialsByIdsForUser(ownCredentialIds, userId)
.catch(() => new Map<number, HostResolutionCredentialRecord>());
const result = await Promise.all(
data.map(async (row: Record<string, unknown>) => {
const baseHost = {
@@ -1425,7 +1549,8 @@ router.get(
};
const resolved =
(await resolveHostCredentials(baseHost, userId)) || baseHost;
(await resolveHostCredentials(baseHost, userId, credentialsById)) ||
baseHost;
return resolved;
}),
);
@@ -1776,7 +1901,9 @@ router.get(
scpLegacy: !!resolvedHost.scpLegacy,
enableDocker: !!resolvedHost.enableDocker,
enableProxmox: !!resolvedHost.enableProxmox,
enableProxmoxStats: !!resolvedHost.enableProxmoxStats,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
@@ -1802,6 +1929,9 @@ router.get(
proxmoxConfig: resolvedHost.proxmoxConfig
? JSON.parse(resolvedHost.proxmoxConfig as string)
: null,
proxmoxStatsConfig: resolvedHost.proxmoxStatsConfig
? JSON.parse(resolvedHost.proxmoxStatsConfig as string)
: null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
@@ -1932,6 +2062,8 @@ router.get(
enableDocker: !!resolvedHost.enableDocker,
enableProxmox: !!resolvedHost.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar:
resolvedHost.enableTerminalToolbar !== false,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
@@ -2185,6 +2317,10 @@ router.delete(
success: true,
});
notifyAutomationInternalEvent("host_deleted", userId, numericHostId, {
name: hostToDelete.name ?? hostToDelete.ip,
});
try {
const axios = (await import("axios")).default;
await axios.post(
@@ -2295,6 +2431,12 @@ registerHostCommandHistoryRoutes(router, authenticateJWT);
async function resolveHostCredentials(
host: Record<string, unknown>,
requestingUserId?: string,
/**
* Credentials already fetched for this request, keyed by id. The host list
* preloads them in one query; single-host callers omit it and fall back to
* fetching the one credential they need.
*/
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
): Promise<Record<string, unknown>> {
try {
const ownerId = (host.ownerId || host.userId) as string | undefined;
@@ -2424,10 +2566,11 @@ async function resolveHostCredentials(
const credentialOwnerId = (host.ownerId || host.userId) as string;
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
preloadedCredentials?.get(credentialId) ??
(await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
credentialOwnerId,
);
));
if (credential) {
const resolvedHost: Record<string, unknown> = {
@@ -2454,7 +2597,7 @@ async function resolveHostCredentials(
return { ...host };
} catch (error) {
sshLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
);
return host;
}
+2 -2
View File
@@ -1,6 +1,5 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { sessionManager } from "../../hosts/terminal/session-manager.js";
@@ -213,6 +212,7 @@ router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = String(req.params.id);
const updates = req.body as Partial<{
hostId: number | null;
label: string;
tabOrder: number;
backendSessionId: string | null;
+22 -19
View File
@@ -1,15 +1,19 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express from "express";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../utils/logger.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { SSHHost } from "../../../types/index.js";
import {
type AuthenticatedRequest,
type SSHHost,
} from "../../../types/index.js";
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { createJumpHostChain } from "../../hosts/jump-host-chain.js";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
import { isSafeNodeName } from "../../hosts/proxmox-shared.js";
const router = express.Router();
const proxmoxLogger = logger;
@@ -24,14 +28,6 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
// Helpers
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
// but we validate defensively before using in a shell command.
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
function isSafeNodeName(name: string): boolean {
return SAFE_NODE_RE.test(name);
}
function execCommand(
client: SSHClient,
command: string,
@@ -240,6 +236,17 @@ function guestSourceKey(sourceHostId: number, guest: ProxmoxGuest): string {
return `${sourceHostId}:${guest.node}:${guest.type}:${guest.vmid}`;
}
function guestTags(guest: ProxmoxGuest): string[] {
const idTag = guest.type === "lxc" ? `ct-${guest.vmid}` : `vm-${guest.vmid}`;
return [
"proxmox",
guest.type,
guest.node,
idTag,
...(guest.enableDocker ? ["docker"] : []),
];
}
function mergeTags(
existing: unknown,
additions: string[],
@@ -701,11 +708,7 @@ async function syncProxmoxHost(
username,
connectionType,
folder: existing?.folder || sourceHostName,
tags: mergeTags(
existing?.tags,
["proxmox", guest.type, guest.node],
["proxmox-missing"],
),
tags: mergeTags(existing?.tags, guestTags(guest), ["proxmox-missing"]),
proxmoxConfig: JSON.stringify(proxmoxConfig),
updatedAt: now,
};
@@ -814,7 +817,7 @@ async function syncProxmoxHost(
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
const message = getErrorMessage(error);
result.errors.push(message);
await writeSyncStatus(userId, sourceHostId, {
lastSyncAt: startedAt,
@@ -855,7 +858,7 @@ router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
const result = await syncProxmoxHost(userId, parsedHostId);
return res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
const message = getErrorMessage(err);
const status =
(err as Error & { code?: string; status?: number }).code ===
"SESSION_EXPIRED"
@@ -1041,7 +1044,7 @@ router.get(
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
const message = getErrorMessage(err);
proxmoxLogger.error("Proxmox discovery (stream) failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
@@ -1086,7 +1089,7 @@ router.post(
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
const message = getErrorMessage(err);
proxmoxLogger.error("Proxmox discovery failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
+11 -18
View File
@@ -1,6 +1,6 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Response } from "express";
import express, { type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { getRequestMeta } from "../../utils/audit-logger.js";
@@ -41,11 +41,13 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isSharePermissionLevel(value: unknown): value is SharePermissionLevel {
export function isSharePermissionLevel(
value: unknown,
): value is SharePermissionLevel {
return SHARE_PERMISSION_LEVELS.includes(value as SharePermissionLevel);
}
function expiryFromDuration(durationHours: unknown): string | null {
export function expiryFromDuration(durationHours: unknown): string | null {
if (durationHours && typeof durationHours === "number" && durationHours > 0) {
const expiryDate = new Date();
expiryDate.setTime(expiryDate.getTime() + durationHours * 60 * 60 * 1000);
@@ -67,12 +69,12 @@ async function canManageHostSharing(
return { allowed: access.hasAccess, isOwner: access.isOwner };
}
interface ShareTarget {
export interface ShareTarget {
type: "user" | "role";
id: string | number;
}
function parseShareTargets(
export function parseShareTargets(
body: Record<string, unknown>,
): ShareTarget[] | null {
const rawTargets = body.targets;
@@ -279,10 +281,7 @@ router.post(
operation: "rbac_host_share_snapshot_failed",
hostId,
accessId: accessGrant.id,
error:
snapshotError instanceof Error
? snapshotError.message
: "Unknown error",
error: getErrorMessage(snapshotError),
});
}
@@ -492,10 +491,7 @@ router.post(
operation: "rbac_folder_share_snapshot_failed",
hostId: host.id,
accessId: accessGrant.id,
error:
snapshotError instanceof Error
? snapshotError.message
: "Unknown error",
error: getErrorMessage(snapshotError),
});
}
}
@@ -1433,10 +1429,7 @@ router.delete(
operation: "remove_role_secret_cleanup",
targetUserId,
roleId,
error:
cleanupError instanceof Error
? cleanupError.message
: "Unknown error",
error: getErrorMessage(cleanupError),
},
);
}
@@ -1,10 +1,9 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import express, { type Request, type Response } from "express";
import fs from "fs";
import path from "path";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { Request, Response } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
createCurrentSessionRecordingRepository,
@@ -4,6 +4,59 @@ export interface SnippetExecutionResult {
error?: string;
}
export interface SnippetHostVars {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function replaceVar(content: string, name: string, value?: string): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
/**
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
* backend are separate builds, so this is kept as a small standalone copy
* rather than a shared package for one pure function.
*/
export function resolveSnippetCommand(
content: string,
host: SnippetHostVars | null,
inputValues: Record<string, string> = {},
): string {
let resolved = content;
resolved = replaceVar(resolved, "HOST", host?.ip);
resolved = replaceVar(resolved, "USER", host?.username);
resolved = replaceVar(
resolved,
"PORT",
host?.port !== undefined ? String(host.port) : undefined,
);
resolved = replaceVar(resolved, "NAME", host?.name);
resolved = resolved.replace(
INPUT_PATTERN,
(
fullMatch,
braceDigits: string | undefined,
_label,
plainDigits: string | undefined,
) => {
const key = `INPUT_${braceDigits ?? plainDigits}`;
return key in inputValues ? inputValues[key] : fullMatch;
},
);
return resolved;
}
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
): number | undefined {
+50 -28
View File
@@ -1,6 +1,6 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import type { Request, Response } from "express";
import express, { type Request, type Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
@@ -8,6 +8,7 @@ import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
import {
createSnippetExecutionResult,
getSnippetExecutionTimeoutMs,
resolveSnippetCommand,
} from "./snippets-execution.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import {
@@ -181,10 +182,7 @@ router.post(
} catch (err) {
authLogger.error("Failed to create snippet folder", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to create snippet folder",
error: getErrorMessage(err, "Failed to create snippet folder"),
});
}
},
@@ -268,10 +266,7 @@ router.put(
} catch (err) {
authLogger.error("Failed to update snippet folder metadata", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to update snippet folder metadata",
error: getErrorMessage(err, "Failed to update snippet folder metadata"),
});
}
},
@@ -356,10 +351,7 @@ router.put(
} catch (err) {
authLogger.error("Failed to rename snippet folder", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to rename snippet folder",
error: getErrorMessage(err, "Failed to rename snippet folder"),
});
}
},
@@ -430,10 +422,7 @@ router.delete(
} catch (err) {
authLogger.error("Failed to delete snippet folder", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to delete snippet folder",
error: getErrorMessage(err, "Failed to delete snippet folder"),
});
}
},
@@ -513,8 +502,7 @@ router.put(
} catch (err) {
authLogger.error("Failed to reorder snippets", err);
res.status(500).json({
error:
err instanceof Error ? err.message : "Failed to reorder snippets",
error: getErrorMessage(err, "Failed to reorder snippets"),
});
}
},
@@ -539,6 +527,15 @@ router.put(
* type: integer
* hostId:
* type: integer
* inputValues:
* type: object
* description: >
* Optional resolved values for $INPUT_n placeholders in the
* snippet content, keyed by "INPUT_n". Host variables
* ($HOST, $USER, $PORT, $NAME) are resolved server-side per
* target host and do not need to be passed here.
* additionalProperties:
* type: string
* responses:
* 200:
* description: Snippet executed successfully.
@@ -555,7 +552,7 @@ router.post(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { snippetId, hostId } = req.body;
const { snippetId, hostId, inputValues } = req.body;
if (!isNonEmptyString(userId) || !snippetId || !hostId) {
authLogger.warn("Invalid snippet execution request", {
@@ -575,6 +572,12 @@ router.post(
return res.status(404).json({ error: "Snippet not found" });
}
if (snippet.isNote) {
return res
.status(400)
.json({ error: "Notes cannot be executed on a host" });
}
const { Client } = await import("ssh2");
const repository = createCurrentHostResolutionRepository();
const host = await repository.findHostById(parseInt(hostId), userId);
@@ -607,6 +610,17 @@ router.post(
let output = "";
let errorOutput = "";
const resolvedCommand = resolveSnippetCommand(
snippet.content,
{
ip: host.ip,
username: host.username,
port: host.port,
name: host.name,
},
inputValues && typeof inputValues === "object" ? inputValues : {},
);
const executePromise = new Promise<{
success: boolean;
output: string;
@@ -616,7 +630,7 @@ router.post(
let timeout: NodeJS.Timeout | undefined;
conn.on("ready", () => {
conn.exec(snippet.content, (err, stream) => {
conn.exec(resolvedCommand, (err, stream) => {
if (err) {
clearTimeout(timeout);
conn.end();
@@ -757,7 +771,7 @@ router.post(
} catch (err) {
authLogger.error("Failed to execute snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to execute snippet",
error: getErrorMessage(err, "Failed to execute snippet"),
});
}
},
@@ -1014,7 +1028,7 @@ router.get(
} catch (err) {
authLogger.error("Failed to fetch snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch snippet",
error: getErrorMessage(err, "Failed to fetch snippet"),
});
}
},
@@ -1045,6 +1059,9 @@ router.get(
* type: string
* order:
* type: integer
* isNote:
* type: boolean
* description: When true, the snippet is a note (copy/paste only, not directly executable on a host).
* responses:
* 201:
* description: Snippet created successfully.
@@ -1059,7 +1076,8 @@ router.post(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, content, description, folder, order, hostFilter } = req.body;
const { name, content, description, folder, order, hostFilter, isNote } =
req.body;
if (
!isNonEmptyString(userId) ||
@@ -1085,6 +1103,7 @@ router.post(
folder,
order,
hostFilter,
isNote,
},
);
databaseLogger.info("Command snippet created", {
@@ -1111,7 +1130,7 @@ router.post(
} catch (err) {
authLogger.error("Failed to create snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to create snippet",
error: getErrorMessage(err, "Failed to create snippet"),
});
}
},
@@ -1148,6 +1167,9 @@ router.post(
* type: string
* order:
* type: integer
* isNote:
* type: boolean
* description: When true, the snippet is a note (copy/paste only, not directly executable on a host).
* responses:
* 200:
* description: The updated snippet.
@@ -1206,7 +1228,7 @@ router.put(
} catch (err) {
authLogger.error("Failed to update snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to update snippet",
error: getErrorMessage(err, "Failed to update snippet"),
});
}
},
@@ -1291,7 +1313,7 @@ router.delete(
} catch (err) {
authLogger.error("Failed to delete snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete snippet",
error: getErrorMessage(err, "Failed to delete snippet"),
});
}
},
@@ -12,6 +12,11 @@ import {
decryptSsoConfigSecrets,
encryptSsoConfigSecrets,
} from "../../utils/system-secret-crypto.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
function isOidcLike(type: SSOProviderType): boolean {
return type === "oidc" || type === "github" || type === "google";
}
const authManager = AuthManager.getInstance();
@@ -188,6 +193,12 @@ export function registerSSOProviderRoutes(router: Router): void {
if (!validTypes.includes(type)) {
return res.status(400).json({ error: "Invalid provider type" });
}
if (isTrustedProxyAuthEnabled() && enabled && isOidcLike(type)) {
return res.status(409).json({
error:
"OIDC providers cannot be enabled with trusted proxy authentication",
});
}
const configWithDefaults =
type === "github" || type === "google"
@@ -317,6 +328,19 @@ export function registerSSOProviderRoutes(router: Router): void {
config?: Record<string, unknown>;
};
const effectiveType = type ?? (existing.type as SSOProviderType);
const effectiveEnabled = enabled ?? existing.enabled;
if (
isTrustedProxyAuthEnabled() &&
effectiveEnabled &&
isOidcLike(effectiveType)
) {
return res.status(409).json({
error:
"OIDC providers cannot be enabled with trusted proxy authentication",
});
}
let encryptedConfig = existing.config;
if (rawConfig !== undefined) {
const existingDecrypted = await decryptProviderConfig(
+35 -26
View File
@@ -1,6 +1,5 @@
import type { Request, Response } from "express";
import express from "express";
import { and, eq } from "drizzle-orm";
import express, { type Request, type Response } from "express";
import { and, eq, type SQL } from "drizzle-orm";
import {
hosts,
sshCredentials,
@@ -85,6 +84,31 @@ export function isValidEntityType(value: unknown): value is SyncEntityType {
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
}
/**
* Locates the stored row a sync payload corresponds to.
*
* Read and write have to agree on this. A singleton entity is keyed on its
* owner rather than a sync id, and `user_preferences` the only singleton
* has no `id` column at all, so an update cannot fall back to one: `table.id`
* is undefined there and drizzle emits `WHERE = ?`.
*/
export function locateSyncRow(
entityType: SyncEntityType,
userId: string,
syncId: string,
): SQL {
const { table, singleton } = ENTITY_CONFIG[entityType];
if (singleton) {
return eq(table.userId, userId);
}
return and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
)!;
}
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
@@ -301,19 +325,12 @@ router.post(
}
try {
const { table, singleton } = ENTITY_CONFIG[entityType];
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
await context.drizzle
.delete(table as typeof hosts)
.where(
singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
);
.where(locateSyncRow(entityType, userId, syncId));
await createCurrentSyncTombstoneRepository().record(
userId,
@@ -372,20 +389,17 @@ router.post(
}
try {
// singleton is still needed below: those tables have no sync_id column
// for the insert to populate.
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(
singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
)
.where(locateRow)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
@@ -407,12 +421,7 @@ router.post(
const updatedRows = await context.drizzle
.update(table as typeof hosts)
.set(encryptedPayload)
.where(
and(
eq((table as typeof hosts).id, existing.id as number),
eq(table.userId, userId),
),
)
.where(locateRow)
.returning();
resultRow = updatedRows[0] as Record<string, unknown>;
} else {
+34 -18
View File
@@ -1,7 +1,10 @@
import { Router } from "express";
import type { RequestHandler, Router as ExpressRouter } from "express";
import {
Router,
type RequestHandler,
type Router as ExpressRouter,
} from "express";
import { apiLogger } from "../../utils/logger.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
interface TailscaleDevice {
@@ -23,10 +26,15 @@ interface TailscaleAPIDevice {
nodeId?: string;
}
const TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
const DEFAULT_TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
const router = Router();
function normalizeApiBase(raw: string | null): string {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
return trimmed || DEFAULT_TAILSCALE_API_BASE;
}
export function registerTailscaleRoutes(
app: ExpressRouter,
authenticateJWT: RequestHandler,
@@ -56,20 +64,22 @@ export function registerTailscaleRoutes(
*/
router.get("/devices", authenticateJWT, async (_req, res) => {
try {
const apiKey =
(await createCurrentSettingsRepository().get("tailscale_api_key")) ??
"";
const settingsRepo = createCurrentSettingsRepository();
const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? "";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
const apiBase = normalizeApiBase(
await settingsRepo.get("tailscale_api_base_url"),
);
const url = `${TAILSCALE_API_BASE}/tailnet/-/devices?fields=all`;
const url = `${apiBase}/tailnet/-/devices?fields=all`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
"User-Agent": "Termix/1.0",
},
dispatcher: getProxyAgent(url),
dispatcher: getFetchDispatcher(url),
});
if (!response.ok) {
@@ -78,13 +88,17 @@ export function registerTailscaleRoutes(
status: response.status,
});
if (response.status === 401 || response.status === 403) {
return res
.status(401)
.json({ error: "Invalid Tailscale API key", devices: [] });
return res.status(401).json({
error: "Invalid Tailscale API key",
devices: [],
hasApiKey: true,
});
}
return res
.status(502)
.json({ error: "Tailscale API error", devices: [] });
return res.status(502).json({
error: "Tailscale API error",
devices: [],
hasApiKey: true,
});
}
const data = (await response.json()) as { devices: TailscaleAPIDevice[] };
@@ -103,9 +117,11 @@ export function registerTailscaleRoutes(
apiLogger.error("Failed to fetch Tailscale devices", err, {
operation: "tailscale_devices",
});
res
.status(500)
.json({ error: "Failed to fetch Tailscale devices", devices: [] });
res.status(500).json({
error: "Failed to fetch Tailscale devices",
devices: [],
hasApiKey: true,
});
}
});

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