mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05: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,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?.();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user