mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [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.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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 needs DNS resolution.
|
||||
*/
|
||||
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);
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
|
||||
// An explicitly allowlisted hostname may resolve to a private address. It
|
||||
// must use the private fetch path; sending it through safeOutboundFetch
|
||||
// would reject it after DNS resolution and make hostname allowlist entries
|
||||
// ineffective. Only administrators can write this list.
|
||||
if (normalized.includes(host)) {
|
||||
return { allowed: true, isPrivate: true };
|
||||
}
|
||||
|
||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,162 +0,0 @@
|
||||
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];
|
||||
},
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -1,174 +0,0 @@
|
||||
import { fetchWithProxy } 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 use the installed Undici fetch implementation with
|
||||
* its matching proxy dispatcher -- still respecting 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 fetchWithProxy(url, init);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -1,182 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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: [],
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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. Route-level RBAC gates the
|
||||
* HTTP API, but tools run in-process with the calling user's identity and never
|
||||
* pass through a router, so "the assistant cannot reach credentials or user
|
||||
* administration" is 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",
|
||||
"stepca",
|
||||
"step_ca",
|
||||
"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,
|
||||
}));
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
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>();
|
||||
@@ -1,279 +0,0 @@
|
||||
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,
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -1,331 +0,0 @@
|
||||
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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,72 +0,0 @@
|
||||
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,44 @@
|
||||
/**
|
||||
* The allowed-users list an external login method passes with its identity:
|
||||
* one pattern per line or comma, "*" for anyone, "@example.com" for a
|
||||
* domain, "*" as a wildcard inside a pattern, or an exact name. Empty allows
|
||||
* everyone.
|
||||
*/
|
||||
export function isExternalUserAllowed(
|
||||
allowedUsers: string,
|
||||
identifier: string,
|
||||
email?: string,
|
||||
): boolean {
|
||||
if (!allowedUsers || !allowedUsers.trim()) return true;
|
||||
const patterns = allowedUsers
|
||||
.split(/[\n,]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (patterns.length === 0) return true;
|
||||
|
||||
const values = [
|
||||
identifier,
|
||||
...(email && email !== identifier ? [email] : []),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern === "*") return true;
|
||||
if (pattern.includes("*")) {
|
||||
const escaped = pattern
|
||||
.toLowerCase()
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*");
|
||||
const regex = new RegExp(`^${escaped}$`);
|
||||
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
|
||||
continue;
|
||||
}
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
if (pattern.toLowerCase().startsWith("@")) {
|
||||
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
|
||||
} else {
|
||||
if (value.toLowerCase() === pattern.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Password login, core's one built-in method. It only proves who the user is;
|
||||
* the pipeline does second factors and the session.
|
||||
*/
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { createCurrentUserRepository } from "../database/repositories/factory.js";
|
||||
import { getPasswordLoginStatus } from "./core-auth.js";
|
||||
import { registerLoginMethod, type LoginMethod } from "./registry.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export async function verifyPasswordLogin(request: {
|
||||
body: Record<string, unknown>;
|
||||
ip?: string;
|
||||
socket?: { remoteAddress?: string };
|
||||
}): Promise<VerifiedIdentity & { rateLimitUsername: string }> {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
throw new LoginMethodError(
|
||||
"Password login is disabled while trusted proxy authentication is enabled",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const { username, password } = request.body;
|
||||
const clientIp = request.ip || request.socket?.remoteAddress || "unknown";
|
||||
|
||||
if (!nonEmpty(username) || !nonEmpty(password)) {
|
||||
throw new LoginMethodError("Invalid username or password", 400);
|
||||
}
|
||||
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Login attempt blocked due to rate limiting", {
|
||||
operation: "user_login_blocked",
|
||||
username,
|
||||
ip: clientIp,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lockStatus.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!(await getPasswordLoginStatus()).allowed) {
|
||||
throw new LoginMethodError(
|
||||
"Password authentication is currently disabled",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findByUsername(username);
|
||||
if (!user) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn("Login failed: user not found", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
ip: clientIp,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
if (user.isOidc && (!user.passwordHash || user.passwordHash.trim() === "")) {
|
||||
authLogger.warn("OIDC-only user attempted traditional login", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new LoginMethodError("This user uses external authentication", 403);
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isMatch) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, username);
|
||||
authLogger.warn("Login failed: incorrect password", {
|
||||
operation: "user_login",
|
||||
username,
|
||||
userId: user.id,
|
||||
ip: clientIp,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
userId: user.id,
|
||||
password,
|
||||
rememberMe: !!request.body.rememberMe,
|
||||
rateLimitUsername: username,
|
||||
};
|
||||
}
|
||||
|
||||
const passwordLoginMethod: LoginMethod = {
|
||||
id: "password",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.password",
|
||||
kind: "form",
|
||||
describe: async () => {
|
||||
const status = await getPasswordLoginStatus();
|
||||
return [{ id: "password", label: "Password", enabled: status.allowed }];
|
||||
},
|
||||
verify: (request) => verifyPasswordLogin(request as never),
|
||||
};
|
||||
|
||||
export function registerPasswordLoginMethod(): () => void {
|
||||
return registerLoginMethod(passwordLoginMethod);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Core's own login method (password) and the lockout guard.
|
||||
*/
|
||||
|
||||
import { getCurrentSettingValue } from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { registerPasswordLoginMethod } from "./builtin-login-methods.js";
|
||||
import { listLoginMethods } from "./registry.js";
|
||||
import { LoginMethodError } from "./types.js";
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Registers core's login methods and factors once. */
|
||||
export function ensureCoreLoginProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerPasswordLoginMethod();
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetCoreLoginProvidersForTests(): void {
|
||||
registered = false;
|
||||
}
|
||||
|
||||
/** The admin setting (or ALLOW_PASSWORD_LOGIN), before the lockout guard. */
|
||||
function isPasswordLoginSettingOn(): boolean {
|
||||
const envVal = process.env.ALLOW_PASSWORD_LOGIN;
|
||||
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
|
||||
try {
|
||||
const value = getCurrentSettingValue("allow_password_login");
|
||||
return value ? value === "true" : true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a new second-factor enrolment when core could not honour it: with
|
||||
* trusted proxy login on the proxy decides who signs in, and with password
|
||||
* login off users sign in through external methods that skip second factors
|
||||
* by default.
|
||||
*/
|
||||
export function assertSecondFactorEnrollmentAllowed(): void {
|
||||
if (isTrustedProxyAuthEnabled()) {
|
||||
throw new LoginMethodError(
|
||||
"Second factors are disabled while trusted proxy authentication is enabled",
|
||||
409,
|
||||
"trusted_proxy_enabled",
|
||||
);
|
||||
}
|
||||
if (!isPasswordLoginSettingOn()) {
|
||||
throw new LoginMethodError(
|
||||
"Cannot enable 2FA while password login is disabled. Enable password login first.",
|
||||
409,
|
||||
"password_login_disabled",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Off by default, matching 2.8: second factors only ran after password and
|
||||
* other local form logins. On, they also run after external methods (SSO,
|
||||
* LDAP), which needs the pending-cookie/temp_token step those methods'
|
||||
* clients (Termix-Mobile included) may not handle yet.
|
||||
*/
|
||||
export function isSecondFactorAfterExternalLoginEnabled(): boolean {
|
||||
try {
|
||||
const value = getCurrentSettingValue("second_factor_after_external_login");
|
||||
return value === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anyone could still sign in without a password: trusted proxy auth,
|
||||
* or a login method reporting an enabled instance (an SSO provider).
|
||||
*/
|
||||
async function hasOtherEnabledLoginMethod(): Promise<boolean> {
|
||||
if (isTrustedProxyAuthEnabled()) return true;
|
||||
ensureCoreLoginProviders();
|
||||
for (const method of listLoginMethods()) {
|
||||
if (method.id === "password" || !method.describe) continue;
|
||||
try {
|
||||
const instances = await method.describe();
|
||||
if (instances.some((instance) => instance.enabled)) return true;
|
||||
} catch {
|
||||
// A method that cannot describe itself does not count.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface PasswordLoginStatus {
|
||||
allowed: boolean;
|
||||
/** Turned off by the admin but kept on so nobody is locked out. */
|
||||
forced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password login is off only when the admin turned it off AND another way to
|
||||
* sign in exists. Otherwise it stays on, with a warning, so that disabling it
|
||||
* (or losing the only SSO provider) can never lock everyone out.
|
||||
*/
|
||||
export async function getPasswordLoginStatus(): Promise<PasswordLoginStatus> {
|
||||
if (isPasswordLoginSettingOn()) return { allowed: true, forced: false };
|
||||
if (await hasOtherEnabledLoginMethod()) {
|
||||
return { allowed: false, forced: false };
|
||||
}
|
||||
authLogger.warn(
|
||||
"Password login is disabled but no other login method is enabled; keeping it on",
|
||||
{ operation: "password_login_lockout_guard" },
|
||||
);
|
||||
return { allowed: true, forced: true };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Whether a user signs in through an external login (SSO, LDAP or any login
|
||||
* plugin) rather than only a password. Read from the is_oidc column, which
|
||||
* keeps its 2.8 name until 3.0.0 replaces it with user_external_identities.
|
||||
*/
|
||||
export function isExternalAccount(user: {
|
||||
isOidc?: boolean | number | null;
|
||||
}): boolean {
|
||||
return !!user.isOidc;
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Every login runs through here:
|
||||
*
|
||||
* method -> verified identity -> find or provision the user -> unlock the
|
||||
* data key -> second factors (fail closed) -> session
|
||||
*
|
||||
* A method never mints a session. It says who the user is and core does the
|
||||
* rest, so password, passkey, OIDC, LDAP and any plugin method get the same
|
||||
* second factors, the same cookie and the same audit line.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
getDeviceId,
|
||||
parseUserAgent,
|
||||
} from "../utils/user-agent-parser.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import {
|
||||
ensureCoreLoginProviders,
|
||||
isSecondFactorAfterExternalLoginEnabled,
|
||||
} from "./core-auth.js";
|
||||
import { findOrProvisionExternalUser } from "./provisioning.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
getSecondFactor,
|
||||
listSecondFactors,
|
||||
type SecondFactor,
|
||||
} from "./registry.js";
|
||||
import {
|
||||
issueSession,
|
||||
sendSession,
|
||||
syncSharedCredentialsForUserRoles,
|
||||
} from "./session-issuer.js";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PendingLogin,
|
||||
type VerifiedIdentity,
|
||||
} from "./types.js";
|
||||
|
||||
const PENDING_TTL_MS = 10 * 60 * 1000;
|
||||
const PENDING_LOGIN_COOKIE = "termix_pending_login";
|
||||
|
||||
const pendingLogins = new Map<string, PendingLogin>();
|
||||
|
||||
function prunePending(now = Date.now()): void {
|
||||
for (const [key, pending] of pendingLogins) {
|
||||
if (now - pending.createdAt > PENDING_TTL_MS) pendingLogins.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function pendingKey(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetPendingLoginsForTests(): void {
|
||||
pendingLogins.clear();
|
||||
}
|
||||
|
||||
export interface SecondFactorState {
|
||||
/** Factors the user must pass, all with an active provider. */
|
||||
required: SecondFactor[];
|
||||
/** Enrolled in a factor whose plugin is disabled or missing. */
|
||||
blocked: { pluginId: string; factorId: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which second factors apply to a user. A factor recorded in
|
||||
* user_second_factors whose plugin is not running blocks the login: skipping
|
||||
* it would turn "disable the plugin" into "remove everyone's 2FA".
|
||||
*/
|
||||
export async function evaluateSecondFactors(
|
||||
userId: string,
|
||||
): Promise<SecondFactorState> {
|
||||
ensureCoreLoginProviders();
|
||||
const rows =
|
||||
await createCurrentUserAuthRepository().listSecondFactors(userId);
|
||||
const required = new Map<string, SecondFactor>();
|
||||
const blocked: SecondFactorState["blocked"] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const factor = getSecondFactor(row.pluginId, row.factorId);
|
||||
if (!factor) {
|
||||
blocked.push({ pluginId: row.pluginId, factorId: row.factorId });
|
||||
continue;
|
||||
}
|
||||
required.set(`${factor.pluginId}:${factor.id}`, factor);
|
||||
}
|
||||
|
||||
// A factor can also report enrolment itself, for data from before
|
||||
// user_second_factors existed.
|
||||
for (const factor of listSecondFactors()) {
|
||||
const key = `${factor.pluginId}:${factor.id}`;
|
||||
if (required.has(key)) continue;
|
||||
try {
|
||||
if (await factor.isEnrolled(userId)) required.set(key, factor);
|
||||
} catch (error) {
|
||||
// Unknown is not "not enrolled": the login stops instead of skipping
|
||||
// a factor the user may well have.
|
||||
blocked.push({ pluginId: factor.pluginId, factorId: factor.id });
|
||||
authLogger.warn("Second factor enrolment check failed", {
|
||||
operation: "second_factor_enrolment_check",
|
||||
factorId: factor.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { required: [...required.values()], blocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether second factors should even be asked for this login. Off, they only
|
||||
* run for password and other local form logins, matching 2.8: the setting
|
||||
* governs external methods (SSO, LDAP) alone.
|
||||
*/
|
||||
function shouldRunSecondFactors(methodId: string): boolean {
|
||||
ensureCoreLoginProviders();
|
||||
const method = getLoginMethod(methodId);
|
||||
if (!method?.external) return true;
|
||||
return isSecondFactorAfterExternalLoginEnabled();
|
||||
}
|
||||
|
||||
async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
||||
if (!fingerprint) return false;
|
||||
const trusted = await AuthManager.getInstance().isTrustedDevice(
|
||||
userId,
|
||||
fingerprint,
|
||||
);
|
||||
if (trusted) {
|
||||
authLogger.info("Second factor bypassed for trusted device", {
|
||||
operation: "totp_bypass",
|
||||
userId,
|
||||
deviceFingerprint: fingerprint,
|
||||
});
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
type DeviceType = ReturnType<typeof parseUserAgent>["type"];
|
||||
|
||||
async function resolveUser(
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
): Promise<UserRecord> {
|
||||
if (identity.kind === "external") {
|
||||
return findOrProvisionExternalUser(identity, deviceType);
|
||||
}
|
||||
const user = await createCurrentUserRepository().findById(identity.userId);
|
||||
if (!user)
|
||||
throw new LoginMethodError("User not found", 404, "user_not_found");
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the user's data key. A password login migrates and opens the key
|
||||
* with the password; everything else uses the server-held wrapping.
|
||||
*/
|
||||
async function unlockUser(
|
||||
user: UserRecord,
|
||||
identity: VerifiedIdentity,
|
||||
deviceType: DeviceType,
|
||||
): Promise<void> {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
if (identity.kind === "external" || user.isOidc) {
|
||||
try {
|
||||
await authManager.authenticateExternalUser(user.id, deviceType);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to set up external user encryption", error, {
|
||||
operation: "external_user_encryption_setup_failed",
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = identity.password
|
||||
? await authManager.authenticateUser(user.id, identity.password, deviceType)
|
||||
: await authManager.unlockWithSystemKey(user.id, deviceType);
|
||||
if (!ok) {
|
||||
throw new LoginMethodError(
|
||||
identity.password
|
||||
? "Incorrect password"
|
||||
: (identity.unlockError ?? "This account could not be unlocked"),
|
||||
401,
|
||||
"unlock_failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoginContext {
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
/** Username the rate limiter counted, cleared on success. */
|
||||
rateLimitUsername?: string;
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| {
|
||||
kind: "session";
|
||||
token: string;
|
||||
maxAge: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
kind: "second-factor";
|
||||
tempToken: string;
|
||||
factors: Array<{ id: string; pluginId: string; labelKey: string }>;
|
||||
rememberMe: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a verified identity to either a session or a second-factor step.
|
||||
* Throws LoginMethodError for anything the user should be told.
|
||||
*/
|
||||
export async function runLogin(
|
||||
req: Request,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
): Promise<LoginResult> {
|
||||
const deviceType = parseUserAgent(req).type;
|
||||
const user = await resolveUser(identity, deviceType);
|
||||
|
||||
await unlockUser(user, identity, deviceType);
|
||||
if ("password" in identity && identity.password) {
|
||||
// A 2.8 key still wrapped by the password only opens here, so this is the
|
||||
// first chance to move that user's TOTP secret and channel configs.
|
||||
const { runTotpMigration } = await import("../upgrade/totp-migration.js");
|
||||
await runTotpMigration(user.id);
|
||||
const { runNotificationChannelMigration } =
|
||||
await import("../upgrade/notification-channel-migration.js");
|
||||
await runNotificationChannelMigration(user.id);
|
||||
const { runProtocolAuthMigration } =
|
||||
await import("../upgrade/protocol-auth-migration.js");
|
||||
await runProtocolAuthMigration(user.id);
|
||||
}
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
user.id,
|
||||
`${context.methodId}_role_shared_credentials`,
|
||||
);
|
||||
|
||||
const factors = shouldRunSecondFactors(context.methodId)
|
||||
? await evaluateSecondFactors(user.id)
|
||||
: { required: [], blocked: [] };
|
||||
if (factors.blocked.length > 0) {
|
||||
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
||||
operation: "second_factor_unavailable",
|
||||
userId: user.id,
|
||||
factors: factors.blocked,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login_blocked_second_factor",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ unavailable: factors.blocked }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
errorMessage: "Enrolled second factor unavailable",
|
||||
});
|
||||
throw new LoginMethodError(
|
||||
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
403,
|
||||
"second_factor_unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
const ssoClaims = {
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
oidcSub: identity.oidcSub ?? null,
|
||||
oidcSid: identity.oidcSid ?? null,
|
||||
};
|
||||
|
||||
if (
|
||||
factors.required.length > 0 &&
|
||||
!identity.mfaSatisfied &&
|
||||
!(await isTrustedDevice(req, user.id))
|
||||
) {
|
||||
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
||||
user.id,
|
||||
{ pendingTOTP: true, expiresIn: "10m" },
|
||||
);
|
||||
prunePending();
|
||||
pendingLogins.set(pendingKey(tempToken), {
|
||||
userId: user.id,
|
||||
methodId: context.methodId,
|
||||
rememberMe: context.rememberMe,
|
||||
...ssoClaims,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return {
|
||||
kind: "second-factor",
|
||||
tempToken,
|
||||
rememberMe: context.rememberMe,
|
||||
factors: factors.required.map((factor) => ({
|
||||
id: factor.id,
|
||||
pluginId: factor.pluginId,
|
||||
labelKey: factor.labelKey,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const session = await issueSession(req, user, {
|
||||
methodId: context.methodId,
|
||||
rememberMe: context.rememberMe,
|
||||
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
||||
longLivedForApps: identity.kind === "external",
|
||||
...ssoClaims,
|
||||
});
|
||||
return { kind: "session", ...session };
|
||||
}
|
||||
|
||||
/** JSON form of a login result, the shape the login screen expects. */
|
||||
function secondFactorBody(
|
||||
result: Extract<LoginResult, { kind: "second-factor" }>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
requires_second_factor: true,
|
||||
temp_token: result.tempToken,
|
||||
rememberMe: result.rememberMe,
|
||||
second_factors: result.factors,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a form login and writes the response. */
|
||||
export async function respondWithLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
): Promise<Response> {
|
||||
const result = await runLogin(req, identity, context);
|
||||
if (result.kind === "second-factor") {
|
||||
return res.json(secondFactorBody(result));
|
||||
}
|
||||
return sendSession(req, res, result);
|
||||
}
|
||||
|
||||
export function sendLoginError(res: Response, error: unknown): Response {
|
||||
if (error instanceof LoginMethodError) {
|
||||
const remainingTime = (error as { remainingTime?: number }).remainingTime;
|
||||
return res.status(error.status).json({
|
||||
error: error.message,
|
||||
...(error.code ? { code: error.code } : {}),
|
||||
...(remainingTime !== undefined ? { remainingTime } : {}),
|
||||
});
|
||||
}
|
||||
authLogger.error("Login failed", error);
|
||||
return res.status(500).json({ error: "Login failed" });
|
||||
}
|
||||
|
||||
export interface PendingLoginLookup {
|
||||
userId: string;
|
||||
token: string;
|
||||
pending: PendingLogin | null;
|
||||
}
|
||||
|
||||
/** Checks a pending token from the body or the redirect cookie. */
|
||||
export async function readPendingLogin(
|
||||
req: Request,
|
||||
): Promise<PendingLoginLookup | null> {
|
||||
const token =
|
||||
(typeof req.body?.temp_token === "string" && req.body.temp_token) ||
|
||||
(req.cookies?.[PENDING_LOGIN_COOKIE] as string | undefined);
|
||||
if (!token) return null;
|
||||
const decoded = await AuthManager.getInstance().verifyJWTToken(token);
|
||||
if (!decoded || !decoded.pendingTOTP) return null;
|
||||
prunePending();
|
||||
const pending = pendingLogins.get(pendingKey(token)) ?? null;
|
||||
return { userId: decoded.userId, token, pending };
|
||||
}
|
||||
|
||||
function consumePendingLogin(token: string): void {
|
||||
pendingLogins.delete(pendingKey(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a login that stopped for a second factor. The pending token comes
|
||||
* from the body, or from the cookie a redirect method set. Without a factor
|
||||
* id the user's first required factor answers, for the 2.8 route.
|
||||
*/
|
||||
export async function verifySecondFactorAndRespond(
|
||||
req: Request,
|
||||
res: Response,
|
||||
factorId?: string,
|
||||
): Promise<Response> {
|
||||
const lookup = await readPendingLogin(req);
|
||||
if (!lookup) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
const user = await createCurrentUserRepository().findById(lookup.userId);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
const lockStatus = loginRateLimiter.isTOTPLocked(user.id);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("Second factor blocked due to rate limiting", {
|
||||
operation: "totp_verify_blocked",
|
||||
userId: user.id,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
return res.status(429).json({
|
||||
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
code: "TOTP_RATE_LIMITED",
|
||||
});
|
||||
}
|
||||
loginRateLimiter.recordFailedTOTPAttempt(user.id);
|
||||
|
||||
const factors = await evaluateSecondFactors(user.id);
|
||||
if (factors.blocked.length > 0) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
code: "second_factor_unavailable",
|
||||
});
|
||||
}
|
||||
const factor = factorId
|
||||
? factors.required.find((candidate) => candidate.id === factorId)
|
||||
: factors.required[0];
|
||||
if (!factor) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "That second factor is not enabled for this user" });
|
||||
}
|
||||
|
||||
const result = await factor.verify(user.id, req.body ?? {});
|
||||
if (result !== true) {
|
||||
if (result && typeof result === "object") {
|
||||
return res.status(result.code === "SESSION_EXPIRED" ? 401 : 400).json({
|
||||
error: result.error,
|
||||
...(result.code ? { code: result.code } : {}),
|
||||
});
|
||||
}
|
||||
authLogger.warn("Second factor verification failed", {
|
||||
operation: "totp_verify_failed",
|
||||
userId: user.id,
|
||||
factorId: factor.id,
|
||||
});
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login_second_factor_failed",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ factor: factor.id }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Invalid TOTP code",
|
||||
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(user.id),
|
||||
});
|
||||
}
|
||||
loginRateLimiter.resetTOTPAttempts(user.id);
|
||||
|
||||
const rememberMe =
|
||||
typeof req.body?.rememberMe === "boolean"
|
||||
? req.body.rememberMe
|
||||
: (lookup.pending?.rememberMe ?? false);
|
||||
|
||||
if (rememberMe) {
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const fingerprint = generateDeviceFingerprint(deviceInfo, getDeviceId(req));
|
||||
if (fingerprint) {
|
||||
await AuthManager.getInstance().addTrustedDevice(
|
||||
user.id,
|
||||
fingerprint,
|
||||
deviceInfo.type,
|
||||
deviceInfo.deviceInfo,
|
||||
);
|
||||
authLogger.info("Device automatically trusted via Remember Me", {
|
||||
operation: "totp_auto_trust",
|
||||
userId: user.id,
|
||||
deviceType: deviceInfo.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pending = lookup.pending;
|
||||
const session = await issueSession(req, user, {
|
||||
methodId: pending?.methodId ?? "password",
|
||||
rememberMe,
|
||||
ssoProviderId: pending?.ssoProviderId ?? null,
|
||||
oidcSub: pending?.oidcSub ?? null,
|
||||
oidcSid: pending?.oidcSid ?? null,
|
||||
});
|
||||
|
||||
consumePendingLogin(lookup.token);
|
||||
res.clearCookie(
|
||||
PENDING_LOGIN_COOKIE,
|
||||
AuthManager.getInstance().getClearCookieOptions(req),
|
||||
);
|
||||
return sendSession(req, res, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes a redirect login (OIDC and friends): back to where the browser
|
||||
* came from, with a cookie, or with the token in the URL for the desktop and
|
||||
* mobile app callbacks that cannot read one.
|
||||
*/
|
||||
export async function respondWithRedirectLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
identity: VerifiedIdentity,
|
||||
context: LoginContext,
|
||||
isTokenCallback: (url: string) => boolean,
|
||||
): Promise<void> {
|
||||
const returnTo = identity.returnTo;
|
||||
if (!returnTo) {
|
||||
res.status(500).json({ error: "Login method did not say where to return" });
|
||||
return;
|
||||
}
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
let result: LoginResult;
|
||||
try {
|
||||
result = await runLogin(req, identity, context);
|
||||
} catch (error) {
|
||||
redirectWithLoginError(res, returnTo, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(returnTo);
|
||||
const tokenCallback = isTokenCallback(returnTo);
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
|
||||
if (result.kind === "second-factor") {
|
||||
url.searchParams.set("second_factor", "1");
|
||||
url.searchParams.set(
|
||||
"second_factors",
|
||||
result.factors.map((factor) => factor.id).join(","),
|
||||
);
|
||||
if (tokenCallback) {
|
||||
url.searchParams.set("temp_token", result.tempToken);
|
||||
res.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
res
|
||||
.cookie(
|
||||
PENDING_LOGIN_COOKIE,
|
||||
result.tempToken,
|
||||
authManager.getSecureCookieOptions(req, PENDING_TTL_MS),
|
||||
)
|
||||
.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
url.searchParams.set("success", "true");
|
||||
if (tokenCallback) {
|
||||
url.searchParams.set("token", result.token);
|
||||
res.redirect(url.toString());
|
||||
return;
|
||||
}
|
||||
res
|
||||
.cookie(
|
||||
"jwt",
|
||||
result.token,
|
||||
authManager.getSecureCookieOptions(req, result.maxAge),
|
||||
)
|
||||
.redirect(url.toString());
|
||||
}
|
||||
|
||||
export function redirectWithLoginError(
|
||||
res: Response,
|
||||
returnTo: string,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Redirect login failed", error);
|
||||
}
|
||||
const url = new URL(returnTo);
|
||||
url.searchParams.set(
|
||||
"error",
|
||||
error instanceof LoginMethodError
|
||||
? (error.code ?? error.message)
|
||||
: "OIDC authentication failed",
|
||||
);
|
||||
res.redirect(url.toString());
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Finds or creates the user behind an external sign-in (OIDC, LDAP, a
|
||||
* plugin's provider). The rules used to be copied into every SSO route; they
|
||||
* live here once so every login method gets the same ones:
|
||||
*
|
||||
* - the allowed-users list is checked on every sign-in, new user or not
|
||||
* - the first user ever becomes admin and skips the list
|
||||
* - anyone else is only created when SSO auto-provisioning is on
|
||||
* - an admin group, when the provider reports one, keeps admin in step
|
||||
* - a mapped role set, when the provider reports one, keeps roles in step
|
||||
*/
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { isExternalUserAllowed } from "./allowed-users.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** SSO auto-provisioning: the admin setting, or OIDC_ALLOW_REGISTRATION. */
|
||||
async function isExternalProvisioningAllowed(): Promise<boolean> {
|
||||
try {
|
||||
if (
|
||||
await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_auto_provision",
|
||||
false,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the environment
|
||||
}
|
||||
return (
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() === "true"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a newly provisioned user must wait for an admin. There is no
|
||||
* approval queue yet, so nobody does; a plugin or a later release can change
|
||||
* that here without touching any login method.
|
||||
*/
|
||||
async function isApprovalRequired(
|
||||
_identity: ExternalIdentity,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowed(identity: ExternalIdentity): boolean {
|
||||
if (!identity.allowedUsers) return true;
|
||||
const email = identity.email ?? undefined;
|
||||
return (
|
||||
isExternalUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
||||
(!!identity.legacyIdentifier &&
|
||||
isExternalUserAllowed(
|
||||
identity.allowedUsers,
|
||||
identity.legacyIdentifier,
|
||||
email,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
async function syncAdmin(
|
||||
user: UserRecord,
|
||||
isAdmin: boolean,
|
||||
): Promise<UserRecord> {
|
||||
if (!!user.isAdmin === isAdmin) return user;
|
||||
const updated =
|
||||
(await createCurrentUserRepository().update(user.id, { isAdmin })) ?? user;
|
||||
try {
|
||||
await createCurrentRoleRepository().switchUserRoleName({
|
||||
userId: user.id,
|
||||
addRoleName: isAdmin ? "admin" : "user",
|
||||
removeRoleName: isAdmin ? "user" : "admin",
|
||||
grantedBy: user.id,
|
||||
});
|
||||
} catch {
|
||||
// Roles follow on the next sign-in; the admin flag itself is saved.
|
||||
}
|
||||
authLogger.info("Admin status synced from the identity provider", {
|
||||
operation: "external_admin_sync",
|
||||
userId: user.id,
|
||||
isAdmin,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds and removes the roles a provider's group map manages. Roles assigned
|
||||
* by hand, and roles outside the map, are never touched.
|
||||
*/
|
||||
async function applyRoleSync(
|
||||
userId: string,
|
||||
roleSync: { desired: string[]; managed: string[] },
|
||||
): Promise<void> {
|
||||
const desired = new Set(roleSync.desired);
|
||||
const managed = new Set(roleSync.managed);
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
const currentRoles = await roleRepository.listUserRoles(userId);
|
||||
const currentNames = new Set(currentRoles.map((role) => role.roleName));
|
||||
|
||||
const toAdd = [...desired].filter((name) => !currentNames.has(name));
|
||||
const toRemove = currentRoles.filter(
|
||||
(role) => managed.has(role.roleName) && !desired.has(role.roleName),
|
||||
);
|
||||
|
||||
for (const roleName of toAdd) {
|
||||
const assigned = await roleRepository.assignRoleNameToUser({
|
||||
userId,
|
||||
roleName,
|
||||
grantedBy: userId,
|
||||
});
|
||||
if (!assigned) {
|
||||
authLogger.warn("Role map references a role that does not exist", {
|
||||
operation: "external_role_map_missing_role",
|
||||
userId,
|
||||
roleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const role of toRemove) {
|
||||
await roleRepository.removeRoleFromUser(userId, role.roleId);
|
||||
}
|
||||
|
||||
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||
authLogger.info("Roles synced from provider group membership", {
|
||||
operation: "external_role_map_sync",
|
||||
userId,
|
||||
added: toAdd,
|
||||
removed: toRemove.map((role) => role.roleName),
|
||||
});
|
||||
PermissionManager.getInstance().invalidateUserPermissionCache(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function findLinkedUser(
|
||||
identity: ExternalIdentity,
|
||||
): Promise<UserRecord | null> {
|
||||
const identities = createCurrentUserAuthRepository();
|
||||
const users = createCurrentUserRepository();
|
||||
|
||||
const linked = await identities.findIdentity(
|
||||
identity.provider,
|
||||
identity.subject,
|
||||
);
|
||||
if (linked) {
|
||||
const user = await users.findById(linked.userId);
|
||||
if (user) return user;
|
||||
}
|
||||
|
||||
// Accounts from before user_external_identities matched on the raw
|
||||
// identifier column; link them the first time they sign in. The column is
|
||||
// not scoped to a provider, so only an SSO account that has no link yet,
|
||||
// from the same provider when both say which, can be claimed this way.
|
||||
if (identity.legacyIdentifier) {
|
||||
const legacy = await users.findByExternalIdentifier(
|
||||
identity.legacyIdentifier,
|
||||
);
|
||||
if (legacy && (await mayClaimLegacyAccount(legacy, identity))) {
|
||||
await identities.linkIdentity({
|
||||
userId: legacy.id,
|
||||
providerId: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
});
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function mayClaimLegacyAccount(
|
||||
legacy: UserRecord,
|
||||
identity: ExternalIdentity,
|
||||
): Promise<boolean> {
|
||||
if (!legacy.isOidc) return false;
|
||||
if (
|
||||
legacy.ssoProviderId != null &&
|
||||
identity.ssoProviderId != null &&
|
||||
legacy.ssoProviderId !== identity.ssoProviderId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const links = await createCurrentUserAuthRepository().listIdentitiesForUser(
|
||||
legacy.id,
|
||||
);
|
||||
return links.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A username nobody else has. An identity provider's display name must never
|
||||
* give an SSO account the same username as a local one, such as "admin".
|
||||
*/
|
||||
async function freeUsername(
|
||||
wanted: string,
|
||||
selfId: string | null,
|
||||
): Promise<string> {
|
||||
const users = createCurrentUserRepository();
|
||||
let candidate = wanted;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const holder = await users.findByUsername(candidate);
|
||||
if (!holder || holder.id === selfId) return candidate;
|
||||
candidate = `${wanted}-${nanoid(4)}`;
|
||||
}
|
||||
return `${wanted}-${nanoid(10)}`;
|
||||
}
|
||||
|
||||
async function createUser(
|
||||
identity: ExternalIdentity,
|
||||
deviceType: string,
|
||||
): Promise<UserRecord> {
|
||||
const users = createCurrentUserRepository();
|
||||
const id = nanoid();
|
||||
const created = await users.createFirstSsoUser({
|
||||
id,
|
||||
username: await freeUsername(identity.name || identity.subject, null),
|
||||
passwordHash: "",
|
||||
isAdmin: !!identity.isAdmin,
|
||||
isOidc: true,
|
||||
oidcIdentifier:
|
||||
identity.legacyIdentifier ?? `${identity.provider}:${identity.subject}`,
|
||||
ssoProviderId: identity.ssoProviderId ?? null,
|
||||
});
|
||||
|
||||
try {
|
||||
const assigned = await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: created.user.isAdmin ? "admin" : "user",
|
||||
grantedBy: id,
|
||||
});
|
||||
if (!assigned) {
|
||||
authLogger.warn("Default role not found for a provisioned user", {
|
||||
operation: "external_default_role",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
} catch (roleError) {
|
||||
authLogger.error("Failed to assign a default role", roleError, {
|
||||
operation: "external_default_role",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? THIRTY_DAYS_MS
|
||||
: ONE_DAY_MS;
|
||||
await AuthManager.getInstance().registerExternalUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await users.delete(id);
|
||||
authLogger.error(
|
||||
"Failed to set up a provisioned user's encryption, user removed",
|
||||
encryptionError,
|
||||
{ operation: "external_user_create_failed", userId: id },
|
||||
);
|
||||
throw new LoginMethodError(
|
||||
"Failed to setup user security - user creation cancelled",
|
||||
500,
|
||||
"setup_failed",
|
||||
);
|
||||
}
|
||||
|
||||
await createCurrentUserAuthRepository().linkIdentity({
|
||||
userId: id,
|
||||
providerId: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
});
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("external_user_create");
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist a provisioned user", saveError, {
|
||||
operation: "external_user_create_save_failed",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
return created.user;
|
||||
}
|
||||
|
||||
export async function findOrProvisionExternalUser(
|
||||
identity: ExternalIdentity,
|
||||
deviceType: string,
|
||||
): Promise<UserRecord> {
|
||||
let user = await findLinkedUser(identity);
|
||||
|
||||
if (!user) {
|
||||
const isFirstUser = (await createCurrentUserRepository().countAll()) === 0;
|
||||
if (!isFirstUser) {
|
||||
if (!isAllowed(identity)) {
|
||||
authLogger.warn("External user not in the allowed list", {
|
||||
operation: "external_user_not_allowed",
|
||||
provider: identity.provider,
|
||||
});
|
||||
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
||||
}
|
||||
if (!(await isExternalProvisioningAllowed())) {
|
||||
authLogger.warn("External sign-in refused, provisioning is off", {
|
||||
operation: "external_registration_disabled",
|
||||
provider: identity.provider,
|
||||
});
|
||||
throw new LoginMethodError(
|
||||
"Registration is disabled",
|
||||
403,
|
||||
"registration_disabled",
|
||||
);
|
||||
}
|
||||
if (await isApprovalRequired(identity)) {
|
||||
throw new LoginMethodError(
|
||||
"Your account is waiting for an admin to approve it",
|
||||
403,
|
||||
"approval_required",
|
||||
);
|
||||
}
|
||||
}
|
||||
user = await createUser(identity, deviceType);
|
||||
} else {
|
||||
if (!isAllowed(identity)) {
|
||||
authLogger.warn("External user not in the allowed list", {
|
||||
operation: "external_user_not_allowed_existing",
|
||||
provider: identity.provider,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new LoginMethodError("User not allowed", 403, "user_not_allowed");
|
||||
}
|
||||
|
||||
// A user who also has a password picked their own username; leave it.
|
||||
const isDualAuth = !!user.passwordHash && user.passwordHash.trim() !== "";
|
||||
if (!isDualAuth && identity.name && user.username !== identity.name) {
|
||||
// Taken by someone else: keep the name the account already has.
|
||||
const holder = await createCurrentUserRepository().findByUsername(
|
||||
identity.name,
|
||||
);
|
||||
if (!holder || holder.id === user.id) {
|
||||
user =
|
||||
(await createCurrentUserRepository().update(user.id, {
|
||||
username: identity.name,
|
||||
})) ?? user;
|
||||
}
|
||||
}
|
||||
|
||||
if (identity.isAdmin !== undefined) {
|
||||
user = await syncAdmin(user, identity.isAdmin);
|
||||
}
|
||||
}
|
||||
|
||||
if (identity.roleSync) {
|
||||
try {
|
||||
await applyRoleSync(user.id, identity.roleSync);
|
||||
} catch (roleSyncError) {
|
||||
authLogger.error("Failed to sync roles from provider", roleSyncError, {
|
||||
operation: "external_role_map_sync_failed",
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Login methods and second factors, from core and from plugins.
|
||||
*
|
||||
* Password login is registered by core and always present. Everything else
|
||||
* (OIDC, LDAP, passkeys, TOTP, ...) registers here from a plugin through
|
||||
* ctx.auth.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginLoginMethod,
|
||||
PluginSecondFactor,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type LoginMethod = PluginLoginMethod & { pluginId: string };
|
||||
export type SecondFactor = PluginSecondFactor & { pluginId: string };
|
||||
|
||||
const loginMethods = new Map<string, LoginMethod>();
|
||||
const secondFactors = new Map<string, SecondFactor>();
|
||||
|
||||
function register<T extends { id: string; pluginId: string }>(
|
||||
map: Map<string, T>,
|
||||
kind: string,
|
||||
entry: T,
|
||||
): () => void {
|
||||
const existing = map.get(entry.id);
|
||||
if (existing && existing.pluginId !== entry.pluginId) {
|
||||
throw new Error(
|
||||
`${kind} "${entry.id}" is already provided by ${existing.pluginId}`,
|
||||
);
|
||||
}
|
||||
map.set(entry.id, entry);
|
||||
return () => {
|
||||
if (map.get(entry.id) === entry) map.delete(entry.id);
|
||||
};
|
||||
}
|
||||
|
||||
export function registerLoginMethod(method: LoginMethod): () => void {
|
||||
return register(loginMethods, "Login method", method);
|
||||
}
|
||||
|
||||
export function getLoginMethod(id: string): LoginMethod | undefined {
|
||||
return loginMethods.get(id);
|
||||
}
|
||||
|
||||
export function listLoginMethods(): LoginMethod[] {
|
||||
return [...loginMethods.values()];
|
||||
}
|
||||
|
||||
export function registerSecondFactor(factor: SecondFactor): () => void {
|
||||
return register(secondFactors, "Second factor", factor);
|
||||
}
|
||||
|
||||
export function getSecondFactor(
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): SecondFactor | undefined {
|
||||
const factor = secondFactors.get(factorId);
|
||||
return factor && factor.pluginId === pluginId ? factor : undefined;
|
||||
}
|
||||
|
||||
export function listSecondFactors(): SecondFactor[] {
|
||||
return [...secondFactors.values()];
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetAuthRegistryForTests(): void {
|
||||
loginMethods.clear();
|
||||
secondFactors.clear();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Admin view and reset of a user's second factors. Covers factors whose
|
||||
* plugin is disabled or gone, which is exactly when a user gets locked out
|
||||
* and an admin has to step in.
|
||||
*/
|
||||
|
||||
import {
|
||||
createCurrentTrustedDeviceRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { ensureCoreLoginProviders } from "./core-auth.js";
|
||||
import { getSecondFactor, listSecondFactors } from "./registry.js";
|
||||
|
||||
export interface UserSecondFactorSummary {
|
||||
pluginId: string;
|
||||
factorId: string;
|
||||
labelKey: string | null;
|
||||
/** False when the plugin behind it is not running. */
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export async function listUserSecondFactors(
|
||||
userId: string,
|
||||
): Promise<UserSecondFactorSummary[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const rows =
|
||||
await createCurrentUserAuthRepository().listSecondFactors(userId);
|
||||
const summaries: UserSecondFactorSummary[] = rows.map((row) => {
|
||||
const factor = getSecondFactor(row.pluginId, row.factorId);
|
||||
return {
|
||||
pluginId: row.pluginId,
|
||||
factorId: row.factorId,
|
||||
labelKey: factor?.labelKey ?? null,
|
||||
available: !!factor,
|
||||
};
|
||||
});
|
||||
|
||||
for (const factor of listSecondFactors()) {
|
||||
if (
|
||||
summaries.some(
|
||||
(summary) =>
|
||||
summary.pluginId === factor.pluginId &&
|
||||
summary.factorId === factor.id,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (await factor.isEnrolled(userId).catch(() => false)) {
|
||||
summaries.push({
|
||||
pluginId: factor.pluginId,
|
||||
factorId: factor.id,
|
||||
labelKey: factor.labelKey,
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every factor: runs each running factor's own reset (so TOTP clears
|
||||
* its secret), drops the enrolment rows including orphaned ones, and forgets
|
||||
* trusted devices so an old "remember me" cannot skip the new factor.
|
||||
*/
|
||||
export async function resetUserSecondFactors(
|
||||
userId: string,
|
||||
): Promise<Array<{ pluginId: string; factorId: string }>> {
|
||||
const factors = await listUserSecondFactors(userId);
|
||||
for (const summary of factors) {
|
||||
const factor = getSecondFactor(summary.pluginId, summary.factorId);
|
||||
if (!factor?.reset) continue;
|
||||
try {
|
||||
await factor.reset(userId);
|
||||
} catch (error) {
|
||||
authLogger.error("Second factor reset failed", error, {
|
||||
operation: "admin_reset_second_factor",
|
||||
userId,
|
||||
factorId: summary.factorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await createCurrentUserAuthRepository().clearSecondFactors(userId);
|
||||
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
|
||||
return factors.map(({ pluginId, factorId }) => ({ pluginId, factorId }));
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* The one place a login becomes a session: JWT, cookie, response body,
|
||||
* audit line and the user_login internal event. Every login method ends here, so
|
||||
* they all behave the same way.
|
||||
*/
|
||||
|
||||
import { isExternalAccount } from "./external-account.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { loginRateLimiter } from "../utils/login-rate-limiter.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { emitInternalEvent } from "../hosts/internal-events.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isNativeAppRequest(req: Request): boolean {
|
||||
return (
|
||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||
req.get("X-Electron-App") === "true"
|
||||
);
|
||||
}
|
||||
|
||||
/** Cookie lifetime: 30 days when remembered, else session_timeout_hours. */
|
||||
async function sessionCookieMaxAge(rememberMe: boolean): Promise<number> {
|
||||
if (rememberMe) return THIRTY_DAYS_MS;
|
||||
const value = await createCurrentSettingsRepository().get(
|
||||
"session_timeout_hours",
|
||||
);
|
||||
const hours = value ? parseInt(value, 10) || 24 : 24;
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export async function syncSharedCredentialsForUserRoles(
|
||||
userId: string,
|
||||
operation: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().snapshotForUserRoles(userId);
|
||||
const { SharedCredentialSecretsManager } =
|
||||
await import("../utils/shared-credential-secrets-manager.js");
|
||||
await SharedCredentialSecretsManager.getInstance().snapshotForUserRoles(
|
||||
userId,
|
||||
);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to sync role shared host secrets", {
|
||||
operation,
|
||||
userId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface IssueSessionOptions {
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
/** Username the rate limiter counted attempts under, to clear them. */
|
||||
rateLimitUsername?: string;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
||||
longLivedForApps?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuedSession {
|
||||
token: string;
|
||||
maxAge: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints the JWT and writes the audit line. The caller decides how the token
|
||||
* reaches the client: a JSON body with a cookie, or a redirect.
|
||||
*/
|
||||
export async function issueSession(
|
||||
req: Request,
|
||||
user: UserRecord,
|
||||
options: IssueSessionOptions,
|
||||
): Promise<IssuedSession> {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const token = await authManager.generateJWTToken(user.id, {
|
||||
rememberMe: options.rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
oidcSub: options.oidcSub ?? null,
|
||||
oidcSid: options.oidcSid ?? null,
|
||||
ssoProviderId: options.ssoProviderId ?? null,
|
||||
});
|
||||
|
||||
if (options.rateLimitUsername) {
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
loginRateLimiter.resetAttempts(clientIp, options.rateLimitUsername);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
details: JSON.stringify({ method: options.methodId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
emitInternalEvent("user_login", user.id, undefined, {
|
||||
username: user.username,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
authLogger.success("User login successful", {
|
||||
operation: "user_login_complete",
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
method: options.methodId,
|
||||
});
|
||||
|
||||
const maxAge =
|
||||
options.longLivedForApps &&
|
||||
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
||||
? THIRTY_DAYS_MS
|
||||
: await sessionCookieMaxAge(options.rememberMe);
|
||||
|
||||
return {
|
||||
token,
|
||||
maxAge,
|
||||
body: {
|
||||
success: true,
|
||||
is_admin: !!user.isAdmin,
|
||||
username: user.username,
|
||||
userId: user.id,
|
||||
is_external: isExternalAccount(user),
|
||||
// 2.8 name, kept until 3.0.0.
|
||||
is_oidc: !!user.isOidc,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
),
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Sets the cookie and sends the body. */
|
||||
export function sendSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
session: IssuedSession,
|
||||
): Response {
|
||||
const authManager = AuthManager.getInstance();
|
||||
return res
|
||||
.cookie(
|
||||
"jwt",
|
||||
session.token,
|
||||
authManager.getSecureCookieOptions(req, session.maxAge),
|
||||
)
|
||||
.json(session.body);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PluginVerifiedIdentity } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export { LoginMethodError } from "@termix/plugin-sdk/backend";
|
||||
|
||||
/** Extra fields core's own methods may set. Plugins never see these. */
|
||||
interface CoreIdentityExtras {
|
||||
/**
|
||||
* The identifier string the user row carried before user_external_identities
|
||||
* existed ("ldap:<provider>:<id>", "github:<provider>:<id>" or a bare OIDC
|
||||
* subject). Found users get their identity row linked lazily; new users keep
|
||||
* the column filled so older code that reads it still works.
|
||||
*/
|
||||
legacyIdentifier?: string;
|
||||
/** SSO provider row the session came from, for back-channel logout. */
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
/** Provider group to role mapping, applied on every login. */
|
||||
roleSync?: { desired: string[]; managed: string[] };
|
||||
/** Shown when the user's data key cannot be unlocked by this method. */
|
||||
unlockError?: string;
|
||||
/** Username the login rate limiter counted, cleared on success. */
|
||||
rateLimitUsername?: string;
|
||||
}
|
||||
|
||||
export type VerifiedIdentity = PluginVerifiedIdentity & CoreIdentityExtras;
|
||||
|
||||
/** What a login needs to remember while it waits for a second factor. */
|
||||
export interface PendingLogin {
|
||||
userId: string;
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
ssoProviderId?: number | null;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
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";
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
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 { resolveHostById } from "../hosts/host-resolver.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 trigger = { ...(request.triggerContext ?? {}) };
|
||||
trigger.type ??= request.triggerType;
|
||||
let host: TemplateContext["host"];
|
||||
if (request.triggerHostId) {
|
||||
try {
|
||||
const resolved = await resolveHostById(
|
||||
request.triggerHostId,
|
||||
automation.userId,
|
||||
);
|
||||
if (resolved) {
|
||||
host = {
|
||||
id: request.triggerHostId,
|
||||
name: resolved.name || resolved.ip,
|
||||
ip: resolved.ip,
|
||||
username: resolved.username,
|
||||
port: resolved.port,
|
||||
};
|
||||
trigger.hostName ??= host.name;
|
||||
}
|
||||
} catch {
|
||||
// A notification should still run with its numeric host id.
|
||||
}
|
||||
}
|
||||
|
||||
const template: TemplateContext = {
|
||||
host,
|
||||
trigger,
|
||||
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/automation-events.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,
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.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. Private delivery therefore needs
|
||||
* both a channel opt-in and an exact host in the administrator allowlist;
|
||||
* scheme validation, DNS pinning and redirect refusal remain in force.
|
||||
*/
|
||||
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 still goes through the guarded resolver. Only an exact host
|
||||
* authorized by an administrator may resolve to a private address.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
return safeOutboundFetch(rawUrl, init, allowlist);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
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,
|
||||
hostName:
|
||||
notification.context?.host?.name ??
|
||||
notification.context?.trigger?.hostName,
|
||||
hostId:
|
||||
notification.context?.host?.id ?? notification.context?.trigger?.hostId,
|
||||
ruleName: notification.title,
|
||||
ruleId: notification.context?.run?.automationId,
|
||||
triggerType: notification.context?.trigger?.type,
|
||||
value: notification.context?.trigger?.value,
|
||||
threshold: notification.context?.trigger?.threshold,
|
||||
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),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The core half of boot that runs once the database is open: user keys, the
|
||||
* auth singletons and the one-off data migrations core owns. The starter and
|
||||
* the boot integration tests share it so they cannot drift apart.
|
||||
*/
|
||||
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import { DataCrypto } from "./utils/data-crypto.js";
|
||||
import { ensurePreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js";
|
||||
import { systemLogger } from "./utils/logger.js";
|
||||
|
||||
/**
|
||||
* Copies the SQLite file before anything opens it, so the copy is the database
|
||||
* exactly as the previous version left it: before core's schema patches and
|
||||
* before any plugin adopts a table. Client-server engines are the operator's
|
||||
* to back up.
|
||||
*/
|
||||
export async function backupBeforeUpgrade(options: {
|
||||
dataDir: string;
|
||||
version: string;
|
||||
}): Promise<void> {
|
||||
const { needsExplicitPersist, resolveDatabaseDialect } =
|
||||
await import("./database/db/dialect.js");
|
||||
const dialect = resolveDatabaseDialect();
|
||||
if (needsExplicitPersist(dialect)) {
|
||||
ensurePreupgradeBackup(options);
|
||||
return;
|
||||
}
|
||||
systemLogger.info(
|
||||
`Skipping pre-upgrade backup on ${dialect} - back up the database yourself`,
|
||||
{ operation: "backend_init_db_backup_skipped", dialect },
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCoreBootMigrations(): Promise<void> {
|
||||
const { UserKeyManager } = await import("./utils/user-keys.js");
|
||||
await UserKeyManager.getInstance().initialize();
|
||||
|
||||
const { runBootDekMigration } =
|
||||
await import("./utils/crypto-migration/dek-migration.js");
|
||||
await runBootDekMigration({ cleanupLegacy: true });
|
||||
|
||||
const { runLegacySharedCredentialCleanup } =
|
||||
await import("./utils/crypto-migration/legacy-share-cleanup.js");
|
||||
await runLegacySharedCredentialCleanup();
|
||||
|
||||
await AuthManager.getInstance().initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
// Before anything snapshots shared secrets, which would drop a protocol
|
||||
// snapshot whose login has not been copied yet.
|
||||
const { runProtocolAuthMigration } =
|
||||
await import("./upgrade/protocol-auth-migration.js");
|
||||
await runProtocolAuthMigration();
|
||||
|
||||
const { runLegacySharedSshAuthOptInMigration } =
|
||||
await import("./utils/crypto-migration/legacy-shared-ssh-auth-opt-in-migration.js");
|
||||
await runLegacySharedSshAuthOptInMigration();
|
||||
|
||||
const { runSharedHostSecretsMigration } =
|
||||
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
||||
await runSharedHostSecretsMigration();
|
||||
|
||||
const { runPrivateSharedSshAuthMigration } =
|
||||
await import("./utils/crypto-migration/private-shared-ssh-auth-migration.js");
|
||||
await runPrivateSharedSshAuthMigration();
|
||||
|
||||
const { runExternalIdentityMigration } =
|
||||
await import("./upgrade/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
|
||||
const { runHostStatusConfigMigration } =
|
||||
await import("./utils/crypto-migration/host-status-config-migration.js");
|
||||
await runHostStatusConfigMigration();
|
||||
|
||||
const { runSshOptionsMigration } =
|
||||
await import("./utils/crypto-migration/ssh-options-migration.js");
|
||||
await runSshOptionsMigration();
|
||||
}
|
||||
+435
-543
File diff suppressed because it is too large.
Load diff
+295
-1614
File diff suppressed because it is too large.
Load diff
@@ -98,199 +98,17 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
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 {
|
||||
|
||||
+427
-1455
File diff suppressed because it is too large.
Load diff
+426
-1455
File diff suppressed because it is too large.
Load diff
+361
-1390
File diff suppressed because it is too large.
Load diff
@@ -1,407 +0,0 @@
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -1,712 +0,0 @@
|
||||
import { and, count, desc, eq, inArray, isNull, lt, or } from "drizzle-orm";
|
||||
import {
|
||||
alertFirings,
|
||||
alertRuleChannels,
|
||||
alertRules,
|
||||
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";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
type AlertRuleRecord = typeof alertRules.$inferSelect;
|
||||
type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
|
||||
type AlertFiringRecord = typeof alertFirings.$inferSelect;
|
||||
|
||||
export interface NotificationChannelRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
host_id: number | null;
|
||||
name: string;
|
||||
enabled: number;
|
||||
trigger_type: string;
|
||||
threshold_value: number | null;
|
||||
threshold_duration_seconds: number | null;
|
||||
cooldown_minutes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertRuleWithChannelsRow extends AlertRuleRow {
|
||||
channels: number[];
|
||||
}
|
||||
|
||||
export interface AlertFiringRow {
|
||||
id: number;
|
||||
user_id: string;
|
||||
rule_id: number;
|
||||
host_id: number;
|
||||
host_name: string;
|
||||
fired_at: string;
|
||||
resolved_at: string | null;
|
||||
value: number | null;
|
||||
message: string;
|
||||
severity: string;
|
||||
acknowledged: number;
|
||||
rule_name: string | null;
|
||||
}
|
||||
|
||||
export interface AlertEngineRule {
|
||||
id: number;
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
}
|
||||
|
||||
export interface AlertEngineChannel {
|
||||
id: number;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class AlertRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
.orderBy(notificationChannels.id);
|
||||
|
||||
return rows.map((row) => {
|
||||
const mapped = mapChannelRow(row);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
});
|
||||
}
|
||||
|
||||
async findNotificationChannelForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return null;
|
||||
const mapped = mapChannelRow(rows[0]);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async createNotificationChannel(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}): Promise<NotificationChannelRow> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
{
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
enabled: input.enabled,
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
const mapped = mapChannelRow(created);
|
||||
mapped.config = input.config;
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async updateNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<NotificationChannelRow | null> {
|
||||
if (Object.keys(input).length === 0) {
|
||||
return this.findNotificationChannelForUser(id, userId);
|
||||
}
|
||||
|
||||
const values =
|
||||
input.config !== undefined
|
||||
? { ...input, config: this.encryptConfig(input.config, userId, id) }
|
||||
: input;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
notificationChannels,
|
||||
values,
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (!updated) return null;
|
||||
await this.afterWrite();
|
||||
const mapped = mapChannelRow(updated);
|
||||
mapped.config = this.decryptConfig(mapped.config, userId, mapped.id);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async deleteNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async listAlertRules(userId: string): Promise<AlertRuleWithChannelsRow[]> {
|
||||
const rules = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
.orderBy(alertRules.id);
|
||||
|
||||
const result: AlertRuleWithChannelsRow[] = [];
|
||||
for (const rule of rules) {
|
||||
result.push({
|
||||
...mapRuleRow(rule),
|
||||
channels: await this.listChannelIdsForRule(rule.id),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async createAlertRule(input: {
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
channels: number[];
|
||||
now: string;
|
||||
}): Promise<AlertRuleWithChannelsRow> {
|
||||
const [created] = await insertReturning(this.context, alertRules, {
|
||||
userId: input.userId,
|
||||
hostId: input.hostId,
|
||||
name: input.name,
|
||||
enabled: input.enabled,
|
||||
triggerType: input.triggerType,
|
||||
thresholdValue: input.thresholdValue,
|
||||
thresholdDurationSeconds: input.thresholdDurationSeconds,
|
||||
cooldownMinutes: input.cooldownMinutes,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
});
|
||||
|
||||
const channels = await this.replaceRuleChannels(
|
||||
created.id,
|
||||
input.userId,
|
||||
input.channels,
|
||||
);
|
||||
await this.afterWrite();
|
||||
return { ...mapRuleRow(created), channels };
|
||||
}
|
||||
|
||||
async findAlertRuleForUser(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<AlertRuleRow | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ? mapRuleRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async updateAlertRule(
|
||||
id: number,
|
||||
userId: string,
|
||||
input: {
|
||||
name?: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType?: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
now: string;
|
||||
},
|
||||
): Promise<AlertRuleWithChannelsRow | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
alertRules,
|
||||
{
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.hostId !== undefined ? { hostId: input.hostId } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(input.triggerType !== undefined
|
||||
? { triggerType: input.triggerType }
|
||||
: {}),
|
||||
...(input.thresholdValue !== undefined
|
||||
? { thresholdValue: input.thresholdValue }
|
||||
: {}),
|
||||
...(input.thresholdDurationSeconds !== undefined
|
||||
? { thresholdDurationSeconds: input.thresholdDurationSeconds }
|
||||
: {}),
|
||||
...(input.cooldownMinutes !== undefined
|
||||
? { cooldownMinutes: input.cooldownMinutes }
|
||||
: {}),
|
||||
updatedAt: input.now,
|
||||
},
|
||||
and(eq(alertRules.id, id), eq(alertRules.userId, userId)),
|
||||
);
|
||||
|
||||
if (!updated) return null;
|
||||
|
||||
const channels =
|
||||
input.channels === undefined
|
||||
? await this.listChannelIdsForRule(id)
|
||||
: await this.replaceRuleChannels(id, userId, input.channels);
|
||||
|
||||
await this.afterWrite();
|
||||
return { ...mapRuleRow(updated), channels };
|
||||
}
|
||||
|
||||
async deleteAlertRule(id: number, userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async listAlertFirings(input: {
|
||||
userId: string;
|
||||
acknowledged?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ firings: AlertFiringRow[]; total: number }> {
|
||||
const filters = [eq(alertFirings.userId, input.userId)];
|
||||
if (input.acknowledged !== undefined) {
|
||||
filters.push(eq(alertFirings.acknowledged, input.acknowledged));
|
||||
}
|
||||
|
||||
const where = and(...filters);
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
firing: alertFirings,
|
||||
ruleName: alertRules.name,
|
||||
})
|
||||
.from(alertFirings)
|
||||
.leftJoin(alertRules, eq(alertRules.id, alertFirings.ruleId))
|
||||
.where(where)
|
||||
.orderBy(desc(alertFirings.firedAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
const totalRows = await this.context.drizzle
|
||||
.select({ total: count() })
|
||||
.from(alertFirings)
|
||||
.where(where);
|
||||
|
||||
return {
|
||||
firings: rows.map((row) => mapFiringRow(row.firing, row.ruleName)),
|
||||
total: totalRows[0]?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async acknowledgeFiring(id: number, userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(alertFirings)
|
||||
.set({ acknowledged: true })
|
||||
.where(and(eq(alertFirings.id, id), eq(alertFirings.userId, userId)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async acknowledgeAllFirings(userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(alertFirings)
|
||||
.set({ acknowledged: true })
|
||||
.where(eq(alertFirings.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
// 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({ rule: alertRules })
|
||||
.from(alertRules)
|
||||
.innerJoin(hosts, eq(hosts.id, hostId))
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
or(
|
||||
eq(alertRules.hostId, hostId),
|
||||
and(isNull(alertRules.hostId), eq(alertRules.userId, hosts.userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return rows.map((row) => mapEngineRule(row.rule));
|
||||
}
|
||||
|
||||
async listEnabledRulesForHostUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<AlertEngineRule[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
eq(alertRules.userId, userId),
|
||||
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
|
||||
),
|
||||
);
|
||||
return rows.map(mapEngineRule);
|
||||
}
|
||||
|
||||
async findRuleById(id: number): Promise<AlertEngineRule | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ? mapEngineRule(rows[0]) : null;
|
||||
}
|
||||
|
||||
async createFiring(input: {
|
||||
userId: string;
|
||||
ruleId: number;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
value: number | null;
|
||||
message: string;
|
||||
severity: string;
|
||||
}): Promise<void> {
|
||||
await this.context.drizzle.insert(alertFirings).values(input);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async pruneFiringsOlderThan(userId: string, days: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(
|
||||
and(
|
||||
eq(alertFirings.userId, userId),
|
||||
lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
firingsDeleted: number;
|
||||
ruleLinksDeleted: number;
|
||||
rulesDeleted: number;
|
||||
channelsDeleted: number;
|
||||
}> {
|
||||
const ruleIds = (
|
||||
await this.context.drizzle
|
||||
.select({ id: alertRules.id })
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
).map((row) => row.id);
|
||||
const channelIds = (
|
||||
await this.context.drizzle
|
||||
.select({ id: notificationChannels.id })
|
||||
.from(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
).map((row) => row.id);
|
||||
|
||||
const firingResult = await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(eq(alertFirings.userId, userId));
|
||||
|
||||
const linkFilters = [
|
||||
...(ruleIds.length > 0
|
||||
? [inArray(alertRuleChannels.ruleId, ruleIds)]
|
||||
: []),
|
||||
...(channelIds.length > 0
|
||||
? [inArray(alertRuleChannels.channelId, channelIds)]
|
||||
: []),
|
||||
];
|
||||
const linkResult =
|
||||
linkFilters.length === 0
|
||||
? null
|
||||
: await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(or(...linkFilters));
|
||||
|
||||
const ruleResult = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(eq(alertRules.userId, userId));
|
||||
const result = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId));
|
||||
|
||||
if (
|
||||
rowsAffected(firingResult) > 0 ||
|
||||
rowsAffected(linkResult) > 0 ||
|
||||
rowsAffected(ruleResult) > 0 ||
|
||||
rowsAffected(result) > 0
|
||||
) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
firingsDeleted: rowsAffected(firingResult),
|
||||
ruleLinksDeleted: rowsAffected(linkResult),
|
||||
rulesDeleted: rowsAffected(ruleResult),
|
||||
channelsDeleted: rowsAffected(result),
|
||||
};
|
||||
}
|
||||
|
||||
async listEnabledChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<AlertEngineChannel[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: notificationChannels.id,
|
||||
userId: notificationChannels.userId,
|
||||
type: notificationChannels.type,
|
||||
config: notificationChannels.config,
|
||||
enabled: notificationChannels.enabled,
|
||||
})
|
||||
.from(notificationChannels)
|
||||
.innerJoin(
|
||||
alertRuleChannels,
|
||||
eq(alertRuleChannels.channelId, notificationChannels.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRuleChannels.ruleId, ruleId),
|
||||
eq(notificationChannels.enabled, true),
|
||||
),
|
||||
);
|
||||
|
||||
// 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> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ name: hosts.name, ip: hosts.ip })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
return row ? row.name || row.ip : null;
|
||||
}
|
||||
|
||||
private async replaceRuleChannels(
|
||||
ruleId: number,
|
||||
userId: string,
|
||||
channelIds: number[],
|
||||
): Promise<number[]> {
|
||||
await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, ruleId));
|
||||
|
||||
const linked: number[] = [];
|
||||
for (const channelId of channelIds) {
|
||||
const channel = await this.findNotificationChannelForUser(
|
||||
channelId,
|
||||
userId,
|
||||
);
|
||||
if (!channel) continue;
|
||||
await this.context.drizzle
|
||||
.insert(alertRuleChannels)
|
||||
.values({ ruleId, channelId });
|
||||
linked.push(channelId);
|
||||
}
|
||||
return linked;
|
||||
}
|
||||
|
||||
private async listChannelIdsForRule(ruleId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ channelId: alertRuleChannels.channelId })
|
||||
.from(alertRuleChannels)
|
||||
.where(eq(alertRuleChannels.ruleId, ruleId));
|
||||
|
||||
return rows.map((row) => row.channelId);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function mapChannelRow(row: NotificationChannelRecord): NotificationChannelRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config: row.config,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
created_at: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuleRow(row: AlertRuleRecord): AlertRuleRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
host_id: row.hostId,
|
||||
name: row.name,
|
||||
enabled: row.enabled ? 1 : 0,
|
||||
trigger_type: row.triggerType,
|
||||
threshold_value: row.thresholdValue,
|
||||
threshold_duration_seconds: row.thresholdDurationSeconds,
|
||||
cooldown_minutes: row.cooldownMinutes,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFiringRow(
|
||||
row: AlertFiringRecord,
|
||||
ruleName: string | null,
|
||||
): AlertFiringRow {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
rule_id: row.ruleId,
|
||||
host_id: row.hostId,
|
||||
host_name: row.hostName,
|
||||
fired_at: row.firedAt,
|
||||
resolved_at: row.resolvedAt,
|
||||
value: row.value,
|
||||
message: row.message,
|
||||
severity: row.severity,
|
||||
acknowledged: row.acknowledged ? 1 : 0,
|
||||
rule_name: ruleName,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEngineRule(row: AlertRuleRecord): AlertEngineRule {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
hostId: row.hostId,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
triggerType: row.triggerType,
|
||||
thresholdValue: row.thresholdValue,
|
||||
thresholdDurationSeconds: row.thresholdDurationSeconds,
|
||||
cooldownMinutes: row.cooldownMinutes,
|
||||
};
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { c2sTunnelPresets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
|
||||
|
||||
export interface C2sTunnelPresetCreateInput {
|
||||
name: string;
|
||||
config: string;
|
||||
platform?: string | null;
|
||||
computerName?: string | null;
|
||||
}
|
||||
|
||||
export type C2sTunnelPresetUpdateInput = Partial<C2sTunnelPresetCreateInput>;
|
||||
|
||||
export class C2sTunnelPresetRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<C2sTunnelPresetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId))
|
||||
.orderBy(asc(c2sTunnelPresets.name));
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async hasNameForUser(
|
||||
userId: string,
|
||||
name: string,
|
||||
excludingId?: number,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: c2sTunnelPresets.id })
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(
|
||||
eq(c2sTunnelPresets.userId, userId),
|
||||
eq(c2sTunnelPresets.name, name),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.some((row) => row.id !== excludingId);
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: C2sTunnelPresetCreateInput,
|
||||
): Promise<C2sTunnelPresetRecord> {
|
||||
const [created] = await insertReturning(this.context, c2sTunnelPresets, {
|
||||
userId,
|
||||
name: input.name,
|
||||
config: input.config,
|
||||
platform: input.platform ?? null,
|
||||
computerName: input.computerName ?? null,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: C2sTunnelPresetUpdateInput,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
c2sTunnelPresets,
|
||||
{
|
||||
...updates,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
},
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
|
||||
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
|
||||
|
||||
export type CollabRoomRole = "host" | "member";
|
||||
|
||||
export interface CollabRoomMemberWithUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CollabRoomStage {
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
}
|
||||
|
||||
export class CollabRoomRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async createRoom(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
}): Promise<CollabRoomRecord> {
|
||||
const [created] = await insertReturning(this.context, collabRooms, {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
ownerUserId: input.ownerUserId,
|
||||
persistent: input.persistent,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(eq(collabRooms.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** The live room whose stage points at this share, if any. */
|
||||
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async setGuestToken(roomId: string, token: string | null): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({ guestLinkToken: token })
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ room: collabRooms })
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
|
||||
.where(
|
||||
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.orderBy(desc(collabRooms.createdAt));
|
||||
return rows.map((row) => row.room);
|
||||
}
|
||||
|
||||
async findMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<CollabRoomMemberRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async addMember(input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: CollabRoomRole;
|
||||
addedBy: string | null;
|
||||
}): Promise<boolean> {
|
||||
if (await this.findMember(input.roomId, input.userId)) return false;
|
||||
await this.context.drizzle.insert(collabRoomMembers).values({
|
||||
roomId: input.roomId,
|
||||
userId: input.userId,
|
||||
roomRole: input.roomRole,
|
||||
addedBy: input.addedBy,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
userId: collabRoomMembers.userId,
|
||||
username: users.username,
|
||||
roomRole: collabRoomMembers.roomRole,
|
||||
createdAt: collabRoomMembers.createdAt,
|
||||
})
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
|
||||
.where(eq(collabRoomMembers.roomId, roomId))
|
||||
.orderBy(users.username);
|
||||
}
|
||||
|
||||
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async replaceStage(
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: CollabRoomStage,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRooms.id, roomId),
|
||||
expectedShareId
|
||||
? eq(collabRooms.stageShareId, expectedShareId)
|
||||
: isNull(collabRooms.stageShareId),
|
||||
),
|
||||
);
|
||||
const changed = rowsAffected(result) > 0;
|
||||
if (changed) await this.afterWrite();
|
||||
return changed;
|
||||
}
|
||||
|
||||
async clearStage(roomId: string): Promise<void> {
|
||||
return this.updateStage(roomId, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
}
|
||||
|
||||
async endRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({
|
||||
endedAt: new Date().toISOString(),
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
})
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRooms)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type CommandHistoryRecord = typeof commandHistory.$inferSelect;
|
||||
|
||||
export class CommandHistoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
executedAt = new Date().toISOString(),
|
||||
): Promise<CommandHistoryRecord> {
|
||||
const [created] = await insertReturning(this.context, commandHistory, {
|
||||
userId,
|
||||
hostId,
|
||||
command,
|
||||
executedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async listUniqueCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 500,
|
||||
): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
command: commandHistory.command,
|
||||
maxExecutedAt: sql<number>`MAX(${commandHistory.executedAt})`,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.groupBy(commandHistory.command)
|
||||
.orderBy(desc(sql`MAX(${commandHistory.executedAt})`))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.command);
|
||||
}
|
||||
|
||||
async listCommandsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 200,
|
||||
): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: commandHistory.id,
|
||||
command: commandHistory.command,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.command);
|
||||
}
|
||||
|
||||
async deleteCommandForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(inArray(commandHistory.hostId, hostIds));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type DashboardServiceLinkRecord =
|
||||
typeof dashboardServiceLinks.$inferSelect;
|
||||
|
||||
export type DashboardServiceLinkUpdate = Partial<{
|
||||
label: string;
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export class DashboardServiceLinkRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<DashboardServiceLinkRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId))
|
||||
.orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: { label: string; url: string },
|
||||
createdAt = new Date().toISOString(),
|
||||
): Promise<DashboardServiceLinkRecord> {
|
||||
const existing = await this.context.drizzle
|
||||
.select({ order: dashboardServiceLinks.order })
|
||||
.from(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId))
|
||||
.orderBy(asc(dashboardServiceLinks.order));
|
||||
const nextOrder =
|
||||
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
|
||||
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
{
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
);
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(dashboardServiceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: DashboardServiceLinkUpdate,
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
{ ...updates, updatedAt: new Date().toISOString() },
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
dashboardServiceLinks,
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type DismissedAlertRecord = typeof dismissedAlerts.$inferSelect;
|
||||
|
||||
export class DismissedAlertRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<DismissedAlertRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
}
|
||||
|
||||
async listAlertIdsByUserId(userId: string): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ alertId: dismissedAlerts.alertId })
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
return rows.map((row) => row.alertId);
|
||||
}
|
||||
|
||||
async findForUser(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
): Promise<DismissedAlertRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(userId: string, alertId: string): Promise<void> {
|
||||
await this.context.drizzle.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async createForImport(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
dismissedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const existing = await this.findForUser(userId, alertId);
|
||||
if (existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
dismissedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, alertId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -3,68 +3,40 @@ import { getDb, getSqlite } from "../db/index.js";
|
||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
|
||||
import { 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";
|
||||
import { CommandHistoryRepository } from "./command-history-repository.js";
|
||||
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";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { HostDefaultsRepository } from "./host-defaults-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
|
||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SessionShareRepository } from "./session-share-repository.js";
|
||||
import { CollabRoomRepository } from "./collab-room-repository.js";
|
||||
import { SecretSourceRepository } from "./secret-source-repository.js";
|
||||
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
|
||||
import { FolderAccessRepository } from "./folder-access-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SnippetRepository } from "./snippet-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
||||
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
||||
import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
|
||||
import { TransferRecentRepository } from "./transfer-recent-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
import { UserDataExportRepository } from "./user-data-export-repository.js";
|
||||
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.
|
||||
@@ -146,34 +118,6 @@ export function getCurrentSettingValue(key: string): string | null {
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialRepository {
|
||||
return new WebauthnCredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("webauthn_credential_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAiRepository(): AiRepository {
|
||||
return new AiRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("ai_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAlertRepository(): AlertRepository {
|
||||
return new AlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("alert_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentAutomationRepository(): AutomationRepository {
|
||||
return new AutomationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("automation_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
||||
return new ApiKeyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -188,20 +132,6 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentC2sTunnelPresetRepository(): C2sTunnelPresetRepository {
|
||||
return new C2sTunnelPresetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("c2s_tunnel_preset_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
|
||||
return new CommandHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("command_history_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
return new CredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -209,62 +139,6 @@ export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
|
||||
return new DashboardServiceLinkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
|
||||
return new SyncTombstoneRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
|
||||
return new DismissedAlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository {
|
||||
return new FileManagerBookmarkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
createCurrentRepositoryWriteHook("homepage_item_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository {
|
||||
return new HomepageLayoutRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("homepage_layout_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
return new HostFolderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -272,40 +146,6 @@ export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostHealthRepository(): HostHealthRepository {
|
||||
return new HostHealthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_health_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
|
||||
return new HostMetricsHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryLazyWriteHook(
|
||||
"host_metrics_history_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
|
||||
return new HostMetricsPreferenceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"host_metrics_preference_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentProxmoxNodeHistoryRepository(): ProxmoxNodeHistoryRepository {
|
||||
return new ProxmoxNodeHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryLazyWriteHook(
|
||||
"proxmox_node_history_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostRepository(): HostRepository {
|
||||
return new HostRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -348,13 +188,6 @@ export function createCurrentUiPreferenceRepository(): UiPreferenceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
|
||||
return new NetworkTopologyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("network_topology_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||
return new OpenTabRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -362,13 +195,6 @@ export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentOpksshTokenRepository(): OpksshTokenRepository {
|
||||
return new OpksshTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("opkssh_token_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginRepository(): PluginRepository {
|
||||
return new PluginRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -376,6 +202,13 @@ export function createCurrentPluginRepository(): PluginRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentUserAuthRepository(): UserAuthRepository {
|
||||
return new UserAuthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("user_auth_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginPermissionGrantRepository(): PluginPermissionGrantRepository {
|
||||
return new PluginPermissionGrantRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -385,19 +218,38 @@ export function createCurrentPluginPermissionGrantRepository(): PluginPermission
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
|
||||
return new PluginRegistryRepository(
|
||||
export function createCurrentPluginStorageRepository(): PluginStorageRepository {
|
||||
return new PluginStorageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_registry_repository_write"),
|
||||
createCurrentRepositoryWriteHook("plugin_storage_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
|
||||
return new PluginInstallCountRepository(
|
||||
export function createCurrentHostDefaultsRepository(): HostDefaultsRepository {
|
||||
return new HostDefaultsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryLazyWriteHook(
|
||||
"plugin_install_count_repository_write",
|
||||
),
|
||||
createCurrentRepositoryWriteHook("host_defaults_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
||||
return new PluginSettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
||||
return new PluginMigrationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_migration_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentRbacPermissionRepository(): RbacPermissionRepository {
|
||||
return new RbacPermissionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("rbac_permission_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,13 +274,6 @@ export function createCurrentRoleRepository(): RoleRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
|
||||
return new SessionRecordingRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_recording_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionRepository(): SessionRepository {
|
||||
return new SessionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -436,20 +281,6 @@ export function createCurrentSessionRepository(): SessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCollabRoomRepository(): CollabRoomRepository {
|
||||
return new CollabRoomRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("collab_room_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||
return new SessionShareRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_share_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
return new SettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -464,6 +295,13 @@ export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRep
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostProtocolAuthRepository(): HostProtocolAuthRepository {
|
||||
return new HostProtocolAuthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_protocol_auth_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthOverrideRepository {
|
||||
return new SharedHostAuthOverrideRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -473,13 +311,6 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSnippetRepository(): SnippetRepository {
|
||||
return new SnippetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("snippet_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -487,41 +318,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSsoProviderRepository(): SsoProviderRepository {
|
||||
return new SsoProviderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sso_provider_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
|
||||
return new TermixIdentityCaRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("termix_identity_ca_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTermixIdentityRepository(): TermixIdentityRepository {
|
||||
return new TermixIdentityRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("termix_identity_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepository {
|
||||
return new TmuxSessionTagRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("tmux_session_tag_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTransferRecentRepository(): TransferRecentRepository {
|
||||
return new TransferRecentRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("transfer_recent_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
|
||||
return new TrustedDeviceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
@@ -570,34 +366,6 @@ export function createCurrentSharedCredentialSecretsRepository(): SharedCredenti
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
|
||||
return new SecretSourceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("secret_source_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
||||
return new VaultProfileRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_profile_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentVaultTokenRepository(): VaultTokenRepository {
|
||||
return new VaultTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_token_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
fileManagerShortcuts,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
|
||||
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
|
||||
export type FileManagerShortcutRecord =
|
||||
typeof fileManagerShortcuts.$inferSelect;
|
||||
|
||||
export interface FileManagerBookmarkInput {
|
||||
hostId: number;
|
||||
path: string;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
function resolveBookmarkName(path: string, name?: string | null): string {
|
||||
return name || path.split("/").pop() || "Unknown";
|
||||
}
|
||||
|
||||
export class FileManagerBookmarkRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRecentForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit = 20,
|
||||
): Promise<FileManagerRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerRecent.lastOpened))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async listRecentByUserId(userId: string): Promise<FileManagerRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId));
|
||||
}
|
||||
|
||||
async upsertRecent(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
lastOpened = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const [existing] = await this.context.drizzle
|
||||
.select({ id: fileManagerRecent.id })
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, input.hostId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(fileManagerRecent)
|
||||
.set({ lastOpened })
|
||||
.where(eq(fileManagerRecent.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(fileManagerRecent).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
lastOpened,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async createRecentForImport(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
lastOpened = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsRecentImportItem(userId, input);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerRecent).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
lastOpened,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteRecentForHostPath(
|
||||
userId: string,
|
||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, input.hostId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async listPinnedForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<FileManagerPinnedRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerPinned.pinnedAt));
|
||||
}
|
||||
|
||||
async listPinnedByUserId(userId: string): Promise<FileManagerPinnedRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId));
|
||||
}
|
||||
|
||||
async createPinned(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
pinnedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsPinned(userId, input.hostId, input.path);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerPinned).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
pinnedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async createPinnedForImport(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
pinnedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsPinnedImportItem(userId, input);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerPinned).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
pinnedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deletePinnedForHostPath(
|
||||
userId: string,
|
||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, input.hostId),
|
||||
eq(fileManagerPinned.path, input.path),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async listShortcutsForHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<FileManagerShortcutRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerShortcuts.createdAt));
|
||||
}
|
||||
|
||||
async listShortcutsByUserId(
|
||||
userId: string,
|
||||
): Promise<FileManagerShortcutRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId));
|
||||
}
|
||||
|
||||
async createShortcut(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
createdAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsShortcut(userId, input.hostId, input.path);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerShortcuts).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
createdAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async createShortcutForImport(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
createdAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const exists = await this.existsShortcutImportItem(userId, input);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(fileManagerShortcuts).values({
|
||||
userId,
|
||||
hostId: input.hostId,
|
||||
path: input.path,
|
||||
name: resolveBookmarkName(input.path, input.name),
|
||||
createdAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteShortcutForHostPath(
|
||||
userId: string,
|
||||
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, input.hostId),
|
||||
eq(fileManagerShortcuts.path, input.path),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const total =
|
||||
(await this.deleteRecentByUserId(userId)) +
|
||||
(await this.deletePinnedByUserId(userId)) +
|
||||
(await this.deleteShortcutsByUserId(userId));
|
||||
|
||||
if (total > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const total =
|
||||
(await this.deleteRecentByHostId(hostId)) +
|
||||
(await this.deletePinnedByHostId(hostId)) +
|
||||
(await this.deleteShortcutsByHostId(hostId));
|
||||
|
||||
if (total > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const total =
|
||||
(await this.deleteRecentByHostIds(hostIds)) +
|
||||
(await this.deletePinnedByHostIds(hostIds)) +
|
||||
(await this.deleteShortcutsByHostIds(hostIds));
|
||||
|
||||
if (total > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private async existsPinned(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerPinned.id })
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId),
|
||||
eq(fileManagerPinned.path, path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsRecentImportItem(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerRecent.id })
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
eq(
|
||||
fileManagerRecent.name,
|
||||
resolveBookmarkName(input.path, input.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsPinnedImportItem(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerPinned.id })
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.path, input.path),
|
||||
eq(
|
||||
fileManagerPinned.name,
|
||||
resolveBookmarkName(input.path, input.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsShortcutImportItem(
|
||||
userId: string,
|
||||
input: FileManagerBookmarkInput,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerShortcuts.id })
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.path, input.path),
|
||||
eq(
|
||||
fileManagerShortcuts.name,
|
||||
resolveBookmarkName(input.path, input.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async existsShortcut(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
path: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: fileManagerShortcuts.id })
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId),
|
||||
eq(fileManagerShortcuts.path, path),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async deleteRecentByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deletePinnedByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteShortcutsByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteRecentByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.hostId, hostId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deletePinnedByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.hostId, hostId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.hostId, hostId));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(inArray(fileManagerRecent.hostId, hostIds));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(inArray(fileManagerPinned.hostId, hostIds));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(inArray(fileManagerShortcuts.hostId, hostIds));
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type HomepageItemRecord = typeof homepageItems.$inferSelect;
|
||||
|
||||
export interface HomepageItemCreateInput {
|
||||
typeId: string;
|
||||
title: string | null;
|
||||
config: string;
|
||||
}
|
||||
|
||||
export type HomepageItemUpdateInput = Partial<{
|
||||
title: string | null;
|
||||
config: string;
|
||||
}>;
|
||||
|
||||
export class HomepageItemRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<HomepageItemRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId))
|
||||
.orderBy(asc(homepageItems.id));
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: HomepageItemCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<HomepageItemRecord> {
|
||||
const [created] = await insertReturning(this.context, homepageItems, {
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
config: input.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: HomepageItemUpdateInput,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
homepageItems,
|
||||
{ ...updates, updatedAt },
|
||||
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
homepageItems,
|
||||
and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { homepageLayouts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type HomepageLayoutRecord = typeof homepageLayouts.$inferSelect;
|
||||
|
||||
export class HomepageLayoutRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<HomepageLayoutRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
layout: string,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<HomepageLayoutRecord> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (!existing) {
|
||||
const [created] = await insertReturning(this.context, homepageLayouts, {
|
||||
userId,
|
||||
layout,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
homepageLayouts,
|
||||
{ layout, updatedAt },
|
||||
eq(homepageLayouts.userId, userId),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { and, eq, inArray, type SQL } from "drizzle-orm";
|
||||
import { hostDefaults, hosts, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import type { HostDefaultsLevel } from "../../../types/host-defaults.js";
|
||||
|
||||
export type HostDefaultsRecord = typeof hostDefaults.$inferSelect;
|
||||
export type HostRow = typeof hosts.$inferSelect;
|
||||
|
||||
export interface HostDefaultsScope {
|
||||
level: HostDefaultsLevel;
|
||||
userId?: string | null;
|
||||
folderId?: number | null;
|
||||
}
|
||||
|
||||
export interface HostDefaultsEntry {
|
||||
namespace: string;
|
||||
key: string;
|
||||
/** JSON-encoded. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function scopeKeyFor(scope: HostDefaultsScope): string {
|
||||
if (scope.level === "admin") return "admin";
|
||||
if (scope.level === "user") return `u:${scope.userId}`;
|
||||
return `f:${scope.folderId}`;
|
||||
}
|
||||
|
||||
const CHUNK = 500;
|
||||
|
||||
function chunks<T>(items: T[], size = CHUNK): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
result.push(items.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host defaults at every level, plus the reads and writes that apply them to
|
||||
* hosts in bulk. The resolution itself lives in hosts/defaults.
|
||||
*/
|
||||
export class HostDefaultsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listScope(scope: HostDefaultsScope): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||
}
|
||||
|
||||
/** The admin rows, and every user and folder row the given users own. */
|
||||
async listForUsers(userIds: string[]): Promise<HostDefaultsRecord[]> {
|
||||
const admin = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.level, "admin"));
|
||||
const own: HostDefaultsRecord[] = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
own.push(
|
||||
...(await this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(inArray(hostDefaults.userId, part))),
|
||||
);
|
||||
}
|
||||
return [...admin, ...own];
|
||||
}
|
||||
|
||||
/** The user's own and folder rows. */
|
||||
async listOwnedBy(userId: string): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(eq(hostDefaults.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and clears keys at one level in one transaction. `set` values are
|
||||
* already JSON-encoded.
|
||||
*/
|
||||
async apply(
|
||||
scope: HostDefaultsScope,
|
||||
set: HostDefaultsEntry[],
|
||||
unset: Array<{ namespace: string; key: string }>,
|
||||
updatedBy: string | null,
|
||||
): Promise<void> {
|
||||
const scopeKey = scopeKeyFor(scope);
|
||||
const now = new Date().toISOString();
|
||||
const match = (namespace: string, key: string) =>
|
||||
and(
|
||||
eq(hostDefaults.scopeKey, scopeKey),
|
||||
eq(hostDefaults.namespace, namespace),
|
||||
eq(hostDefaults.key, key),
|
||||
)!;
|
||||
const touched = [...set, ...unset];
|
||||
if (touched.length === 0) return;
|
||||
|
||||
if (this.context.dialect === "sqlite") {
|
||||
// better-sqlite3 runs a transaction synchronously.
|
||||
const db = this.context.drizzle;
|
||||
db.transaction((tx) => {
|
||||
for (const entry of unset) {
|
||||
tx.delete(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.run();
|
||||
}
|
||||
for (const entry of set) {
|
||||
const existing = tx
|
||||
.select({ id: hostDefaults.id })
|
||||
.from(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.limit(1)
|
||||
.all();
|
||||
if (existing[0]) {
|
||||
tx.update(hostDefaults)
|
||||
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||
.where(eq(hostDefaults.id, existing[0].id))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(hostDefaults)
|
||||
.values({
|
||||
level: scope.level,
|
||||
scopeKey,
|
||||
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||
folderId:
|
||||
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||
namespace: entry.namespace,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
updatedBy,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.context.drizzle.transaction(async (tx) => {
|
||||
for (const entry of unset) {
|
||||
await tx
|
||||
.delete(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key));
|
||||
}
|
||||
for (const entry of set) {
|
||||
const existing = await tx
|
||||
.select({ id: hostDefaults.id })
|
||||
.from(hostDefaults)
|
||||
.where(match(entry.namespace, entry.key))
|
||||
.limit(1);
|
||||
if (existing[0]) {
|
||||
await tx
|
||||
.update(hostDefaults)
|
||||
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||
.where(eq(hostDefaults.id, existing[0].id));
|
||||
} else {
|
||||
await tx.insert(hostDefaults).values({
|
||||
level: scope.level,
|
||||
scopeKey,
|
||||
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||
folderId:
|
||||
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||
namespace: entry.namespace,
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
updatedBy,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async deleteScope(scope: HostDefaultsScope): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostDefaults)
|
||||
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** Folder ids and paths for a set of users. */
|
||||
async listFolders(
|
||||
userIds: string[],
|
||||
): Promise<Array<{ id: number; userId: string; name: string }>> {
|
||||
const rows: Array<{ id: number; userId: string; name: string }> = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
rows.push(
|
||||
...(await this.context.drizzle
|
||||
.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
name: sshFolders.name,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(inArray(sshFolders.userId, part))),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async findFolder(
|
||||
folderId: number,
|
||||
): Promise<{ id: number; userId: string; name: string } | null> {
|
||||
const [row] = await this.context.drizzle
|
||||
.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
name: sshFolders.name,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.id, folderId))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** The folder row for a path, created when the folder only exists on hosts. */
|
||||
async ensureFolder(userId: string, name: string): Promise<number> {
|
||||
const [existing] = await this.context.drizzle
|
||||
.select({ id: sshFolders.id })
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
if (existing) return existing.id;
|
||||
await this.context.drizzle.insert(sshFolders).values({ userId, name });
|
||||
const [created] = await this.context.drizzle
|
||||
.select({ id: sshFolders.id })
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
await this.onWrite?.();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Host rows by id or by owner. Secrets are read as stored, never used. */
|
||||
async listHosts(filter: {
|
||||
hostIds?: number[];
|
||||
userIds?: string[];
|
||||
all?: boolean;
|
||||
}): Promise<HostRow[]> {
|
||||
const db = this.context.drizzle;
|
||||
if (filter.all) return db.select().from(hosts);
|
||||
const rows: HostRow[] = [];
|
||||
const conditions: SQL[] = [];
|
||||
for (const part of chunks(filter.hostIds ?? [])) {
|
||||
conditions.push(inArray(hosts.id, part));
|
||||
}
|
||||
for (const part of chunks(filter.userIds ?? [])) {
|
||||
conditions.push(inArray(hosts.userId, part));
|
||||
}
|
||||
for (const condition of conditions) {
|
||||
rows.push(...(await db.select().from(hosts).where(condition)));
|
||||
}
|
||||
const seen = new Set<number>();
|
||||
return rows.filter((row) => {
|
||||
if (seen.has(row.id)) return false;
|
||||
seen.add(row.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every host of the given owners, for walking sub-host chains. */
|
||||
async listHostPlacement(userIds: string[]): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
folder: string | null;
|
||||
parentHostId: number | null;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
id: number;
|
||||
userId: string;
|
||||
folder: string | null;
|
||||
parentHostId: number | null;
|
||||
}> = [];
|
||||
for (const part of chunks([...new Set(userIds)])) {
|
||||
rows.push(
|
||||
...(await this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
userId: hosts.userId,
|
||||
folder: hosts.folder,
|
||||
parentHostId: hosts.parentHostId,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(inArray(hosts.userId, part))),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes column patches, one statement per host, in one transaction.
|
||||
* `updatedAt` is set on each, which is what sync looks for.
|
||||
*/
|
||||
async updateHosts(
|
||||
patches: Array<{ id: number; values: Partial<HostRow> }>,
|
||||
): Promise<void> {
|
||||
if (patches.length === 0) return;
|
||||
const now = new Date().toISOString();
|
||||
for (const part of chunks(patches)) {
|
||||
if (this.context.dialect === "sqlite") {
|
||||
this.context.drizzle.transaction((tx) => {
|
||||
for (const patch of part) {
|
||||
tx.update(hosts)
|
||||
.set({ ...patch.values, updatedAt: now })
|
||||
.where(eq(hosts.id, patch.id))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await this.context.drizzle.transaction(async (tx) => {
|
||||
for (const patch of part) {
|
||||
await tx
|
||||
.update(hosts)
|
||||
.set({ ...patch.values, updatedAt: now })
|
||||
.where(eq(hosts.id, patch.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
/** Rows that name a folder, credential or host a user no longer has. */
|
||||
async deleteWhere(condition: SQL): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostDefaults)
|
||||
.where(condition);
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async listByKey(
|
||||
namespace: string,
|
||||
key: string,
|
||||
): Promise<HostDefaultsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostDefaults)
|
||||
.where(
|
||||
and(eq(hostDefaults.namespace, namespace), eq(hostDefaults.key, key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,28 @@ export class HostFolderRepository {
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a folder, its subfolders and every host in them on this device
|
||||
* only, or lets them sync again.
|
||||
*/
|
||||
async setLocalOnly(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
localOnly: boolean,
|
||||
): Promise<void> {
|
||||
const inFolder = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ localOnly })
|
||||
.where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name)));
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ localOnly, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(hosts.userId, userId), inFolder(hosts.folder)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async upsertMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { and, desc, eq, notInArray } from "drizzle-orm";
|
||||
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
|
||||
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
|
||||
|
||||
export interface HostHealthResultInput {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export class HostHealthRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findChecksByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HostHealthCheckRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostHealthChecks)
|
||||
.where(
|
||||
and(
|
||||
eq(hostHealthChecks.userId, userId),
|
||||
eq(hostHealthChecks.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
checks: string,
|
||||
intervalSeconds: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<HostHealthCheckRecord> {
|
||||
const existing = await this.findChecksByUserAndHost(userId, hostId);
|
||||
if (existing) {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
hostHealthChecks,
|
||||
{ checks, intervalSeconds, updatedAt: now },
|
||||
eq(hostHealthChecks.id, existing.id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, hostHealthChecks, {
|
||||
userId,
|
||||
hostId,
|
||||
checks,
|
||||
intervalSeconds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async recordHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HostHealthResultInput[],
|
||||
keep: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (results.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(hostHealthHistory).values(
|
||||
results.map((result) => ({
|
||||
userId,
|
||||
hostId,
|
||||
checkId: result.checkId,
|
||||
ts: now,
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
detail: result.detail,
|
||||
})),
|
||||
);
|
||||
|
||||
await this.pruneHistory(userId, hostId, keep);
|
||||
await this.afterWrite();
|
||||
return results.length;
|
||||
}
|
||||
|
||||
async listHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit: number,
|
||||
): Promise<HostHealthHistoryRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostHealthHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(hostHealthHistory.userId, userId),
|
||||
eq(hostHealthHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(hostHealthHistory.ts))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
checksDeleted: number;
|
||||
historyDeleted: number;
|
||||
}> {
|
||||
const historyResult = await this.context.drizzle
|
||||
.delete(hostHealthHistory)
|
||||
.where(eq(hostHealthHistory.userId, userId));
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostHealthChecks)
|
||||
.where(eq(hostHealthChecks.userId, userId));
|
||||
|
||||
if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
checksDeleted: rowsAffected(result),
|
||||
historyDeleted: rowsAffected(historyResult),
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps the newest `keep` rows for the host and drops the rest. */
|
||||
private async pruneHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
keep: number,
|
||||
): Promise<void> {
|
||||
const scope = and(
|
||||
eq(hostHealthHistory.userId, userId),
|
||||
eq(hostHealthHistory.hostId, hostId),
|
||||
);
|
||||
|
||||
const retained = await this.context.drizzle
|
||||
.select({ id: hostHealthHistory.id })
|
||||
.from(hostHealthHistory)
|
||||
.where(scope)
|
||||
.orderBy(desc(hostHealthHistory.ts))
|
||||
.limit(keep);
|
||||
|
||||
// Nothing retained means nothing to keep back, so the scope alone is the
|
||||
// delete condition.
|
||||
await this.context.drizzle.delete(hostHealthHistory).where(
|
||||
retained.length
|
||||
? and(
|
||||
scope,
|
||||
notInArray(
|
||||
hostHealthHistory.id,
|
||||
retained.map((row) => row.id),
|
||||
),
|
||||
)
|
||||
: scope,
|
||||
);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { and, asc, eq, gte, lt, lte } from "drizzle-orm";
|
||||
import { hostMetricsHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
|
||||
|
||||
export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect;
|
||||
|
||||
export interface HostMetricsHistoryCreateInput {
|
||||
hostId: number;
|
||||
cpuPercent?: number | null;
|
||||
memPercent?: number | null;
|
||||
diskPercent?: number | null;
|
||||
netRxBytes?: number | null;
|
||||
netTxBytes?: number | null;
|
||||
}
|
||||
|
||||
export class HostMetricsHistoryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(input: HostMetricsHistoryCreateInput): Promise<void> {
|
||||
await this.context.drizzle.insert(hostMetricsHistory).values({
|
||||
hostId: input.hostId,
|
||||
cpuPercent: input.cpuPercent,
|
||||
memPercent: input.memPercent,
|
||||
diskPercent: input.diskPercent,
|
||||
netRxBytes: input.netRxBytes,
|
||||
netTxBytes: input.netTxBytes,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(hostMetricsHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(hostMetricsHistory.hostId, hostId),
|
||||
lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listRange(
|
||||
hostId: number,
|
||||
fromTs: string,
|
||||
toTs: string,
|
||||
): Promise<HostMetricsHistoryRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostMetricsHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(hostMetricsHistory.hostId, hostId),
|
||||
gte(hostMetricsHistory.ts, fromTs),
|
||||
lte(hostMetricsHistory.ts, toTs),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(hostMetricsHistory.ts));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { hostMetricsPreferences, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type HostMetricsPreferenceRecord =
|
||||
typeof hostMetricsPreferences.$inferSelect;
|
||||
|
||||
export class HostMetricsPreferenceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HostMetricsPreferenceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostMetricsPreferences)
|
||||
.where(
|
||||
and(
|
||||
eq(hostMetricsPreferences.userId, userId),
|
||||
eq(hostMetricsPreferences.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertLayout(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
layout: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<HostMetricsPreferenceRecord> {
|
||||
const existing = await this.findByUserAndHost(userId, hostId);
|
||||
if (existing) {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
hostMetricsPreferences,
|
||||
{ layout, updatedAt: now },
|
||||
eq(hostMetricsPreferences.id, existing.id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
hostMetricsPreferences,
|
||||
{
|
||||
userId,
|
||||
hostId,
|
||||
layout,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateHostStatsConfig(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
statsConfig: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ statsConfig })
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostMetricsPreferences)
|
||||
.where(eq(hostMetricsPreferences.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { hostProtocolAuth } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
export type HostProtocolAuthRecord = typeof hostProtocolAuth.$inferSelect;
|
||||
|
||||
/** A host's login for one plugin protocol, decrypted. */
|
||||
export interface HostProtocolLogin {
|
||||
protocol: string;
|
||||
authType: string;
|
||||
credentialId: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
/** Non-secret declared fields. */
|
||||
fields: Record<string, string>;
|
||||
/** Secret declared fields. */
|
||||
secretFields: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The encryption context of a login's secret columns. */
|
||||
export function protocolAuthRecordId(hostId: number, protocol: string): string {
|
||||
return `host-protocol-auth-${hostId}-${protocol}`;
|
||||
}
|
||||
|
||||
function parseStringMap(value: string | null): Record<string, string> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (typeof entry === "string") out[key] = entry;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function openField(
|
||||
value: string | null,
|
||||
dek: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
if (!FieldCrypto.isEncrypted(value)) return value;
|
||||
try {
|
||||
return FieldCrypto.decryptField(value, dek, recordId, fieldName);
|
||||
} catch (error) {
|
||||
// A value that does not open is treated as missing, never passed on.
|
||||
databaseLogger.warn("A host protocol login could not be decrypted", {
|
||||
operation: "host_protocol_auth_decrypt",
|
||||
recordId,
|
||||
fieldName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sealField(
|
||||
value: string | null,
|
||||
dek: Buffer,
|
||||
recordId: string,
|
||||
fieldName: string,
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
return FieldCrypto.encryptField(value, dek, recordId, fieldName);
|
||||
}
|
||||
|
||||
export function decryptProtocolLogin(
|
||||
row: HostProtocolAuthRecord,
|
||||
dek: Buffer,
|
||||
): HostProtocolLogin {
|
||||
const recordId = protocolAuthRecordId(row.hostId, row.protocol);
|
||||
const secrets = openField(row.secretFields, dek, recordId, "secretFields");
|
||||
return {
|
||||
protocol: row.protocol,
|
||||
authType: row.authType || "direct",
|
||||
credentialId: row.credentialId ?? null,
|
||||
username: row.username ?? null,
|
||||
password: openField(row.password, dek, recordId, "password"),
|
||||
fields: parseStringMap(row.fields),
|
||||
secretFields: parseStringMap(secrets),
|
||||
};
|
||||
}
|
||||
|
||||
export function encryptProtocolLogin(
|
||||
hostId: number,
|
||||
login: HostProtocolLogin,
|
||||
dek: Buffer,
|
||||
): Pick<
|
||||
HostProtocolAuthRecord,
|
||||
| "authType"
|
||||
| "credentialId"
|
||||
| "username"
|
||||
| "password"
|
||||
| "fields"
|
||||
| "secretFields"
|
||||
> {
|
||||
const recordId = protocolAuthRecordId(hostId, login.protocol);
|
||||
const secretFields = Object.keys(login.secretFields).length
|
||||
? JSON.stringify(login.secretFields)
|
||||
: null;
|
||||
return {
|
||||
authType: login.authType || "direct",
|
||||
credentialId: login.credentialId ?? null,
|
||||
username: login.username || null,
|
||||
password: sealField(login.password, dek, recordId, "password"),
|
||||
fields: Object.keys(login.fields).length
|
||||
? JSON.stringify(login.fields)
|
||||
: null,
|
||||
secretFields: sealField(secretFields, dek, recordId, "secretFields"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* host_protocol_auth: one login per host and plugin protocol, encrypted with
|
||||
* the host owner's data key.
|
||||
*/
|
||||
export class HostProtocolAuthRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRowsForHosts(hostIds: number[]): Promise<HostProtocolAuthRecord[]> {
|
||||
const unique = Array.from(new Set(hostIds));
|
||||
if (unique.length === 0) return [];
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostProtocolAuth)
|
||||
.where(inArray(hostProtocolAuth.hostId, unique));
|
||||
}
|
||||
|
||||
async listRowsForHost(hostId: number): Promise<HostProtocolAuthRecord[]> {
|
||||
return this.listRowsForHosts([hostId]);
|
||||
}
|
||||
|
||||
async findRow(
|
||||
hostId: number,
|
||||
protocol: string,
|
||||
): Promise<HostProtocolAuthRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostProtocolAuth)
|
||||
.where(
|
||||
and(
|
||||
eq(hostProtocolAuth.hostId, hostId),
|
||||
eq(hostProtocolAuth.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listForHost(hostId: number, dek: Buffer): Promise<HostProtocolLogin[]> {
|
||||
return (await this.listRowsForHost(hostId)).map((row) =>
|
||||
decryptProtocolLogin(row, dek),
|
||||
);
|
||||
}
|
||||
|
||||
async find(
|
||||
hostId: number,
|
||||
protocol: string,
|
||||
dek: Buffer,
|
||||
): Promise<HostProtocolLogin | null> {
|
||||
const row = await this.findRow(hostId, protocol);
|
||||
return row ? decryptProtocolLogin(row, dek) : null;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
ownerId: string,
|
||||
hostId: number,
|
||||
login: HostProtocolLogin,
|
||||
dek: Buffer,
|
||||
): Promise<void> {
|
||||
const values = encryptProtocolLogin(hostId, login, dek);
|
||||
const existing = await this.findRow(hostId, login.protocol);
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(hostProtocolAuth)
|
||||
.set({
|
||||
...values,
|
||||
userId: ownerId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(hostProtocolAuth.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(hostProtocolAuth).values({
|
||||
...values,
|
||||
hostId,
|
||||
userId: ownerId,
|
||||
protocol: login.protocol,
|
||||
});
|
||||
}
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(hostId: number, protocol: string): Promise<boolean> {
|
||||
const existing = await this.findRow(hostId, protocol);
|
||||
if (!existing) return false;
|
||||
await this.context.drizzle
|
||||
.delete(hostProtocolAuth)
|
||||
.where(eq(hostProtocolAuth.id, existing.id));
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The owner's hosts whose login for some protocol uses the credential. */
|
||||
async listHostIdsForCredential(
|
||||
ownerId: string,
|
||||
credentialId: number,
|
||||
): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ hostId: hostProtocolAuth.hostId })
|
||||
.from(hostProtocolAuth)
|
||||
.where(
|
||||
and(
|
||||
eq(hostProtocolAuth.userId, ownerId),
|
||||
eq(hostProtocolAuth.credentialId, credentialId),
|
||||
),
|
||||
);
|
||||
return Array.from(new Set(rows.map((row) => row.hostId)));
|
||||
}
|
||||
|
||||
/** Every login a user owns, for an export. */
|
||||
async listRowsForUser(userId: string): Promise<HostProtocolAuthRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hostProtocolAuth)
|
||||
.where(eq(hostProtocolAuth.userId, userId));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import { hostAccess, hosts, pluginSettings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
@@ -15,9 +15,7 @@ export type NewHostRecord = typeof hosts.$inferInsert;
|
||||
export type HostUpdate = Partial<Omit<NewHostRecord, "id" | "userId">>;
|
||||
export interface HostBulkUpdateState {
|
||||
id: number;
|
||||
statsConfig: string | null;
|
||||
credentialId: number | null;
|
||||
proxmoxConfig: string | null;
|
||||
}
|
||||
|
||||
export class HostRepository {
|
||||
@@ -103,19 +101,6 @@ export class HostRepository {
|
||||
return DataCrypto.decryptRecord("ssh_data", row, userId, userDataKey);
|
||||
}
|
||||
|
||||
async listProxmoxEnabled(): Promise<
|
||||
Pick<HostRecord, "id" | "userId" | "proxmoxConfig">[]
|
||||
> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
userId: hosts.userId,
|
||||
proxmoxConfig: hosts.proxmoxConfig,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(eq(hosts.enableProxmox, true));
|
||||
}
|
||||
|
||||
async listByUserId(userId: string): Promise<HostRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
@@ -246,9 +231,7 @@ export class HostRepository {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
statsConfig: hosts.statsConfig,
|
||||
credentialId: hosts.credentialId,
|
||||
proxmoxConfig: hosts.proxmoxConfig,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
|
||||
@@ -338,6 +321,25 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
// Bulk path, so it never passes through deleteAccessForHost: clear the
|
||||
// host-scope plugin settings for these hosts before the rows go.
|
||||
const owned = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
if (owned.length > 0) {
|
||||
await this.context.drizzle.delete(pluginSettings).where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
inArray(
|
||||
pluginSettings.scopeId,
|
||||
owned.map((row) => String(row.id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
@@ -350,6 +352,18 @@ export class HostRepository {
|
||||
}
|
||||
|
||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||
// plugin_settings.scope_id is polymorphic, so it has no foreign key to
|
||||
// ssh_data and the engine will not cascade it. Cleared alongside the
|
||||
// access rows, which every host delete path already goes through.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, "host"),
|
||||
eq(pluginSettings.scopeId, String(hostId)),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
@@ -15,10 +15,6 @@ export interface HostKeyVerificationRecord {
|
||||
export interface HostUpdateStateRecord {
|
||||
userId: string;
|
||||
credentialId: number | null;
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
vaultProfileId: number | null;
|
||||
authType: string;
|
||||
parentHostId: number | null;
|
||||
folder: string | null;
|
||||
@@ -57,6 +53,17 @@ export type HostListRow = HostResolutionHostRecord & {
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
export interface HostStatusTargetRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
connectionType: string;
|
||||
jumpHosts: string | null;
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number | null;
|
||||
}
|
||||
|
||||
export class HostResolutionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
@@ -132,10 +139,6 @@ export class HostResolutionRepository {
|
||||
.select({
|
||||
userId: hosts.userId,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
vaultProfileId: hosts.vaultProfileId,
|
||||
authType: hosts.authType,
|
||||
parentHostId: hosts.parentHostId,
|
||||
folder: hosts.folder,
|
||||
@@ -262,19 +265,36 @@ export class HostResolutionRepository {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle.select().from(hosts);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
/**
|
||||
* What the status checker needs, without decrypting anything: none of these
|
||||
* columns are encrypted. Filtered by owner, by id, or both.
|
||||
*/
|
||||
async listStatusTargets(filter: {
|
||||
userId?: string;
|
||||
hostIds?: number[];
|
||||
}): Promise<HostStatusTargetRow[]> {
|
||||
if (filter.hostIds && filter.hostIds.length === 0) return [];
|
||||
const conditions = [
|
||||
filter.userId ? eq(hosts.userId, filter.userId) : undefined,
|
||||
filter.hostIds ? inArray(hosts.id, filter.hostIds) : undefined,
|
||||
].filter((condition) => condition !== undefined);
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
userId: hosts.userId,
|
||||
ip: hosts.ip,
|
||||
port: hosts.port,
|
||||
connectionType: hosts.connectionType,
|
||||
jumpHosts: hosts.jumpHosts,
|
||||
statusCheckEnabled: hosts.statusCheckEnabled,
|
||||
statusCheckInterval: hosts.statusCheckInterval,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
||||
}
|
||||
|
||||
async listHostsWithTunnelConnections(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
|
||||
);
|
||||
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle.select().from(hosts);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
}
|
||||
@@ -326,6 +346,7 @@ export class HostResolutionRepository {
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyFirstSeen: now,
|
||||
hostKeyLastVerified: now,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
@@ -347,6 +368,7 @@ export class HostResolutionRepository {
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyLastVerified: now,
|
||||
hostKeyChangedCount: currentChangeCount + 1,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
@@ -358,7 +380,7 @@ export class HostResolutionRepository {
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ hostKeyLastVerified: now })
|
||||
.set({ hostKeyLastVerified: now, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(eq(hosts.id, hostId));
|
||||
await (this.onLazyWrite?.() ?? this.afterWrite());
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { networkTopology } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type NetworkTopologyRecord = typeof networkTopology.$inferSelect;
|
||||
|
||||
export class NetworkTopologyRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<NetworkTopologyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
topology: string,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.findByUserId(userId);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(networkTopology)
|
||||
.set({ topology, updatedAt })
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
await this.afterWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(networkTopology).values({
|
||||
userId,
|
||||
topology,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { opksshTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { upsert } from "./returning.js";
|
||||
|
||||
export type OpksshTokenRecord = typeof opksshTokens.$inferSelect;
|
||||
|
||||
export interface OpksshTokenUpsertInput {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
email?: string | null;
|
||||
sub?: string | null;
|
||||
issuer?: string | null;
|
||||
audience?: string | null;
|
||||
expiresAt: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class OpksshTokenRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(input: OpksshTokenUpsertInput): Promise<void> {
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
|
||||
await upsert(
|
||||
this.context,
|
||||
opksshTokens,
|
||||
{
|
||||
userId: input.userId,
|
||||
hostId: input.hostId,
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
email: input.email,
|
||||
sub: input.sub,
|
||||
issuer: input.issuer,
|
||||
audience: input.audience,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
{
|
||||
target: [opksshTokens.userId, opksshTokens.hostId],
|
||||
set: {
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
email: input.email,
|
||||
sub: input.sub,
|
||||
issuer: input.issuer,
|
||||
audience: input.audience,
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<OpksshTokenRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateLastUsed(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
lastUsed = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(opksshTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(eq(opksshTokens.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginInstallCounts } 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 PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
||||
|
||||
export interface PluginInstallCountUpsertInput {
|
||||
pluginId: string;
|
||||
registryId: string;
|
||||
count: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export class PluginInstallCountRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByPlugin(pluginId: string): Promise<PluginInstallCountRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginInstallCounts)
|
||||
.where(eq(pluginInstallCounts.pluginId, pluginId));
|
||||
}
|
||||
|
||||
async findByPluginAndRegistry(
|
||||
pluginId: string,
|
||||
registryId: string,
|
||||
): Promise<PluginInstallCountRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginInstallCounts)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginInstallCounts.pluginId, pluginId),
|
||||
eq(pluginInstallCounts.registryId, registryId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsert(
|
||||
input: PluginInstallCountUpsertInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<PluginInstallCountRecord> {
|
||||
const existing = await this.findByPluginAndRegistry(
|
||||
input.pluginId,
|
||||
input.registryId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
pluginInstallCounts,
|
||||
{
|
||||
count: input.count,
|
||||
source: input.source ?? existing.source,
|
||||
updatedAt: now,
|
||||
},
|
||||
eq(pluginInstallCounts.id, existing.id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, pluginInstallCounts, {
|
||||
pluginId: input.pluginId,
|
||||
registryId: input.registryId,
|
||||
count: input.count,
|
||||
source: input.source ?? "aggregate-telemetry",
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginInstallCounts)
|
||||
.where(eq(pluginInstallCounts.pluginId, pluginId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginMigrations } from "../db/schema.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type PluginMigrationRecord = typeof pluginMigrations.$inferSelect;
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have run.
|
||||
*
|
||||
* The checksum is the reason this is a table rather than a file marker: it
|
||||
* makes an applied migration immutable, so an edited one is caught instead of
|
||||
* leaving two installs with quietly different schemas.
|
||||
*/
|
||||
export class PluginMigrationRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByPlugin(pluginId: string): Promise<PluginMigrationRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginId, pluginId));
|
||||
}
|
||||
|
||||
async record(
|
||||
pluginId: string,
|
||||
migrationId: string,
|
||||
checksum: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await insertReturning(this.context, pluginMigrations, {
|
||||
pluginId,
|
||||
migrationId,
|
||||
checksum,
|
||||
appliedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginId, pluginId));
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async has(pluginId: string, migrationId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginMigrations.pluginId, pluginId),
|
||||
eq(pluginMigrations.migrationId, migrationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ export type PluginPermissionGrantRecord =
|
||||
export interface PluginPermissionGrantCreateInput {
|
||||
pluginId: string;
|
||||
capability: string;
|
||||
grantedBy: string;
|
||||
/** Null for a bundled grant, which no user made. */
|
||||
grantedBy: string | null;
|
||||
source?: "admin" | "bundled";
|
||||
}
|
||||
|
||||
export class PluginPermissionGrantRepository {
|
||||
@@ -55,6 +57,7 @@ export class PluginPermissionGrantRepository {
|
||||
pluginId: input.pluginId,
|
||||
capability: input.capability,
|
||||
grantedBy: input.grantedBy,
|
||||
source: input.source ?? "admin",
|
||||
grantedAt: now,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { pluginRegistries } 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 PluginRegistryRecord = typeof pluginRegistries.$inferSelect;
|
||||
|
||||
export interface PluginRegistryCreateInput {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind?: string;
|
||||
enabled?: boolean;
|
||||
signingKey?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginRegistryUpdateInput {
|
||||
name?: string;
|
||||
url?: string;
|
||||
kind?: string;
|
||||
enabled?: boolean;
|
||||
signingKey?: string | null;
|
||||
lastCheckedAt?: string | null;
|
||||
lastIndexHash?: string | null;
|
||||
}
|
||||
|
||||
export class PluginRegistryRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listAll(): Promise<PluginRegistryRecord[]> {
|
||||
return this.context.drizzle.select().from(pluginRegistries);
|
||||
}
|
||||
|
||||
async findById(registryId: string): Promise<PluginRegistryRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginRegistries)
|
||||
.where(eq(pluginRegistries.id, registryId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
input: PluginRegistryCreateInput,
|
||||
): Promise<PluginRegistryRecord> {
|
||||
const [created] = await insertReturning(this.context, pluginRegistries, {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind ?? "community",
|
||||
enabled: input.enabled ?? true,
|
||||
signingKey: input.signingKey ?? null,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
registryId: string,
|
||||
input: PluginRegistryUpdateInput,
|
||||
): Promise<PluginRegistryRecord | null> {
|
||||
const existing = await this.findById(registryId);
|
||||
if (!existing) return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
pluginRegistries,
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
url: input.url ?? existing.url,
|
||||
kind: input.kind ?? existing.kind,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
signingKey:
|
||||
input.signingKey === undefined
|
||||
? existing.signingKey
|
||||
: input.signingKey,
|
||||
lastCheckedAt:
|
||||
input.lastCheckedAt === undefined
|
||||
? existing.lastCheckedAt
|
||||
: input.lastCheckedAt,
|
||||
lastIndexHash:
|
||||
input.lastIndexHash === undefined
|
||||
? existing.lastIndexHash
|
||||
: input.lastIndexHash,
|
||||
},
|
||||
eq(pluginRegistries.id, registryId),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async delete(registryId: string): Promise<boolean> {
|
||||
const deleted = await deleteReturning(
|
||||
this.context,
|
||||
pluginRegistries,
|
||||
eq(pluginRegistries.id, registryId),
|
||||
);
|
||||
|
||||
if (deleted.length > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<number> {
|
||||
const all = await this.listAll();
|
||||
const result = await this.context.drizzle.delete(pluginRegistries);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return all.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface PluginCreateInput {
|
||||
source?: string;
|
||||
registryId?: string | null;
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
manifestJson: string;
|
||||
}
|
||||
@@ -29,6 +30,7 @@ export interface PluginUpdateInput {
|
||||
source?: string;
|
||||
registryId?: string | null;
|
||||
state?: string;
|
||||
lastError?: string | null;
|
||||
autoUpdate?: boolean;
|
||||
manifestJson?: string;
|
||||
}
|
||||
@@ -64,6 +66,7 @@ export class PluginRepository {
|
||||
source: input.source ?? "community",
|
||||
registryId: input.registryId ?? null,
|
||||
state: input.state ?? "disabled",
|
||||
lastError: input.lastError ?? null,
|
||||
autoUpdate: input.autoUpdate ?? false,
|
||||
manifestJson: input.manifestJson,
|
||||
installedAt: now,
|
||||
@@ -95,6 +98,8 @@ export class PluginRepository {
|
||||
? existing.registryId
|
||||
: input.registryId,
|
||||
state: input.state ?? existing.state,
|
||||
lastError:
|
||||
input.lastError === undefined ? existing.lastError : input.lastError,
|
||||
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
|
||||
manifestJson: input.manifestJson ?? existing.manifestJson,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { pluginSettings } 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 PluginSettingsRecord = typeof pluginSettings.$inferSelect;
|
||||
|
||||
/**
|
||||
* "secret" holds ctx.secrets values, one row per user and key. The settings
|
||||
* service is driven by manifest fields, so it never reads them.
|
||||
*/
|
||||
export type PluginSettingsScope = "admin" | "user" | "host" | "secret";
|
||||
|
||||
/**
|
||||
* Values behind ctx.settings and the /plugins/:id/settings routes.
|
||||
*
|
||||
* scope_id is null for admin scope, so every lookup has to use IS NULL rather
|
||||
* than = NULL: SQL equality against null never matches, which would make an
|
||||
* admin setting look unset no matter how often it was written.
|
||||
*/
|
||||
export class PluginSettingsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private scopeMatch(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
) {
|
||||
return and(
|
||||
eq(pluginSettings.pluginId, pluginId),
|
||||
eq(pluginSettings.scope, scope),
|
||||
scopeId === null
|
||||
? isNull(pluginSettings.scopeId)
|
||||
: eq(pluginSettings.scopeId, scopeId),
|
||||
);
|
||||
}
|
||||
|
||||
async get(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<PluginSettingsRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async getAll(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(this.scopeMatch(pluginId, scope, scopeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row for a set of scope ids, in one query.
|
||||
*
|
||||
* The host list would otherwise run one query per host, which is the shape
|
||||
* that made other list endpoints slow at a few hundred hosts.
|
||||
*/
|
||||
async getAllForScopeIds(
|
||||
scope: PluginSettingsScope,
|
||||
scopeIds: string[],
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
if (scopeIds.length === 0) return [];
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
inArray(pluginSettings.scopeId, scopeIds),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row across all scope ids for one plugin/scope/key, regardless of
|
||||
* owner. For a plugin's own background scan (auto-sync, polling) that needs
|
||||
* to find every host with a setting enabled, across every user, before it
|
||||
* knows which actor to run as.
|
||||
*/
|
||||
async listByKey(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
key: string,
|
||||
): Promise<PluginSettingsRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.pluginId, pluginId),
|
||||
eq(pluginSettings.scope, scope),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async set(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const where = and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
)!;
|
||||
|
||||
const existing = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginSettings)
|
||||
.where(where)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await updateReturning(
|
||||
this.context,
|
||||
pluginSettings,
|
||||
{ value, encrypted, updatedAt: now },
|
||||
where,
|
||||
);
|
||||
} else {
|
||||
await insertReturning(this.context, pluginSettings, {
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value,
|
||||
encrypted,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(
|
||||
pluginId: string,
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
this.scopeMatch(pluginId, scope, scopeId),
|
||||
eq(pluginSettings.key, key),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(eq(pluginSettings.pluginId, pluginId));
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every plugin's rows for one user or host.
|
||||
*
|
||||
* scope_id carries no foreign key, so the user and host delete paths call
|
||||
* this instead of relying on the engine to cascade.
|
||||
*/
|
||||
async deleteByScope(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginSettings.scope, scope),
|
||||
eq(pluginSettings.scopeId, scopeId),
|
||||
),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginStorage } 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 PluginStorageRecord = typeof pluginStorage.$inferSelect;
|
||||
|
||||
export class PluginStorageRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async get(pluginId: string, key: string): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
async listKeys(pluginId: string): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(eq(pluginStorage.pluginId, pluginId));
|
||||
|
||||
return rows.map((row) => row.key);
|
||||
}
|
||||
|
||||
/** How many keys this plugin holds, for the per-plugin key cap. */
|
||||
async countKeys(pluginId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(eq(pluginStorage.pluginId, pluginId));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async set(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await updateReturning(
|
||||
this.context,
|
||||
pluginStorage,
|
||||
{ value, updatedAt: now },
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key))!,
|
||||
);
|
||||
} else {
|
||||
await insertReturning(this.context, pluginStorage, {
|
||||
pluginId,
|
||||
key,
|
||||
value,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(pluginId: string, key: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginStorage)
|
||||
.where(
|
||||
and(eq(pluginStorage.pluginId, pluginId), eq(pluginStorage.key, key)),
|
||||
);
|
||||
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginStorage)
|
||||
.where(eq(pluginStorage.pluginId, pluginId));
|
||||
|
||||
if (rowsAffected(result) > 0) await this.afterWrite();
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
hosts,
|
||||
roles,
|
||||
sharedHostSecrets,
|
||||
snippetAccess,
|
||||
snippets,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
@@ -43,35 +41,9 @@ export interface RbacSharedHost {
|
||||
ownerUsername: string;
|
||||
}
|
||||
|
||||
export interface RbacSharedSnippet {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
|
||||
userId: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isNote: boolean;
|
||||
}
|
||||
|
||||
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
|
||||
hostFilter: string | null;
|
||||
}
|
||||
|
||||
export interface RbacRoleHostAccessCredentialSource {
|
||||
hostAccessId: number;
|
||||
credentialId: number | null;
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
hostId: number;
|
||||
hostOwnerId: string;
|
||||
}
|
||||
@@ -93,12 +65,6 @@ export type UpsertHostAccessInput = RbacAccessTarget & {
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
export type UpsertSnippetAccessInput = RbacAccessTarget & {
|
||||
snippetId: number;
|
||||
grantedBy: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
|
||||
|
||||
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
|
||||
@@ -249,76 +215,6 @@ export class RbacAccessRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: snippetAccess.id,
|
||||
userId: snippetAccess.userId,
|
||||
roleId: snippetAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: snippetAccess.grantedBy,
|
||||
grantedByUsername: sql<
|
||||
string | null
|
||||
>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
createdAt: snippetAccess.createdAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.leftJoin(users, eq(snippetAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
|
||||
.where(eq(snippetAccess.snippetId, snippetId))
|
||||
.orderBy(desc(snippetAccess.createdAt));
|
||||
|
||||
return rows.map(toAccessListItem);
|
||||
}
|
||||
|
||||
async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{
|
||||
id: number;
|
||||
created: boolean;
|
||||
}> {
|
||||
const existing = await this.findSnippetAccess(input.snippetId, input);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(snippetAccess)
|
||||
.set({ expiresAt: input.expiresAt })
|
||||
.where(eq(snippetAccess.id, existing.id));
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: existing.id, created: false };
|
||||
}
|
||||
|
||||
const [created] = await insertReturning(this.context, snippetAccess, {
|
||||
snippetId: input.snippetId,
|
||||
userId: input.targetType === "user" ? input.targetUserId : null,
|
||||
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
||||
grantedBy: input.grantedBy,
|
||||
permissionLevel: "view",
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: created.id, created: true };
|
||||
}
|
||||
|
||||
async revokeSnippetAccess(
|
||||
accessId: number,
|
||||
snippetId: number,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(snippetAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.id, accessId),
|
||||
eq(snippetAccess.snippetId, snippetId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSharedHosts(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
@@ -371,146 +267,6 @@ export class RbacAccessRepository {
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
}
|
||||
|
||||
async listSharedSnippets(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacSharedSnippet[]> {
|
||||
const directShared = await this.context.drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.userId, userId),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return directShared;
|
||||
}
|
||||
|
||||
const directIds = new Set(directShared.map((snippet) => snippet.id));
|
||||
const roleShared = await this.context.drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
inArray(snippetAccess.roleId, roleIds),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
...directShared,
|
||||
...roleShared.filter((snippet) => !directIds.has(snippet.id)),
|
||||
];
|
||||
}
|
||||
|
||||
async listVisibleSharedSnippets(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacVisibleSharedSnippet[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
isNote: snippets.isNote,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
this.userOrRoleSnippetAccessFilter(userId, roleIds),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findAccessibleSharedSnippet(
|
||||
snippetId: number,
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacAccessibleSnippet | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
hostFilter: snippets.hostFilter,
|
||||
isNote: snippets.isNote,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.snippetId, snippetId),
|
||||
this.userOrRoleSnippetAccessFilter(userId, roleIds),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteExpiredHostAccess(
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
@@ -569,9 +325,6 @@ export class RbacAccessRepository {
|
||||
.select({
|
||||
hostAccessId: hostAccess.id,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
hostId: hosts.id,
|
||||
hostOwnerId: hosts.userId,
|
||||
})
|
||||
@@ -670,17 +423,6 @@ export class RbacAccessRepository {
|
||||
);
|
||||
}
|
||||
|
||||
private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) {
|
||||
if (roleIds.length === 0) {
|
||||
return eq(snippetAccess.userId, userId);
|
||||
}
|
||||
|
||||
return or(
|
||||
eq(snippetAccess.userId, userId),
|
||||
inArray(snippetAccess.roleId, roleIds),
|
||||
);
|
||||
}
|
||||
|
||||
private async findHostAccess(hostId: number, target: RbacAccessTarget) {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
@@ -698,23 +440,6 @@ export class RbacAccessRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippetAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.snippetId, snippetId),
|
||||
target.targetType === "user"
|
||||
? eq(snippetAccess.userId, target.targetUserId)
|
||||
: eq(snippetAccess.roleId, target.targetRoleId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { rbacKnownPermissions, rbacAppliedDefaults } from "../db/schema.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type RbacKnownPermissionRecord =
|
||||
typeof rbacKnownPermissions.$inferSelect;
|
||||
export type RbacAppliedDefaultRecord = typeof rbacAppliedDefaults.$inferSelect;
|
||||
|
||||
export interface KnownPermissionInput {
|
||||
permission: string;
|
||||
pluginId?: string | null;
|
||||
}
|
||||
|
||||
export interface AppliedDefaultInput {
|
||||
roleName: string;
|
||||
permission: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two records that make plugin permissions outlive their plugin.
|
||||
*
|
||||
* `rbac_known_permissions` is why a role holding a plugin permission
|
||||
* (`<plugin>.use`) still saves while its plugin is disabled. `rbac_applied_defaults` is why a default an admin
|
||||
* revoked is not handed back on the next boot. Neither has a foreign key to
|
||||
* `plugins`, because both have to survive the plugin going away.
|
||||
*/
|
||||
export class RbacPermissionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listKnown(): Promise<RbacKnownPermissionRecord[]> {
|
||||
return this.context.drizzle.select().from(rbacKnownPermissions);
|
||||
}
|
||||
|
||||
/** Inserts the ones that are new. Re-recording an existing one is a no-op. */
|
||||
async recordKnown(
|
||||
entries: KnownPermissionInput[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
const existing = new Set(
|
||||
(await this.listKnown()).map((row) => row.permission),
|
||||
);
|
||||
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
if (existing.has(entry.permission)) continue;
|
||||
existing.add(entry.permission);
|
||||
|
||||
// Another boot can win the race on a shared database, and losing it just
|
||||
// means the row is already there.
|
||||
try {
|
||||
await insertReturning(this.context, rbacKnownPermissions, {
|
||||
permission: entry.permission,
|
||||
pluginId: entry.pluginId ?? null,
|
||||
firstSeenAt: now,
|
||||
});
|
||||
written += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (written > 0) await this.afterWrite();
|
||||
return written;
|
||||
}
|
||||
|
||||
async listAppliedDefaults(): Promise<RbacAppliedDefaultRecord[]> {
|
||||
return this.context.drizzle.select().from(rbacAppliedDefaults);
|
||||
}
|
||||
|
||||
async recordAppliedDefaults(
|
||||
entries: AppliedDefaultInput[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
const existing = new Set(
|
||||
(await this.listAppliedDefaults()).map(
|
||||
(row) => `${row.roleName}:${row.permission}`,
|
||||
),
|
||||
);
|
||||
|
||||
let written = 0;
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.roleName}:${entry.permission}`;
|
||||
if (existing.has(key)) continue;
|
||||
existing.add(key);
|
||||
|
||||
try {
|
||||
await insertReturning(this.context, rbacAppliedDefaults, {
|
||||
roleName: entry.roleName,
|
||||
permission: entry.permission,
|
||||
appliedAt: now,
|
||||
});
|
||||
written += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (written > 0) await this.afterWrite();
|
||||
return written;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { secretSources } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
|
||||
export type SecretSourceRecord = typeof secretSources.$inferSelect;
|
||||
export type SecretSourceKind = "onepassword-connect";
|
||||
|
||||
/** A row with the token still encrypted - safe to hand to the API. */
|
||||
export type SecretSourcePublic = Omit<SecretSourceRecord, "token"> & {
|
||||
hasToken: boolean;
|
||||
};
|
||||
|
||||
export interface SecretSourceCreateInput {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
kind: SecretSourceKind;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface SecretSourceUpdateInput {
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
token?: string;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export function toPublicSecretSource(
|
||||
row: SecretSourceRecord,
|
||||
): SecretSourcePublic {
|
||||
const { token, ...rest } = row;
|
||||
return { ...rest, hasToken: token.length > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The token is encrypted with the owner's data key under the row id, like
|
||||
* vault_tokens - so a shared source only decrypts while its owner's key is
|
||||
* loaded, which the resolver reports as a clear error.
|
||||
*/
|
||||
export class SecretSourceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
private encryptToken(id: string, ownerId: string, token: string): string {
|
||||
const key = DataCrypto.validateUserAccess(ownerId);
|
||||
return FieldCrypto.encryptField(token, key, id, "token");
|
||||
}
|
||||
|
||||
decryptToken(row: SecretSourceRecord): string {
|
||||
const key = DataCrypto.getUserDataKey(row.userId);
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"The secret source owner's data is locked; they need to sign in first",
|
||||
);
|
||||
}
|
||||
return FieldCrypto.decryptField(row.token, key, row.id, "token");
|
||||
}
|
||||
|
||||
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(secretSources)
|
||||
.where(
|
||||
or(eq(secretSources.userId, userId), eq(secretSources.shared, true)),
|
||||
)
|
||||
.orderBy(desc(secretSources.updatedAt));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SecretSourceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(secretSources)
|
||||
.where(eq(secretSources.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(input: SecretSourceCreateInput): Promise<SecretSourceRecord> {
|
||||
const [created] = await insertReturning(this.context, secretSources, {
|
||||
id: input.id,
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
baseUrl: input.baseUrl,
|
||||
token: this.encryptToken(input.id, input.userId, input.token),
|
||||
shared: input.shared,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
row: SecretSourceRecord,
|
||||
input: SecretSourceUpdateInput,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(secretSources)
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
|
||||
...(input.shared !== undefined ? { shared: input.shared } : {}),
|
||||
...(input.token !== undefined
|
||||
? { token: this.encryptToken(row.id, row.userId, input.token) }
|
||||
: {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(secretSources.id, row.id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(secretSources)
|
||||
.where(eq(secretSources.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(secretSources)
|
||||
.where(eq(secretSources.userId, userId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
||||
import { hosts, sessionRecordings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type SessionRecordingRecord = typeof sessionRecordings.$inferSelect;
|
||||
|
||||
export interface SessionRecordingCreateInput {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
duration?: number | null;
|
||||
commands?: string | null;
|
||||
dangerousActions?: string | null;
|
||||
recordingPath?: string | null;
|
||||
protocol?: string | null;
|
||||
format?: string | null;
|
||||
terminatedByOwner?: boolean | null;
|
||||
terminationReason?: string | null;
|
||||
}
|
||||
|
||||
export interface SessionRecordingListRecord {
|
||||
id: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
duration: number | null;
|
||||
recordingPath: string | null;
|
||||
protocol: string;
|
||||
format: string | null;
|
||||
hostName: string | null;
|
||||
hostIp: string | null;
|
||||
}
|
||||
|
||||
export interface SessionRecordingPathRecord {
|
||||
id: number;
|
||||
recordingPath: string | null;
|
||||
}
|
||||
|
||||
export class SessionRecordingRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
input: SessionRecordingCreateInput,
|
||||
): Promise<SessionRecordingRecord> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
sessionRecordings,
|
||||
input,
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateEnded(
|
||||
id: number,
|
||||
input: {
|
||||
endedAt: string;
|
||||
duration: number | null;
|
||||
terminatedByOwner?: boolean;
|
||||
terminationReason?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessionRecordings)
|
||||
.set(input)
|
||||
.where(eq(sessionRecordings.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SessionRecordingRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findPathById(id: number): Promise<
|
||||
| (SessionRecordingPathRecord & {
|
||||
userId: string;
|
||||
format?: string | null;
|
||||
})
|
||||
| null
|
||||
> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
userId: sessionRecordings.userId,
|
||||
format: sessionRecordings.format,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByUserIdWithHost(
|
||||
userId: string,
|
||||
): Promise<SessionRecordingListRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
hostId: sessionRecordings.hostId,
|
||||
userId: sessionRecordings.userId,
|
||||
startedAt: sessionRecordings.startedAt,
|
||||
endedAt: sessionRecordings.endedAt,
|
||||
duration: sessionRecordings.duration,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
protocol: sessionRecordings.protocol,
|
||||
format: sessionRecordings.format,
|
||||
hostName: hosts.name,
|
||||
hostIp: hosts.ip,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
|
||||
.where(eq(sessionRecordings.userId, userId))
|
||||
.orderBy(desc(sessionRecordings.startedAt));
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<SessionRecordingRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findPathByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<SessionRecordingPathRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listPathsOlderThan(
|
||||
cutoff: string,
|
||||
): Promise<SessionRecordingPathRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(lt(sessionRecordings.startedAt, cutoff));
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches recordings from a user being deleted instead of removing them.
|
||||
* A recording is evidence about the host as much as about the person, and the
|
||||
* file stays on disk regardless — deleting only the row would orphan it.
|
||||
*/
|
||||
async anonymizeByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionRecordings)
|
||||
.set({ userId: null })
|
||||
.where(eq(sessionRecordings.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(inArray(sessionRecordings.hostId, hostIds));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { and, eq, gt, isNull, lt } from "drizzle-orm";
|
||||
import {
|
||||
hosts,
|
||||
sessionShareParticipants,
|
||||
sessionShares,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||
export type SessionShareParticipantRecord =
|
||||
typeof sessionShareParticipants.$inferSelect;
|
||||
|
||||
// "room" shares are minted internally by the collab-room routes and are
|
||||
// joinable by any member of the room whose stage references them.
|
||||
export type SessionShareType = "link" | "user" | "room";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareCreateInput {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string | null;
|
||||
linkToken?: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SessionShareWithHost extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
export interface SharedWithMeRecord extends SessionShareRecord {
|
||||
hostName: string | null;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
function activeShareFilter(now: string) {
|
||||
return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now));
|
||||
}
|
||||
|
||||
export class SessionShareRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
||||
const [created] = await insertReturning(this.context, sessionShares, {
|
||||
id: input.id,
|
||||
hostId: input.hostId,
|
||||
ownerUserId: input.ownerUserId,
|
||||
protocol: input.protocol,
|
||||
sessionId: input.sessionId,
|
||||
tabInstanceId: input.tabInstanceId ?? null,
|
||||
shareType: input.shareType,
|
||||
targetUserId: input.targetUserId ?? null,
|
||||
linkToken: input.linkToken ?? null,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(eq(sessionShares.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveById(
|
||||
id: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(and(eq(sessionShares.id, id), activeShareFilter(now)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByLinkToken(
|
||||
linkToken: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findActiveSharesForHost(
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SessionShareRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessionShares)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.hostId, hostId),
|
||||
eq(sessionShares.ownerUserId, ownerUserId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findSharesTargetingUser(
|
||||
userId: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<SharedWithMeRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
share: sessionShares,
|
||||
hostName: hosts.name,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(sessionShares)
|
||||
.leftJoin(hosts, eq(sessionShares.hostId, hosts.id))
|
||||
.leftJoin(users, eq(sessionShares.ownerUserId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.shareType, "user"),
|
||||
eq(sessionShares.targetUserId, userId),
|
||||
activeShareFilter(now),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row.share,
|
||||
hostName: row.hostName,
|
||||
ownerUsername: row.ownerUsername,
|
||||
}));
|
||||
}
|
||||
|
||||
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(sessionShares.id, shareId),
|
||||
eq(sessionShares.ownerUserId, requestingUserId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where(eq(sessionShares.id, shareId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(lt(sessionShares.expiresAt, now));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async touchShareUsage(
|
||||
shareId: string,
|
||||
lastJoinedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const current = await this.findById(shareId);
|
||||
await this.context.drizzle
|
||||
.update(sessionShares)
|
||||
.set({
|
||||
lastJoinedAt,
|
||||
joinCount: (current?.joinCount ?? 0) + 1,
|
||||
})
|
||||
.where(eq(sessionShares.id, shareId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordParticipantJoin(
|
||||
shareId: string,
|
||||
userId: string | null,
|
||||
guestLabel: string | null,
|
||||
): Promise<SessionShareParticipantRecord> {
|
||||
const [created] = await insertReturning(
|
||||
this.context,
|
||||
sessionShareParticipants,
|
||||
{ shareId, userId, guestLabel },
|
||||
);
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async recordParticipantLeave(participantId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessionShareParticipants)
|
||||
.set({ leftAt: new Date().toISOString() })
|
||||
.where(eq(sessionShareParticipants.id, participantId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteSharesForHost(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(sessionShares)
|
||||
.where(eq(sessionShares.hostId, hostId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { hostAccess, hosts, sharedHostSecrets } from "../db/schema.js";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
hostAccess,
|
||||
hostProtocolAuth,
|
||||
hosts,
|
||||
sharedHostSecrets,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type SharedHostSecretRecord = typeof sharedHostSecrets.$inferSelect;
|
||||
export type NewSharedHostSecretRecord = typeof sharedHostSecrets.$inferInsert;
|
||||
|
||||
export type ShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
/** "ssh", or a protocol id a plugin declares. */
|
||||
export type ShareProtocol = string;
|
||||
|
||||
export class SharedHostSecretsRepository {
|
||||
constructor(
|
||||
@@ -178,19 +184,20 @@ export class SharedHostSecretsRepository {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.userId, ownerId), eq(hosts.credentialId, credentialId)),
|
||||
);
|
||||
const protocolRows = await this.context.drizzle
|
||||
.select({ id: hostProtocolAuth.hostId })
|
||||
.from(hostProtocolAuth)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, ownerId),
|
||||
or(
|
||||
eq(hosts.credentialId, credentialId),
|
||||
eq(hosts.rdpCredentialId, credentialId),
|
||||
eq(hosts.vncCredentialId, credentialId),
|
||||
eq(hosts.telnetCredentialId, credentialId),
|
||||
),
|
||||
eq(hostProtocolAuth.userId, ownerId),
|
||||
eq(hostProtocolAuth.credentialId, credentialId),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
return Array.from(new Set([...rows, ...protocolRows].map((row) => row.id)));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { snippetFolders, snippets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type SnippetRecord = typeof snippets.$inferSelect;
|
||||
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
|
||||
export interface RenameSnippetFolderResult {
|
||||
status: "renamed" | "missing" | "conflict";
|
||||
}
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
export interface NewSnippetInput {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number | null;
|
||||
hostFilter?: unknown;
|
||||
isNote?: boolean;
|
||||
}
|
||||
export interface SnippetUpdateInput {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number;
|
||||
hostFilter?: unknown;
|
||||
isNote?: boolean;
|
||||
}
|
||||
export interface UpdateSnippetResult {
|
||||
existing: SnippetRecord;
|
||||
updated: SnippetRecord;
|
||||
}
|
||||
export interface SnippetBulkImportResult {
|
||||
snippetsImported: number;
|
||||
snippetsSkipped: number;
|
||||
snippetsUpdated: number;
|
||||
foldersImported: number;
|
||||
foldersSkipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}
|
||||
interface BulkImportSnippetInput {
|
||||
name?: unknown;
|
||||
content?: unknown;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: unknown;
|
||||
hostFilter?: string | null;
|
||||
}
|
||||
interface BulkImportFolderInput {
|
||||
name?: unknown;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export class SnippetRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findOwnedById(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listFolders(userId: string): Promise<SnippetFolderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(eq(snippetFolders.userId, userId))
|
||||
.orderBy(asc(snippetFolders.name));
|
||||
}
|
||||
|
||||
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
|
||||
return (
|
||||
this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
// coalesce, not asc(folder): folder is nullable, and NULLs sort first on
|
||||
// SQLite and MySQL but last on Postgres. An export whose row order depends
|
||||
// on the engine is not much of an export.
|
||||
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order))
|
||||
);
|
||||
}
|
||||
|
||||
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
|
||||
return this.listFolders(userId);
|
||||
}
|
||||
|
||||
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
|
||||
async listOwnedSnippetsInFolder(
|
||||
userId: string,
|
||||
folder: string,
|
||||
): Promise<SnippetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
or(
|
||||
eq(snippets.folder, folder),
|
||||
like(snippets.folder, `${folder} / %`),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
.orderBy(
|
||||
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
|
||||
asc(snippets.folder),
|
||||
asc(snippets.order),
|
||||
sql`${snippets.updatedAt} DESC`,
|
||||
);
|
||||
}
|
||||
|
||||
async reorderSnippets(
|
||||
userId: string,
|
||||
updates: SnippetReorderUpdate[],
|
||||
): Promise<void> {
|
||||
for (const update of updates) {
|
||||
const { id, order, folder } = update;
|
||||
|
||||
if (!id || order === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const updateFields: Partial<{
|
||||
order: number;
|
||||
folder: string | null;
|
||||
}> = {
|
||||
order,
|
||||
};
|
||||
|
||||
if (folder !== undefined) {
|
||||
updateFields.folder = folder?.trim() || null;
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set(updateFields)
|
||||
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async createSnippet(
|
||||
userId: string,
|
||||
input: NewSnippetInput,
|
||||
): Promise<SnippetRecord> {
|
||||
const folderValue = input.folder?.trim() || "";
|
||||
const order =
|
||||
input.order === undefined || input.order === null
|
||||
? await this.nextOrderForFolder(userId, folderValue)
|
||||
: input.order;
|
||||
|
||||
const rows = await insertReturning(this.context, snippets, {
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: input.name.trim(),
|
||||
content: input.content.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
folder: input.folder?.trim() || null,
|
||||
order,
|
||||
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
|
||||
isNote: input.isNote ?? false,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateSnippet(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
input: SnippetUpdateInput,
|
||||
): Promise<UpdateSnippetResult | null> {
|
||||
const existing = await this.findOwnedById(userId, snippetId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updateFields: Partial<{
|
||||
updatedAt: ReturnType<typeof sql>;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
hostFilter: string | null;
|
||||
isNote: boolean;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (input.name !== undefined) updateFields.name = input.name.trim();
|
||||
if (input.content !== undefined)
|
||||
updateFields.content = input.content.trim();
|
||||
if (input.description !== undefined)
|
||||
updateFields.description = input.description?.trim() || null;
|
||||
if (input.folder !== undefined)
|
||||
updateFields.folder = input.folder?.trim() || null;
|
||||
if (input.order !== undefined) updateFields.order = input.order;
|
||||
if (input.hostFilter !== undefined)
|
||||
updateFields.hostFilter = input.hostFilter
|
||||
? JSON.stringify(input.hostFilter)
|
||||
: null;
|
||||
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
|
||||
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
snippets,
|
||||
updateFields,
|
||||
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return { existing, updated: rows[0] };
|
||||
}
|
||||
|
||||
async deleteSnippet(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const existing = await this.findOwnedById(userId, snippetId);
|
||||
if (!existing) return null;
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
|
||||
|
||||
await this.afterWrite();
|
||||
return existing;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
snippetsDeleted: number;
|
||||
foldersDeleted: number;
|
||||
}> {
|
||||
const snippetResult = await this.context.drizzle
|
||||
.delete(snippets)
|
||||
.where(eq(snippets.userId, userId));
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(snippetFolders)
|
||||
.where(eq(snippetFolders.userId, userId));
|
||||
|
||||
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
snippetsDeleted: rowsAffected(snippetResult),
|
||||
foldersDeleted: rowsAffected(result),
|
||||
};
|
||||
}
|
||||
|
||||
async bulkImport(
|
||||
userId: string,
|
||||
snippetsToImport: unknown[] | undefined,
|
||||
foldersToImport: unknown[] | undefined,
|
||||
overwrite: boolean,
|
||||
): Promise<SnippetBulkImportResult> {
|
||||
const results: SnippetBulkImportResult = {
|
||||
snippetsImported: 0,
|
||||
snippetsSkipped: 0,
|
||||
snippetsUpdated: 0,
|
||||
foldersImported: 0,
|
||||
foldersSkipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
let changed = false;
|
||||
|
||||
if (Array.isArray(foldersToImport)) {
|
||||
for (const rawFolder of foldersToImport) {
|
||||
const folder = rawFolder as BulkImportFolderInput;
|
||||
if (!isNonEmptyString(folder.name)) {
|
||||
results.failed++;
|
||||
results.errors.push(`Folder missing name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.createFolder(
|
||||
userId,
|
||||
folder.name,
|
||||
folder.color,
|
||||
folder.icon,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!created) {
|
||||
results.foldersSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
results.foldersImported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(snippetsToImport)) {
|
||||
for (let i = 0; i < snippetsToImport.length; i++) {
|
||||
const snippet = snippetsToImport[i] as BulkImportSnippetInput;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(snippet.name) ||
|
||||
!isNonEmptyString(snippet.content)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Snippet ${i + 1}: name and content are required`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderVal = snippet.folder?.trim() || null;
|
||||
const existing = await this.findByNameAndFolder(
|
||||
userId,
|
||||
snippet.name.trim(),
|
||||
folderVal,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (!overwrite) {
|
||||
results.snippetsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({
|
||||
content: snippet.content.trim(),
|
||||
description: snippet.description?.trim() || null,
|
||||
folder: folderVal,
|
||||
order:
|
||||
typeof snippet.order === "number"
|
||||
? snippet.order
|
||||
: existing.order,
|
||||
hostFilter: snippet.hostFilter || null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
and(eq(snippets.id, existing.id), eq(snippets.userId, userId)),
|
||||
);
|
||||
changed = true;
|
||||
results.snippetsUpdated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
|
||||
await this.context.drizzle.insert(snippets).values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: snippet.name.trim(),
|
||||
content: snippet.content.trim(),
|
||||
description: snippet.description?.trim() || null,
|
||||
folder: folderVal,
|
||||
order:
|
||||
typeof snippet.order === "number" ? snippet.order : maxOrder + 1,
|
||||
hostFilter: snippet.hostFilter || null,
|
||||
});
|
||||
changed = true;
|
||||
results.snippetsImported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async createFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
triggerSave = true,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const existing = await this.findFolderByName(userId, name);
|
||||
if (existing) return null;
|
||||
|
||||
const rows = await insertReturning(this.context, snippetFolders, {
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: name.trim(),
|
||||
color: color?.trim() || null,
|
||||
icon: icon?.trim() || null,
|
||||
});
|
||||
|
||||
if (triggerSave) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateFolderMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const existing = await this.findFolderByName(userId, name);
|
||||
if (!existing) return null;
|
||||
|
||||
const updateFields: Partial<{
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
updatedAt: ReturnType<typeof sql>;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (color !== undefined) updateFields.color = color?.trim() || null;
|
||||
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
|
||||
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
snippetFolders,
|
||||
updateFields,
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<RenameSnippetFolderResult> {
|
||||
const existing = await this.findFolderByName(userId, oldName);
|
||||
if (!existing) return { status: "missing" };
|
||||
|
||||
const nameExists = await this.findFolderByName(userId, newName);
|
||||
if (nameExists) return { status: "conflict" };
|
||||
|
||||
await this.context.drizzle
|
||||
.update(snippetFolders)
|
||||
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(
|
||||
and(
|
||||
eq(snippetFolders.userId, userId),
|
||||
eq(snippetFolders.name, oldName),
|
||||
),
|
||||
);
|
||||
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: newName })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
|
||||
|
||||
await this.afterWrite();
|
||||
return { status: "renamed" };
|
||||
}
|
||||
|
||||
async deleteFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
await this.context.drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: null })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
snippetFolders,
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ? { syncId: rows[0].syncId } : null;
|
||||
}
|
||||
|
||||
private async findFolderByName(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippetFolders)
|
||||
.where(
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async nextOrderForFolder(
|
||||
userId: string,
|
||||
folder: string,
|
||||
): Promise<number> {
|
||||
return (await this.maxOrderForFolder(userId, folder || null)) + 1;
|
||||
}
|
||||
|
||||
private async maxOrderForFolder(
|
||||
userId: string,
|
||||
folder: string | null,
|
||||
): Promise<number> {
|
||||
const maxOrderResult = await this.context.drizzle
|
||||
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
folder
|
||||
? eq(snippets.folder, folder)
|
||||
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||
),
|
||||
);
|
||||
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
|
||||
return maxOrder;
|
||||
}
|
||||
|
||||
private async findByNameAndFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
folder: string | null,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
eq(snippets.name, name),
|
||||
folder
|
||||
? eq(snippets.folder, folder)
|
||||
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { ssoProviders, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type SsoProviderRecord = typeof ssoProviders.$inferSelect;
|
||||
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
|
||||
export type SsoProviderUpdate = Partial<
|
||||
Pick<
|
||||
NewSsoProviderRecord,
|
||||
"name" | "type" | "enabled" | "displayOrder" | "config" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export type PublicSsoProviderRecord = Pick<
|
||||
SsoProviderRecord,
|
||||
"id" | "name" | "type" | "displayOrder"
|
||||
>;
|
||||
|
||||
export class SsoProviderRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listEnabledPublic(): Promise<PublicSsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: ssoProviders.id,
|
||||
name: ssoProviders.name,
|
||||
type: ssoProviders.type,
|
||||
displayOrder: ssoProviders.displayOrder,
|
||||
})
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async listEnabled(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async listAll(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findFirstEnabledOidcLike(): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
|
||||
return (
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.type === "oidc" || row.type === "github" || row.type === "google",
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
|
||||
const rows = await insertReturning(this.context, ssoProviders, provider);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
update: SsoProviderUpdate,
|
||||
): Promise<SsoProviderRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
ssoProviders,
|
||||
update,
|
||||
eq(ssoProviders.id, id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async countUsersByProviderId(providerId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.ssoProviderId, providerId));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { syncTombstones } from "../db/schema.js";
|
||||
import { timestampAtOrAfter } from "../sync-timestamp.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
|
||||
|
||||
export type SyncEntityType =
|
||||
| "hosts"
|
||||
| "sshCredentials"
|
||||
| "sshFolders"
|
||||
| "snippets"
|
||||
| "snippetFolders"
|
||||
| "vaultProfiles"
|
||||
| "dashboardServiceLinks"
|
||||
| "homepageItems"
|
||||
| "userPreferences";
|
||||
|
||||
export class SyncTombstoneRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async record(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncId: string,
|
||||
): Promise<void> {
|
||||
if (!syncId) return;
|
||||
await this.context.drizzle.insert(syncTombstones).values({
|
||||
userId,
|
||||
entityType,
|
||||
syncId,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordMany(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncIds: string[],
|
||||
): Promise<void> {
|
||||
const rows = syncIds.filter(Boolean);
|
||||
if (rows.length === 0) return;
|
||||
await this.context.drizzle
|
||||
.insert(syncTombstones)
|
||||
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSince(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
since: string | null,
|
||||
): Promise<SyncTombstoneRecord[]> {
|
||||
const conditions = [
|
||||
eq(syncTombstones.userId, userId),
|
||||
eq(syncTombstones.entityType, entityType),
|
||||
];
|
||||
if (since)
|
||||
conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since));
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(syncTombstones)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { termixIdentityCa } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import {
|
||||
insertedId,
|
||||
rowsAffected,
|
||||
supportsReturning,
|
||||
} from "./mutation-result.js";
|
||||
import { updateReturning } from "./returning.js";
|
||||
|
||||
export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect;
|
||||
export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert;
|
||||
export type TermixIdentityCaUpdate = Partial<
|
||||
Pick<
|
||||
NewTermixIdentityCaRecord,
|
||||
"publicKey" | "privateKey" | "validityDays" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export class TermixIdentityCaRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findPublicByIdentityId(
|
||||
identityId: number,
|
||||
): Promise<Pick<
|
||||
TermixIdentityCaRecord,
|
||||
"publicKey" | "validityDays"
|
||||
> | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
publicKey: termixIdentityCa.publicKey,
|
||||
validityDays: termixIdentityCa.validityDays,
|
||||
})
|
||||
.from(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.identityId, identityId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findDecryptedByIdentityId(
|
||||
userId: string,
|
||||
identityId: number,
|
||||
): Promise<TermixIdentityCaRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.identityId, identityId))
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async createEncryptedForUser(
|
||||
userId: string,
|
||||
ca: NewTermixIdentityCaRecord,
|
||||
): Promise<TermixIdentityCaRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const result = await this.insertThenEncrypt(userId, ca, userDataKey);
|
||||
|
||||
await this.afterWrite();
|
||||
return DataCrypto.decryptRecord(
|
||||
"termix_identity_ca",
|
||||
result,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a CA in two steps, because the ciphertext depends on the id.
|
||||
*
|
||||
* The private key is encrypted with the row's own id as context, which does
|
||||
* not exist until the row does. So: insert with an empty key, encrypt, update.
|
||||
* The empty key must never be observable, hence the transaction.
|
||||
*
|
||||
* Two branches because better-sqlite3 rejects an async transaction callback —
|
||||
* see the same note in UserRepository.
|
||||
*/
|
||||
private async insertThenEncrypt(
|
||||
userId: string,
|
||||
ca: NewTermixIdentityCaRecord,
|
||||
userDataKey: Buffer,
|
||||
): Promise<TermixIdentityCaRecord> {
|
||||
const draft = { ...ca, privateKey: "" };
|
||||
|
||||
const seal = (id: number) =>
|
||||
DataCrypto.encryptRecord(
|
||||
"termix_identity_ca",
|
||||
{ id, privateKey: ca.privateKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).privateKey;
|
||||
|
||||
if (this.context.dialect === "sqlite") {
|
||||
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
|
||||
is checked directly above, and better-sqlite3 needs the synchronous
|
||||
.all() form, which has no async equivalent. */
|
||||
return this.context.drizzle.transaction((tx) => {
|
||||
const row = tx
|
||||
.insert(termixIdentityCa)
|
||||
.values(draft)
|
||||
.returning()
|
||||
.all()[0];
|
||||
return tx
|
||||
.update(termixIdentityCa)
|
||||
.set({ privateKey: seal(row.id) })
|
||||
.where(eq(termixIdentityCa.id, row.id))
|
||||
.returning()
|
||||
.all()[0];
|
||||
});
|
||||
/* eslint-enable no-restricted-syntax */
|
||||
}
|
||||
|
||||
return this.context.drizzle.transaction(async (tx) => {
|
||||
let id: number | null;
|
||||
if (supportsReturning(this.context.dialect)) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- guarded by the check above
|
||||
const rows = await tx
|
||||
.insert(termixIdentityCa)
|
||||
.values(draft)
|
||||
.returning();
|
||||
id = rows[0]?.id ?? null;
|
||||
} else {
|
||||
id = insertedId(await tx.insert(termixIdentityCa).values(draft));
|
||||
}
|
||||
|
||||
if (id === null) {
|
||||
throw new Error("Insert into termix_identity_ca returned no id.");
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(termixIdentityCa)
|
||||
.set({ privateKey: seal(id) })
|
||||
.where(eq(termixIdentityCa.id, id));
|
||||
|
||||
const [row] = await tx
|
||||
.select()
|
||||
.from(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.id, id));
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
async updateEncryptedForIdentity(
|
||||
userId: string,
|
||||
identityId: number,
|
||||
update: TermixIdentityCaUpdate,
|
||||
): Promise<TermixIdentityCaRecord | null> {
|
||||
const existing = await this.findDecryptedByIdentityId(userId, identityId);
|
||||
if (!existing) return null;
|
||||
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const encryptedPrivateKey = update.privateKey
|
||||
? DataCrypto.encryptRecord(
|
||||
"termix_identity_ca",
|
||||
{ id: existing.id, privateKey: update.privateKey },
|
||||
userId,
|
||||
userDataKey,
|
||||
).privateKey
|
||||
: undefined;
|
||||
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
termixIdentityCa,
|
||||
{
|
||||
...update,
|
||||
...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}),
|
||||
},
|
||||
eq(termixIdentityCa.identityId, identityId),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async deleteByIdentityId(identityId: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.identityId, identityId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentityCa)
|
||||
.where(eq(termixIdentityCa.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private decryptOne<T extends Record<string, unknown>>(
|
||||
record: T | null,
|
||||
userId: string,
|
||||
): T | null {
|
||||
if (!record) return null;
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return null;
|
||||
return DataCrypto.decryptRecord(
|
||||
"termix_identity_ca",
|
||||
record,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { termixIdentities, termixIdentityKeys } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type TermixIdentityRecord = typeof termixIdentities.$inferSelect;
|
||||
export type NewTermixIdentityRecord = typeof termixIdentities.$inferInsert;
|
||||
export type TermixIdentityUpdate = Partial<
|
||||
Pick<NewTermixIdentityRecord, "handle" | "description" | "updatedAt">
|
||||
>;
|
||||
|
||||
export type TermixIdentityKeyRecord = typeof termixIdentityKeys.$inferSelect;
|
||||
export type NewTermixIdentityKeyRecord = typeof termixIdentityKeys.$inferInsert;
|
||||
export type TermixIdentityKeyUpdate = Partial<
|
||||
Pick<NewTermixIdentityKeyRecord, "enabled" | "label">
|
||||
>;
|
||||
|
||||
export class TermixIdentityRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findIdentityForUser(
|
||||
userId: string,
|
||||
): Promise<TermixIdentityRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findIdentityByHandle(
|
||||
handle: string,
|
||||
): Promise<TermixIdentityRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentities)
|
||||
.where(eq(termixIdentities.handle, handle))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async isHandleTaken(handle: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: termixIdentities.id })
|
||||
.from(termixIdentities)
|
||||
.where(eq(termixIdentities.handle, handle))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async createIdentity(
|
||||
identity: NewTermixIdentityRecord,
|
||||
): Promise<TermixIdentityRecord> {
|
||||
const rows = await insertReturning(
|
||||
this.context,
|
||||
termixIdentities,
|
||||
identity,
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateIdentityForUser(
|
||||
userId: string,
|
||||
update: TermixIdentityUpdate,
|
||||
): Promise<TermixIdentityRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
termixIdentities,
|
||||
update,
|
||||
eq(termixIdentities.userId, userId),
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteIdentityForUser(userId: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
identitiesDeleted: number;
|
||||
keysDeleted: number;
|
||||
}> {
|
||||
const keyResult = await this.context.drizzle
|
||||
.delete(termixIdentityKeys)
|
||||
.where(eq(termixIdentityKeys.userId, userId));
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentities)
|
||||
.where(eq(termixIdentities.userId, userId));
|
||||
|
||||
if (rowsAffected(keyResult) > 0 || rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
identitiesDeleted: rowsAffected(result),
|
||||
keysDeleted: rowsAffected(keyResult),
|
||||
};
|
||||
}
|
||||
|
||||
async listKeysByIdentityId(
|
||||
identityId: number,
|
||||
): Promise<TermixIdentityKeyRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityKeys)
|
||||
.where(eq(termixIdentityKeys.identityId, identityId))
|
||||
.orderBy(asc(termixIdentityKeys.id));
|
||||
}
|
||||
|
||||
async listEnabledKeysByIdentityId(
|
||||
identityId: number,
|
||||
): Promise<TermixIdentityKeyRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.identityId, identityId),
|
||||
eq(termixIdentityKeys.enabled, true),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(termixIdentityKeys.id));
|
||||
}
|
||||
|
||||
async listLinkedCredentialIds(identityId: number): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ credentialId: termixIdentityKeys.credentialId })
|
||||
.from(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.identityId, identityId),
|
||||
eq(termixIdentityKeys.enabled, true),
|
||||
),
|
||||
);
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.map((row) => row.credentialId)
|
||||
.filter(
|
||||
(credentialId): credentialId is number => credentialId !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async createKey(
|
||||
key: NewTermixIdentityKeyRecord,
|
||||
): Promise<TermixIdentityKeyRecord> {
|
||||
const rows = await insertReturning(this.context, termixIdentityKeys, key);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateKeyForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
update: TermixIdentityKeyUpdate,
|
||||
): Promise<TermixIdentityKeyRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
termixIdentityKeys,
|
||||
update,
|
||||
and(eq(termixIdentityKeys.id, id), eq(termixIdentityKeys.userId, userId)),
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteKeyForUser(userId: string, id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.id, id),
|
||||
eq(termixIdentityKeys.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async findKeyForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<TermixIdentityKeyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(termixIdentityKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(termixIdentityKeys.id, id),
|
||||
eq(termixIdentityKeys.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { tmuxSessionTags } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type TmuxSessionTagRecord = typeof tmuxSessionTags.$inferSelect;
|
||||
|
||||
export interface TmuxSessionTagInput {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
sessionName: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export class TmuxSessionTagRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserAndHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.userId, userId),
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
),
|
||||
);
|
||||
|
||||
const bySession = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const tags = bySession.get(row.sessionName) ?? [];
|
||||
tags.push(row.tag);
|
||||
bySession.set(row.sessionName, tags);
|
||||
}
|
||||
return bySession;
|
||||
}
|
||||
|
||||
async renameSessionForHost(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
newSessionName: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.update(tmuxSessionTags)
|
||||
.set({ sessionName: newSessionName })
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteSessionForHost(
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async replaceForUserHostSession(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
sessionName: string,
|
||||
tags: string[],
|
||||
): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.userId, userId),
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
|
||||
if (tags.length > 0) {
|
||||
await this.context.drizzle.insert(tmuxSessionTags).values(
|
||||
tags.map((tag) => ({
|
||||
userId,
|
||||
hostId,
|
||||
sessionName,
|
||||
tag,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const changedRows = rowsAffected(result) + tags.length;
|
||||
if (changedRows > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return changedRows;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(tmuxSessionTags)
|
||||
.where(eq(tmuxSessionTags.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { and, desc, eq, inArray, or } from "drizzle-orm";
|
||||
import { transferRecent } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
|
||||
|
||||
export interface TransferRecentDestinationInput {
|
||||
sourceHostId: number;
|
||||
destHostId: number;
|
||||
destPath: string;
|
||||
destPathLabel?: string | null;
|
||||
}
|
||||
|
||||
export class TransferRecentRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<TransferRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(transferRecent)
|
||||
.where(eq(transferRecent.userId, userId));
|
||||
}
|
||||
|
||||
async listBySourceHost(
|
||||
userId: string,
|
||||
sourceHostId: number,
|
||||
limit = 10,
|
||||
): Promise<TransferRecentRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(transferRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecent.userId, userId),
|
||||
eq(transferRecent.sourceHostId, sourceHostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transferRecent.lastUsed))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async upsertForDestination(
|
||||
userId: string,
|
||||
input: TransferRecentDestinationInput,
|
||||
lastUsed = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const [existing] = await this.context.drizzle
|
||||
.select({ id: transferRecent.id })
|
||||
.from(transferRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecent.userId, userId),
|
||||
eq(transferRecent.sourceHostId, input.sourceHostId),
|
||||
eq(transferRecent.destHostId, input.destHostId),
|
||||
eq(transferRecent.destPath, input.destPath),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(transferRecent)
|
||||
.set({ lastUsed })
|
||||
.where(eq(transferRecent.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(transferRecent).values({
|
||||
userId,
|
||||
sourceHostId: input.sourceHostId,
|
||||
destHostId: input.destHostId,
|
||||
destPath: input.destPath,
|
||||
destPathLabel: input.destPathLabel || input.destPath,
|
||||
lastUsed,
|
||||
});
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async pruneSourceHost(
|
||||
userId: string,
|
||||
sourceHostId: number,
|
||||
keep = 10,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: transferRecent.id })
|
||||
.from(transferRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(transferRecent.userId, userId),
|
||||
eq(transferRecent.sourceHostId, sourceHostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(transferRecent.lastUsed));
|
||||
|
||||
const idsToDelete = rows.slice(keep).map((row) => row.id);
|
||||
if (idsToDelete.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(inArray(transferRecent.id, idsToDelete));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(eq(transferRecent.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
eq(transferRecent.sourceHostId, hostId),
|
||||
eq(transferRecent.destHostId, hostId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
inArray(transferRecent.sourceHostId, hostIds),
|
||||
inArray(transferRecent.destHostId, hostIds),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import crypto from "crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { userExternalIdentities, userSecondFactors } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type UserExternalIdentityRecord =
|
||||
typeof userExternalIdentities.$inferSelect;
|
||||
export type UserSecondFactorRecord = typeof userSecondFactors.$inferSelect;
|
||||
|
||||
/** Subjects are indexed, so they stay within the portable key length. */
|
||||
const MAX_EXTERNAL_SUBJECT_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* Keeps a subject within the index length. Longer ones are hashed, which is
|
||||
* stable, so the same provider answer always finds the same row.
|
||||
*/
|
||||
function normalizeExternalSubject(subject: string): string {
|
||||
if (subject.length <= MAX_EXTERNAL_SUBJECT_LENGTH) return subject;
|
||||
return `sha256:${crypto.createHash("sha256").update(subject).digest("hex")}`;
|
||||
}
|
||||
|
||||
/** External sign-in identities and second-factor enrolments. */
|
||||
export class UserAuthRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findIdentity(
|
||||
providerId: string,
|
||||
rawSubject: string,
|
||||
): Promise<UserExternalIdentityRecord | null> {
|
||||
const subject = normalizeExternalSubject(rawSubject);
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userExternalIdentities)
|
||||
.where(
|
||||
and(
|
||||
eq(userExternalIdentities.providerId, providerId),
|
||||
eq(userExternalIdentities.subject, subject),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listIdentitiesForUser(
|
||||
userId: string,
|
||||
): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
}
|
||||
|
||||
async countUsersForProvider(providerId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userExternalIdentities.userId })
|
||||
.from(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.providerId, providerId));
|
||||
return new Set(rows.map((row) => row.userId)).size;
|
||||
}
|
||||
|
||||
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle.select().from(userExternalIdentities);
|
||||
}
|
||||
|
||||
/** Links or moves an identity to a user. Idempotent. */
|
||||
async linkIdentity(input: {
|
||||
userId: string;
|
||||
providerId: string;
|
||||
subject: string;
|
||||
email?: string | null;
|
||||
}): Promise<void> {
|
||||
const subject = normalizeExternalSubject(input.subject);
|
||||
const existing = await this.findIdentity(input.providerId, subject);
|
||||
if (existing) {
|
||||
if (existing.userId === input.userId) return;
|
||||
await this.context.drizzle
|
||||
.update(userExternalIdentities)
|
||||
.set({ userId: input.userId, email: input.email ?? existing.email })
|
||||
.where(eq(userExternalIdentities.id, existing.id));
|
||||
} else {
|
||||
await this.context.drizzle.insert(userExternalIdentities).values({
|
||||
userId: input.userId,
|
||||
providerId: input.providerId,
|
||||
subject,
|
||||
email: input.email ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async unlinkIdentitiesForUser(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async moveIdentities(fromUserId: string, toUserId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(userExternalIdentities)
|
||||
.set({ userId: toUserId })
|
||||
.where(eq(userExternalIdentities.userId, fromUserId));
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async listSecondFactors(userId: string): Promise<UserSecondFactorRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId));
|
||||
}
|
||||
|
||||
async listAllSecondFactors(): Promise<UserSecondFactorRecord[]> {
|
||||
return this.context.drizzle.select().from(userSecondFactors);
|
||||
}
|
||||
|
||||
/** Users enrolled in at least one second factor, whichever plugin owns it. */
|
||||
async listUserIdsWithSecondFactors(): Promise<Set<string>> {
|
||||
const rows = await this.context.drizzle
|
||||
.selectDistinct({ userId: userSecondFactors.userId })
|
||||
.from(userSecondFactors);
|
||||
return new Set(rows.map((row) => row.userId));
|
||||
}
|
||||
|
||||
async countUsersWithSecondFactors(): Promise<number> {
|
||||
return (await this.listUserIdsWithSecondFactors()).size;
|
||||
}
|
||||
|
||||
async hasSecondFactor(userId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userSecondFactors.userId })
|
||||
.from(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async recordSecondFactor(
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): Promise<void> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userSecondFactors)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecondFactors.userId, userId),
|
||||
eq(userSecondFactors.pluginId, pluginId),
|
||||
eq(userSecondFactors.factorId, factorId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (rows[0]) return;
|
||||
await this.context.drizzle.insert(userSecondFactors).values({
|
||||
userId,
|
||||
pluginId,
|
||||
factorId,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
});
|
||||
await this.onWrite?.();
|
||||
}
|
||||
|
||||
async removeSecondFactor(
|
||||
userId: string,
|
||||
pluginId: string,
|
||||
factorId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userSecondFactors)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecondFactors.userId, userId),
|
||||
eq(userSecondFactors.pluginId, pluginId),
|
||||
eq(userSecondFactors.factorId, factorId),
|
||||
),
|
||||
);
|
||||
const affected = rowsAffected(result) > 0;
|
||||
if (affected) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async clearSecondFactors(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(userSecondFactors)
|
||||
.where(eq(userSecondFactors.userId, userId));
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.onWrite?.();
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { users } from "../db/schema.js";
|
||||
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
|
||||
import { pluginSettings, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import {
|
||||
countValue,
|
||||
@@ -79,7 +79,7 @@ export class UserRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByOidcIdentifier(
|
||||
async findByExternalIdentifier(
|
||||
oidcIdentifier: string,
|
||||
): Promise<UserRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
@@ -199,6 +199,18 @@ export class UserRepository {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
// plugin_settings.scope_id is polymorphic, so it carries no foreign key to
|
||||
// users and the engine will not cascade it. Removed here rather than in a
|
||||
// route so every caller that deletes a user is covered.
|
||||
await this.context.drizzle
|
||||
.delete(pluginSettings)
|
||||
.where(
|
||||
and(
|
||||
inArray(pluginSettings.scope, ["user", "secret"]),
|
||||
eq(pluginSettings.scopeId, id),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(users)
|
||||
.where(eq(users.id, id));
|
||||
@@ -216,15 +228,6 @@ export class UserRepository {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async countTotpEnabled(): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.totpEnabled, true));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { vaultProfiles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import {
|
||||
deleteReturning,
|
||||
insertReturning,
|
||||
updateReturning,
|
||||
} from "./returning.js";
|
||||
|
||||
export type VaultProfileRecord = typeof vaultProfiles.$inferSelect;
|
||||
|
||||
export interface VaultProfileCreateInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
tags?: string | null;
|
||||
vaultAddr: string;
|
||||
vaultNamespace?: string | null;
|
||||
oidcMount?: string | null;
|
||||
oidcRole?: string | null;
|
||||
sshMount?: string | null;
|
||||
sshRole: string;
|
||||
validPrincipals?: string | null;
|
||||
keyType?: string | null;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
export type VaultProfileUpdateInput = Partial<
|
||||
Omit<VaultProfileCreateInput, "userId">
|
||||
> & {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export class VaultProfileRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listVisibleToUser(userId: string): Promise<VaultProfileRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(
|
||||
or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)),
|
||||
)
|
||||
.orderBy(desc(vaultProfiles.updatedAt));
|
||||
}
|
||||
|
||||
async create(input: VaultProfileCreateInput): Promise<VaultProfileRecord> {
|
||||
const [created] = await insertReturning(this.context, vaultProfiles, {
|
||||
syncId: randomUUID(),
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
folder: input.folder,
|
||||
tags: input.tags,
|
||||
vaultAddr: input.vaultAddr,
|
||||
vaultNamespace: input.vaultNamespace,
|
||||
oidcMount: input.oidcMount,
|
||||
oidcRole: input.oidcRole,
|
||||
sshMount: input.sshMount,
|
||||
sshRole: input.sshRole,
|
||||
validPrincipals: input.validPrincipals,
|
||||
keyType: input.keyType,
|
||||
shared: input.shared ?? false,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<VaultProfileRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateById(
|
||||
id: number,
|
||||
input: VaultProfileUpdateInput,
|
||||
): Promise<VaultProfileRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
vaultProfiles,
|
||||
{
|
||||
...input,
|
||||
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
||||
},
|
||||
eq(vaultProfiles.id, id),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await deleteReturning(
|
||||
this.context,
|
||||
vaultProfiles,
|
||||
eq(vaultProfiles.id, id),
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return { syncId: rows[0].syncId };
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultProfiles)
|
||||
.where(eq(vaultProfiles.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { vaultTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { upsert } from "./returning.js";
|
||||
|
||||
export type VaultTokenRecord = typeof vaultTokens.$inferSelect;
|
||||
|
||||
export interface VaultTokenUpsertInput {
|
||||
userId: string;
|
||||
profileId: number;
|
||||
sshCert: string;
|
||||
privateKey: string;
|
||||
expiresAt: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class VaultTokenRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async upsert(input: VaultTokenUpsertInput): Promise<void> {
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
|
||||
await upsert(
|
||||
this.context,
|
||||
vaultTokens,
|
||||
{
|
||||
userId: input.userId,
|
||||
profileId: input.profileId,
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
expiresAt: input.expiresAt,
|
||||
},
|
||||
{
|
||||
target: [vaultTokens.userId, vaultTokens.profileId],
|
||||
set: {
|
||||
sshCert: input.sshCert,
|
||||
privateKey: input.privateKey,
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findByUserAndProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<VaultTokenRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(vaultTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateLastUsed(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
lastUsed = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(vaultTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndProfile(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(vaultTokens.userId, userId),
|
||||
eq(vaultTokens.profileId, profileId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(vaultTokens)
|
||||
.where(eq(vaultTokens.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { webauthnCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type WebauthnCredentialRecord = typeof webauthnCredentials.$inferSelect;
|
||||
export type NewWebauthnCredentialRecord =
|
||||
typeof webauthnCredentials.$inferInsert;
|
||||
|
||||
export interface WebauthnCredentialAuthState {
|
||||
counter: number;
|
||||
backedUp: boolean;
|
||||
deviceType: string | null;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
export class WebauthnCredentialRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<WebauthnCredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId));
|
||||
}
|
||||
|
||||
async findByCredentialId(
|
||||
credentialId: string,
|
||||
): Promise<WebauthnCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.credentialId, credentialId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async create(
|
||||
record: NewWebauthnCredentialRecord,
|
||||
): Promise<WebauthnCredentialRecord> {
|
||||
const rows = await insertReturning(
|
||||
this.context,
|
||||
webauthnCredentials,
|
||||
record,
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async updateAuthState(
|
||||
id: string,
|
||||
state: WebauthnCredentialAuthState,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(webauthnCredentials)
|
||||
.set(state)
|
||||
.where(eq(webauthnCredentials.id, id));
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: string): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(webauthnCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user