mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
draft: database layer refactor (#1054)
* feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: start database layer refactor * docs: plan database layer refactor * docs: audit database layer refactor phase zero * chore: add database runtime adapter skeleton * chore: add settings repository skeleton * chore: add user session repository skeleton * chore: add host credential repository skeleton * chore: add field encryption boundary * chore: migrate settings route slice * chore: migrate user settings routes * chore: migrate host metrics settings routes * chore: migrate acme settings route * chore: migrate terminal settings route * chore: migrate tailscale settings read * chore: migrate guacamole settings reads * chore: migrate session timeout settings reads * chore: migrate auth route settings reads * chore: migrate host metrics settings reads * chore: migrate startup settings reads * chore: migrate user settings cleanup * chore: migrate password reset settings * chore: migrate oidc legacy settings read * chore: migrate user route settings slice * chore: migrate oidc state settings * chore: migrate user login settings reads * chore: migrate user crypto settings * chore: consolidate startup settings defaults * chore: consolidate database settings import export * chore: migrate core session auth paths * chore: migrate remaining session auth paths * chore: migrate admin user routes * chore: migrate user route admin checks * chore: migrate user lifecycle routes * chore: migrate auth user lookups * chore: migrate oidc user routes * chore: migrate api key repository paths * docs: add database gray rollout guide * chore: migrate trusted device paths * chore: migrate user session route user lookups * chore: add database repository rollout guard * chore: expose repository rollout status * chore: warn on repository rollout misconfiguration * chore: migrate remaining user lookup helpers * chore: migrate ssh user lookups * chore: migrate user settings admin lookups * chore: migrate acme ssl user lookups * chore: migrate audit log admin checks * chore: migrate oidc account user updates * chore: migrate password reset user updates * chore: migrate user deletion core records * chore: migrate snippet audit user lookups * chore: migrate ldap user sync paths * chore: migrate totp user updates * chore: migrate rbac user checks * chore: migrate rbac role paths * chore: migrate permission role lookups * chore: migrate rbac access list reads * chore: migrate shared rbac reads * chore: migrate rbac access writes * chore: migrate permission host access * chore: migrate role host access lookup * chore: migrate snippet access lookup * chore: migrate shared credential access lookups * chore: migrate host access cleanup writes * chore: migrate host list access checks * chore: migrate host access cleanup routes * chore: migrate shared credential role lookups * chore: migrate user role cleanup * chore: migrate admin role sync * chore: migrate ldap role sync * chore: migrate user role assignment * chore: migrate sso provider access * chore: migrate audit log access * chore: migrate user preference access * chore: migrate open tab access * chore: migrate dismissed alert access * chore: migrate homepage layout access * chore: migrate network topology access * chore: migrate dashboard service link access * chore: migrate command history access * chore: migrate recent activity cleanup * chore: migrate ssh credential usage access * chore: migrate transfer recent access * chore: migrate file manager bookmark access * chore: migrate c2s tunnel preset access * chore: migrate homepage item access * chore: migrate session recording access * chore: migrate tmux session tag access * chore: migrate opkssh token access * chore: migrate vault token access * chore: migrate vault profile access * chore: migrate host metrics preference access * chore: migrate host health access * chore: migrate host metrics history access * chore: migrate alert persistence access * chore: route alert host lookup through repository * chore: migrate user data export reads * chore: route host metrics stats sync through repository * chore: migrate host folder persistence * chore: migrate host resolution reads * chore: route jump host resolution reads * chore: route docker console jump host reads * chore: route docker ssh resolution reads * chore: route proxmox discovery resolution reads * chore: route file manager activity host reads * chore: route host metrics resolution reads * chore: route ssh auth credential reads * chore: route tunnel endpoint credential reads * chore: route credential deployment resolution reads * chore: route command history host flag reads * chore: route snippet execution resolution reads * chore: route terminal host resolution reads * chore: route vault oidc host resolution reads * chore: route wake on lan host reads * chore: route internal host list reads * chore: route host key verification persistence * chore: route credential read paths * chore: route credential host usage reads * chore: route credential folder rename * chore: route host owner access checks * chore: route shared credential source reads * chore: route user host credential cleanup * chore: route credential delete reads * chore: route credential update reads * chore: route host credential reads * chore: route host read paths * chore: route host projection reads * chore: route host list reads * chore: route snippet read paths * chore: route snippet folder writes * chore: route snippet crud paths * chore: route snippet bulk import * chore: route rbac ownership reads * chore: route user count reads * chore: route cleanup snippets folders * chore: route shared credential persistence * chore: route dashboard activity * chore: route guacamole host reads * chore: route host bulk lookups * chore: remove unlock-only simple db ops * chore: route host autostart persistence * chore: route ldap provisioning through users * chore: route credential encrypted writes * chore: route host encrypted writes * chore: route bulk host encrypted writes * chore: route termix id credentials * chore: route termix id ca persistence * chore: route termix identity persistence * chore: route credential system migration * chore: isolate user encryption migration storage * chore: remove legacy simple db ops * chore: isolate legacy sqlite migration copy * chore: route database settings import export * chore: route database host credential export * chore: route database host credential import * chore: route database file-manager import export * chore: route database alert usage import export * chore: route database user checks * chore: isolate auth lazy migration storage * chore: route explicit database saves * chore: initialize database save boundary * chore: route migration snapshot saves * chore: isolate sqlite import constraints * chore: route import sqlite boundary * chore: route user encryption migration store * chore: centralize current repository runtime * chore: route more current repositories * chore: route activity repository runtimes * chore: route token repository runtimes * chore: route health repository runtimes * chore: route identity repository runtimes * chore: route rbac repository runtime * chore: centralize current sqlite runtime access * chore: route user deletion key cleanup * chore: route user deletion vault cleanup * chore: route user deletion homepage cleanup * chore: route user deletion health cleanup * chore: route user deletion alert cleanup * chore: route user deletion identity cleanup * chore: add database layer preupgrade backup * Fix database repository type errors * fix: complete post-merge compile fixes for database refactor Restore missing DatabaseSaveTrigger/getDb imports, session log format fallback, OIDC provider resolution, guacamole recording insert, and passwordFallbackOnly typing after merging current dev. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io>
This commit is contained in:
+38
-102
@@ -1,19 +1,15 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "./utils/cors-config.js";
|
||||
import { getDb } from "./database/db/index.js";
|
||||
import {
|
||||
recentActivity,
|
||||
hosts,
|
||||
hostAccess,
|
||||
userRoles,
|
||||
} from "./database/db/schema.js";
|
||||
import { eq, and, desc, inArray, or, isNull, gte, sql } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../types/index.js";
|
||||
import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js";
|
||||
import { createCurrentHostResolutionRepository } from "./database/repositories/current-host-resolution-repository.js";
|
||||
import { createCurrentRbacAccessRepository } from "./database/repositories/current-rbac-access-repository.js";
|
||||
import { createCurrentRecentActivityRepository } from "./database/repositories/current-recent-activity-repository.js";
|
||||
import { createCurrentRoleRepository } from "./database/repositories/current-role-repository.js";
|
||||
import { DataCrypto } from "./utils/data-crypto.js";
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -23,6 +19,10 @@ const serverStartTime = Date.now();
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000;
|
||||
|
||||
function isUserDataUnlocked(userId: string): boolean {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
@@ -103,7 +103,7 @@ app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -112,16 +112,8 @@ app.get("/activity/recent", async (req, res) => {
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(limit),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
const activities =
|
||||
await createCurrentRecentActivityRepository().listByUserId(userId, limit);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
@@ -168,7 +160,7 @@ app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -223,97 +215,45 @@ app.post("/activity/log", async (req, res) => {
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
const ownedHosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
const isOwnedHost =
|
||||
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (ownedHosts.length === 0) {
|
||||
const userRoleIds = await getDb()
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const roleIds = userRoleIds.map((r) => r.roleId);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const sharedHosts = await getDb()
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
or(
|
||||
eq(hostAccess.userId, userId),
|
||||
roleIds.length > 0
|
||||
? sql`${hostAccess.roleId} IN (${sql.join(
|
||||
roleIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`
|
||||
: sql`false`,
|
||||
),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
if (!isOwnedHost) {
|
||||
const roleIds =
|
||||
await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||
const sharedHosts =
|
||||
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||
userId,
|
||||
roleIds,
|
||||
);
|
||||
const hasSharedAccess = sharedHosts.some(
|
||||
(access) => access.hostId === hostId,
|
||||
);
|
||||
|
||||
if (sharedHosts.length === 0) {
|
||||
if (!hasSharedAccess) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
const result = (await SimpleDBOps.insert(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
const result = await createCurrentRecentActivityRepository().create({
|
||||
userId,
|
||||
)) as unknown as { id: number };
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
});
|
||||
|
||||
// Best-effort trim of old activity entries; failures here should not
|
||||
// cause the primary /activity/log request to 500.
|
||||
try {
|
||||
const allActivities = await SimpleDBOps.select<{
|
||||
id: number;
|
||||
timestamp: string;
|
||||
}>(
|
||||
getDb()
|
||||
.select({
|
||||
id: recentActivity.id,
|
||||
timestamp: recentActivity.timestamp,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
await createCurrentRecentActivityRepository().trimUserActivity(
|
||||
userId,
|
||||
100,
|
||||
);
|
||||
|
||||
if (allActivities.length > 100) {
|
||||
const idsToDelete = allActivities
|
||||
.slice(100)
|
||||
.map((a) => a.id)
|
||||
.filter((id) => typeof id === "number");
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
inArray(recentActivity.id, idsToDelete),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (trimErr) {
|
||||
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
||||
operation: "trim_recent_activity",
|
||||
@@ -349,18 +289,14 @@ app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (!isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
eq(recentActivity.userId, userId),
|
||||
);
|
||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||
|
||||
dashboardLogger.success("Recent activity cleared", {
|
||||
operation: "reset_recent_activity",
|
||||
|
||||
+318
-367
@@ -34,27 +34,24 @@ import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
|
||||
import { DatabaseMigration } from "../utils/database-migration.js";
|
||||
import { UserDataExport } from "../utils/user-data-export.js";
|
||||
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createCurrentCredentialRepository } from "./repositories/current-credential-repository.js";
|
||||
import { createCurrentDismissedAlertRepository } from "./repositories/current-dismissed-alert-repository.js";
|
||||
import { createCurrentFileManagerBookmarkRepository } from "./repositories/current-file-manager-bookmark-repository.js";
|
||||
import { createCurrentHostRepository } from "./repositories/current-host-repository.js";
|
||||
import { createCurrentSettingsRepository } from "./repositories/current-settings-repository.js";
|
||||
import { createCurrentSshCredentialUsageRepository } from "./repositories/current-ssh-credential-usage-repository.js";
|
||||
import { createCurrentUserRepository } from "./repositories/current-user-repository.js";
|
||||
import { getRepositoryRolloutStatus } from "./repositories/repository-rollout.js";
|
||||
import { withCurrentSqliteForeignKeysDisabled } from "./runtime/sqlite-foreign-key-boundary.js";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { getProxyAgent } from "../utils/proxy-agent.js";
|
||||
import {
|
||||
users,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
sshCredentialUsage,
|
||||
settings,
|
||||
} from "./db/schema.js";
|
||||
import type {
|
||||
CacheEntry,
|
||||
GitHubRelease,
|
||||
GitHubAPIResponse,
|
||||
AuthenticatedRequest,
|
||||
} from "../../types/index.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "./db/index.js";
|
||||
import { DatabaseSaveTrigger } from "./db/index.js";
|
||||
import Database from "better-sqlite3";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
@@ -70,6 +67,45 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
app.use(createCorsMiddleware());
|
||||
|
||||
type SettingData = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function shouldExportSetting(key: string): boolean {
|
||||
return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_");
|
||||
}
|
||||
|
||||
async function getExportableSettings(): Promise<SettingData[]> {
|
||||
const settingsRows = await createCurrentSettingsRepository().listAll();
|
||||
|
||||
return settingsRows.filter((setting) => shouldExportSetting(setting.key));
|
||||
}
|
||||
|
||||
function writeSettingsToExportDatabase(
|
||||
exportDb: Database.Database,
|
||||
settingsRows: SettingData[],
|
||||
): void {
|
||||
const insertSetting = exportDb.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
for (const setting of settingsRows) {
|
||||
insertSetting.run(setting.key, setting.value);
|
||||
}
|
||||
}
|
||||
|
||||
function readImportedSettings(importDb: Database.Database): SettingData[] {
|
||||
return importDb
|
||||
.prepare("SELECT key, value FROM settings")
|
||||
.all() as SettingData[];
|
||||
}
|
||||
|
||||
async function upsertImportedSetting(setting: SettingData): Promise<void> {
|
||||
await createCurrentSettingsRepository().upsert(setting.key, setting.value);
|
||||
}
|
||||
|
||||
const uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads");
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
@@ -621,12 +657,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const user = await userRepository.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!user[0].isOidc;
|
||||
const isOidcUser = !!user.isOidc;
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
@@ -867,22 +904,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
userRecord.totpBackupCodes || null,
|
||||
);
|
||||
|
||||
const sshHosts = await getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
const sshHosts =
|
||||
await createCurrentHostRepository().listDecryptedByUserId(userId);
|
||||
const insertHost = exportDb.prepare(`
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const host of sshHosts) {
|
||||
const decrypted = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
host,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
for (const decrypted of sshHosts) {
|
||||
insertHost.run(
|
||||
decrypted.id,
|
||||
decrypted.userId,
|
||||
@@ -940,22 +969,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
const credentials =
|
||||
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
||||
const insertCred = exportDb.prepare(`
|
||||
INSERT INTO ssh_credentials (id, user_id, name, description, folder, tags, auth_type, username, password, key, private_key, public_key, key_password, key_type, detected_key_type, usage_count, last_used, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const cred of credentials) {
|
||||
const decrypted = DataCrypto.decryptRecord(
|
||||
"ssh_credentials",
|
||||
cred,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
for (const decrypted of credentials) {
|
||||
insertCred.run(
|
||||
decrypted.id,
|
||||
decrypted.userId,
|
||||
@@ -979,19 +1000,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const fileManagerRepository =
|
||||
createCurrentFileManagerBookmarkRepository();
|
||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId)),
|
||||
getDb()
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId)),
|
||||
fileManagerRepository.listRecentByUserId(userId),
|
||||
fileManagerRepository.listPinnedByUserId(userId),
|
||||
fileManagerRepository.listShortcutsByUserId(userId),
|
||||
]);
|
||||
|
||||
const insertRecent = exportDb.prepare(`
|
||||
@@ -1039,10 +1053,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const alerts = await getDb()
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
|
||||
const alerts = await dismissedAlertRepository.listByUserId(userId);
|
||||
const insertAlert = exportDb.prepare(`
|
||||
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
@@ -1056,10 +1068,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const usage = await getDb()
|
||||
.select()
|
||||
.from(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId));
|
||||
const sshCredentialUsageRepository =
|
||||
createCurrentSshCredentialUsageRepository();
|
||||
const usage = await sshCredentialUsageRepository.listByUserId(userId);
|
||||
const insertUsage = exportDb.prepare(`
|
||||
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -1074,20 +1085,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const settingsData = await getDb().select().from(settings);
|
||||
const insertSetting = exportDb.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
for (const setting of settingsData) {
|
||||
if (
|
||||
setting.key.startsWith("reset_code_") ||
|
||||
setting.key.startsWith("temp_reset_token_")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
insertSetting.run(setting.key, setting.value);
|
||||
}
|
||||
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
|
||||
} finally {
|
||||
exportDb.close();
|
||||
}
|
||||
@@ -1182,19 +1180,16 @@ app.post(
|
||||
}
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const mainDb = getDb();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const userRecords = await mainDb
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const userRecord = await userRepository.findById(userId);
|
||||
|
||||
if (!userRecords || userRecords.length === 0) {
|
||||
if (!userRecord) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!userRecords[0].isOidc;
|
||||
const isOidcUser = !!userRecord.isOidc;
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
@@ -1276,332 +1271,287 @@ app.post(
|
||||
};
|
||||
|
||||
try {
|
||||
mainDb.$client.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, userId),
|
||||
eq(hosts.ip, host.ip),
|
||||
eq(hosts.port, host.port),
|
||||
eq(hosts.username, host.username),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
autostartPassword: host.autostart_password,
|
||||
autostartKey: host.autostart_key,
|
||||
autostartKeyPassword: host.autostart_key_password,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
enableTerminal: Boolean(host.enable_terminal),
|
||||
enableTunnel: Boolean(host.enable_tunnel),
|
||||
tunnelConnections: host.tunnel_connections,
|
||||
jumpHosts: host.jump_hosts,
|
||||
enableFileManager: Boolean(host.enable_file_manager),
|
||||
enableDocker: Boolean(host.enable_docker),
|
||||
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
|
||||
showFileManagerInSidebar: Boolean(
|
||||
host.show_file_manager_in_sidebar,
|
||||
),
|
||||
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
|
||||
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
|
||||
showServerStatsInSidebar: Boolean(
|
||||
host.show_server_stats_in_sidebar,
|
||||
),
|
||||
defaultPath: host.default_path,
|
||||
statsConfig: host.stats_config,
|
||||
terminalConfig: host.terminal_config,
|
||||
quickActions: host.quick_actions,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const encrypted = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
hostData,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
await mainDb.insert(hosts).values(encrypted);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.name, cred.name),
|
||||
eq(sshCredentials.username, cred.username),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const encrypted = DataCrypto.encryptRecord(
|
||||
"ssh_credentials",
|
||||
credData,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
await mainDb.insert(sshCredentials).values(encrypted);
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const fileManagerTables = [
|
||||
{
|
||||
table: "file_manager_recent",
|
||||
schema: fileManagerRecent,
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_pinned",
|
||||
schema: fileManagerPinned,
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_shortcuts",
|
||||
schema: fileManagerShortcuts,
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { table, schema, key } of fileManagerTables) {
|
||||
await withCurrentSqliteForeignKeysDisabled(async () => {
|
||||
try {
|
||||
const importedItems = importDb
|
||||
.prepare(`SELECT * FROM ${table}`)
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
for (const item of importedItems) {
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(schema)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.userId, userId),
|
||||
eq(schema.path, item.path),
|
||||
eq(schema.name, item.name),
|
||||
),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemData = {
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
hostId: item.host_id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
...(table === "file_manager_recent" && {
|
||||
lastOpened: item.last_opened,
|
||||
}),
|
||||
...(table === "file_manager_pinned" && {
|
||||
pinnedAt: item.pinned_at,
|
||||
}),
|
||||
...(table === "file_manager_shortcuts" && {
|
||||
createdAt: item.created_at,
|
||||
}),
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
autostartPassword: host.autostart_password,
|
||||
autostartKey: host.autostart_key,
|
||||
autostartKeyPassword: host.autostart_key_password,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
enableTerminal: Boolean(host.enable_terminal),
|
||||
enableTunnel: Boolean(host.enable_tunnel),
|
||||
tunnelConnections: host.tunnel_connections,
|
||||
jumpHosts: host.jump_hosts,
|
||||
enableFileManager: Boolean(host.enable_file_manager),
|
||||
enableDocker: Boolean(host.enable_docker),
|
||||
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
|
||||
showFileManagerInSidebar: Boolean(
|
||||
host.show_file_manager_in_sidebar,
|
||||
),
|
||||
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
|
||||
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
|
||||
showServerStatsInSidebar: Boolean(
|
||||
host.show_server_stats_in_sidebar,
|
||||
),
|
||||
defaultPath: host.default_path,
|
||||
statsConfig: host.stats_config,
|
||||
terminalConfig: host.terminal_config,
|
||||
quickActions: host.quick_actions,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await mainDb.insert(schema).values(itemData);
|
||||
result.summary[key]++;
|
||||
} catch (itemError) {
|
||||
await hostRepository.createEncryptedForUser(userId, hostData);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`${table} import error: ${itemError.message}`,
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(`${table} table not found in import file, skipping`);
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const importedAlerts = importDb
|
||||
.prepare("SELECT * FROM dismissed_alerts")
|
||||
.all();
|
||||
for (const alert of importedAlerts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alert.alert_id),
|
||||
),
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const credentialRepository =
|
||||
createCurrentCredentialRepository();
|
||||
const exists =
|
||||
await credentialRepository.existsForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
await mainDb.insert(dismissedAlerts).values({
|
||||
userId: userId,
|
||||
alertId: alert.alert_id,
|
||||
dismissedAt: alert.dismissed_at || new Date().toISOString(),
|
||||
});
|
||||
result.summary.dismissedAlertsImported++;
|
||||
} catch (alertError) {
|
||||
result.summary.errors.push(
|
||||
`Dismissed alert import error: ${alertError.message}`,
|
||||
const fileManagerTables = [
|
||||
{
|
||||
table: "file_manager_recent",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_pinned",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
{
|
||||
table: "file_manager_shortcuts",
|
||||
key: "fileManagerItemsImported",
|
||||
},
|
||||
];
|
||||
|
||||
const fileManagerRepository =
|
||||
createCurrentFileManagerBookmarkRepository();
|
||||
|
||||
for (const { table, key } of fileManagerTables) {
|
||||
try {
|
||||
const importedItems = importDb
|
||||
.prepare(`SELECT * FROM ${table}`)
|
||||
.all();
|
||||
for (const item of importedItems) {
|
||||
try {
|
||||
const bookmark = {
|
||||
hostId: item.host_id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
};
|
||||
const created =
|
||||
table === "file_manager_recent"
|
||||
? await fileManagerRepository.createRecentForImport(
|
||||
userId,
|
||||
bookmark,
|
||||
item.last_opened,
|
||||
)
|
||||
: table === "file_manager_pinned"
|
||||
? await fileManagerRepository.createPinnedForImport(
|
||||
userId,
|
||||
bookmark,
|
||||
item.pinned_at,
|
||||
)
|
||||
: await fileManagerRepository.createShortcutForImport(
|
||||
userId,
|
||||
bookmark,
|
||||
item.created_at,
|
||||
);
|
||||
|
||||
if (created) {
|
||||
result.summary[key]++;
|
||||
} else {
|
||||
result.summary.skippedItems++;
|
||||
}
|
||||
} catch (itemError) {
|
||||
result.summary.errors.push(
|
||||
`${table} import error: ${itemError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
`${table} table not found in import file, skipping`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"dismissed_alerts table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const targetUser = await mainDb
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (targetUser.length > 0 && targetUser[0].isAdmin) {
|
||||
const dismissedAlertRepository =
|
||||
createCurrentDismissedAlertRepository();
|
||||
|
||||
try {
|
||||
const importedSettings = importDb
|
||||
.prepare("SELECT * FROM settings")
|
||||
const importedAlerts = importDb
|
||||
.prepare("SELECT * FROM dismissed_alerts")
|
||||
.all();
|
||||
for (const setting of importedSettings) {
|
||||
for (const alert of importedAlerts) {
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, setting.key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await mainDb
|
||||
.update(settings)
|
||||
.set({ value: setting.value })
|
||||
.where(eq(settings.key, setting.key));
|
||||
result.summary.settingsImported++;
|
||||
const created = await dismissedAlertRepository.createForImport(
|
||||
userId,
|
||||
alert.alert_id,
|
||||
alert.dismissed_at,
|
||||
);
|
||||
if (created) {
|
||||
result.summary.dismissedAlertsImported++;
|
||||
} else {
|
||||
await mainDb.insert(settings).values({
|
||||
key: setting.key,
|
||||
value: setting.value,
|
||||
});
|
||||
result.summary.settingsImported++;
|
||||
result.summary.skippedItems++;
|
||||
}
|
||||
} catch (settingError) {
|
||||
} catch (alertError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
`Dismissed alert import error: ${alertError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("settings table not found in import file, skipping");
|
||||
apiLogger.info(
|
||||
"dismissed_alerts table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
|
||||
mainDb.$client.exec("PRAGMA foreign_keys = ON");
|
||||
result.success = true;
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"settings table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (importDb) {
|
||||
importDb.close();
|
||||
@@ -1945,6 +1895,7 @@ app.get(
|
||||
|
||||
res.json({
|
||||
migrationStatus: status,
|
||||
repositoryRollout: getRepositoryRolloutStatus(),
|
||||
files: {
|
||||
unencryptedDbSize: unencryptedSize,
|
||||
encryptedDbSize: encryptedSize,
|
||||
|
||||
@@ -25,6 +25,28 @@ let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database;
|
||||
|
||||
function getRawSettingValue(key: string): string | null {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(key) as { value?: string } | undefined;
|
||||
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
function setRawSettingValue(key: string, value: string): void {
|
||||
sqlite
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
function ensureRawSettingDefault(key: string, value: string): void {
|
||||
if (getRawSettingValue(key) === null) {
|
||||
sqlite
|
||||
.prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
@@ -615,16 +637,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("allow_registration", "true");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
@@ -633,16 +646,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("allow_password_login", "true");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize allow_password_login setting", {
|
||||
operation: "db_init",
|
||||
@@ -651,16 +655,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("guac_enabled", "true");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
||||
operation: "db_init",
|
||||
@@ -669,16 +664,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
|
||||
)
|
||||
.run(getDefaultGuacdUrl());
|
||||
}
|
||||
ensureRawSettingDefault("guac_url", getDefaultGuacdUrl());
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
operation: "db_init",
|
||||
@@ -1937,31 +1923,30 @@ const migrateSchema = () => {
|
||||
|
||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||
try {
|
||||
const migrationDone = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'")
|
||||
.get();
|
||||
const migrationDone = getRawSettingValue("sso_migration_v1");
|
||||
if (!migrationDone) {
|
||||
const providerCount = (
|
||||
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as { c: number }
|
||||
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as {
|
||||
c: number;
|
||||
}
|
||||
).c;
|
||||
if (providerCount === 0) {
|
||||
const legacyRow = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (legacyRow) {
|
||||
const legacyConfig = getRawSettingValue("oidc_config");
|
||||
if (legacyConfig) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
|
||||
)
|
||||
.run("OIDC", legacyRow.value);
|
||||
databaseLogger.info("Migrated legacy oidc_config into sso_providers table", {
|
||||
operation: "sso_migration_v1",
|
||||
});
|
||||
.run("OIDC", legacyConfig);
|
||||
databaseLogger.info(
|
||||
"Migrated legacy oidc_config into sso_providers table",
|
||||
{
|
||||
operation: "sso_migration_v1",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
sqlite
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')")
|
||||
.run();
|
||||
setRawSettingValue("sso_migration_v1", "true");
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Failed to run SSO migration v1", {
|
||||
@@ -2100,19 +2085,15 @@ const migrateSchema = () => {
|
||||
|
||||
// Seed default metrics history retention setting
|
||||
try {
|
||||
const retentionRow = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'")
|
||||
.get();
|
||||
if (!retentionRow) {
|
||||
sqlite
|
||||
.prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')")
|
||||
.run();
|
||||
}
|
||||
ensureRawSettingDefault("metrics_history_retention_days", "7");
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize metrics_history_retention_days setting", {
|
||||
operation: "schema_migration",
|
||||
error: e,
|
||||
});
|
||||
databaseLogger.warn(
|
||||
"Could not initialize metrics_history_retention_days setting",
|
||||
{
|
||||
operation: "schema_migration",
|
||||
error: e,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- homepage begin ---
|
||||
@@ -2203,21 +2184,23 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handlePostInitFileEncryption() {
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
if (memoryDatabase) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
|
||||
if (enableFileEncryption) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (DatabaseSaveTrigger.isDirty) {
|
||||
saveMemoryDatabaseToFile();
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
}
|
||||
|
||||
if (!enableFileEncryption) return;
|
||||
|
||||
try {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
migration.cleanupOldBackups();
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { AlertRepository } from "./alert-repository.js";
|
||||
|
||||
describe("AlertRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<AlertRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE alert_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
threshold_value REAL,
|
||||
threshold_duration_seconds INTEGER,
|
||||
cooldown_minutes INTEGER NOT NULL DEFAULT 15,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE alert_rule_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id INTEGER NOT NULL,
|
||||
channel_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE alert_firings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
rule_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TEXT,
|
||||
value REAL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
acknowledged INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip)
|
||||
VALUES (1, 'user-1', 'alpha', '127.0.0.1');
|
||||
`);
|
||||
|
||||
return new AlertRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("manages notification channels", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
user_id: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
const updated = await repo.updateNotificationChannel(created.id, "user-1", {
|
||||
name: "Ops disabled",
|
||||
enabled: false,
|
||||
});
|
||||
expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 });
|
||||
|
||||
expect(await repo.listNotificationChannels("user-1")).toHaveLength(1);
|
||||
expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await repo.listNotificationChannels("user-1")).toHaveLength(0);
|
||||
expect(writes).toBe(3);
|
||||
});
|
||||
|
||||
it("manages alert rules and linked channels", async () => {
|
||||
const repo = await createRepository();
|
||||
const ownedChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Owned",
|
||||
type: "ntfy",
|
||||
config: '{"url":"https://ntfy.test","topic":"termix"}',
|
||||
enabled: true,
|
||||
});
|
||||
const foreignChannel = await repo.createNotificationChannel({
|
||||
userId: "user-2",
|
||||
name: "Foreign",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const created = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 80,
|
||||
thresholdDurationSeconds: 30,
|
||||
cooldownMinutes: 5,
|
||||
channels: [ownedChannel.id, foreignChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
user_id: "user-1",
|
||||
host_id: null,
|
||||
name: "CPU high",
|
||||
trigger_type: "cpu_threshold",
|
||||
threshold_value: 80,
|
||||
channels: [ownedChannel.id],
|
||||
});
|
||||
|
||||
const updated = await repo.updateAlertRule(created.id, "user-1", {
|
||||
name: "CPU very high",
|
||||
hostId: 1,
|
||||
channels: [],
|
||||
now: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
name: "CPU very high",
|
||||
host_id: 1,
|
||||
channels: [],
|
||||
updated_at: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const rules = await repo.listAlertRules("user-1");
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].channels).toEqual([]);
|
||||
expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false);
|
||||
expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("lists, acknowledges, and prunes firings", async () => {
|
||||
const repo = await createRepository();
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "Host offline",
|
||||
enabled: true,
|
||||
triggerType: "host_offline",
|
||||
thresholdValue: null,
|
||||
thresholdDurationSeconds: null,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await repo.createFiring({
|
||||
userId: "user-1",
|
||||
ruleId: rule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: null,
|
||||
message: "down",
|
||||
severity: "critical",
|
||||
});
|
||||
|
||||
const listed = await repo.listAlertFirings({
|
||||
userId: "user-1",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(listed.total).toBe(1);
|
||||
expect(listed.firings[0]).toMatchObject({
|
||||
rule_id: rule.id,
|
||||
host_name: "alpha",
|
||||
acknowledged: 0,
|
||||
rule_name: "Host offline",
|
||||
});
|
||||
|
||||
await repo.acknowledgeFiring(listed.firings[0].id, "user-1");
|
||||
const unacknowledged = await repo.listAlertFirings({
|
||||
userId: "user-1",
|
||||
acknowledged: false,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(unacknowledged.total).toBe(0);
|
||||
|
||||
await repo.acknowledgeAllFirings("user-1");
|
||||
repo.pruneFiringsOlderThan("user-1", 0);
|
||||
});
|
||||
|
||||
it("loads enabled rules and notification channels for the alert engine", async () => {
|
||||
const repo = await createRepository();
|
||||
const channel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const disabledChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Disabled",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://disabled.test"}',
|
||||
enabled: false,
|
||||
});
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [channel.id, disabledChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(await repo.listEnabledRulesForHost(1)).toMatchObject([
|
||||
{
|
||||
id: rule.id,
|
||||
userId: "user-1",
|
||||
triggerType: "cpu_threshold",
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(await repo.findRuleById(rule.id)).toMatchObject({
|
||||
id: rule.id,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([
|
||||
{
|
||||
id: channel.id,
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("loads host display names for alert payloads", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.getHostDisplayName(1)).toBe("alpha");
|
||||
expect(await repo.getHostDisplayName(999)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes all alert data for a user", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const userChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const otherChannel = await repo.createNotificationChannel({
|
||||
userId: "user-2",
|
||||
name: "Other",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://other.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const userRule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 80,
|
||||
thresholdDurationSeconds: 30,
|
||||
cooldownMinutes: 5,
|
||||
channels: [userChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const otherRule = await repo.createAlertRule({
|
||||
userId: "user-2",
|
||||
hostId: null,
|
||||
name: "Memory high",
|
||||
enabled: true,
|
||||
triggerType: "memory_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 60,
|
||||
cooldownMinutes: 10,
|
||||
channels: [otherChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
await repo.createFiring({
|
||||
userId: "user-1",
|
||||
ruleId: userRule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: 95,
|
||||
message: "high",
|
||||
severity: "warning",
|
||||
});
|
||||
await repo.createFiring({
|
||||
userId: "user-2",
|
||||
ruleId: otherRule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: 91,
|
||||
message: "other",
|
||||
severity: "warning",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
|
||||
firingsDeleted: 1,
|
||||
ruleLinksDeleted: 1,
|
||||
rulesDeleted: 1,
|
||||
channelsDeleted: 1,
|
||||
});
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
|
||||
firingsDeleted: 0,
|
||||
ruleLinksDeleted: 0,
|
||||
rulesDeleted: 0,
|
||||
channelsDeleted: 0,
|
||||
});
|
||||
|
||||
expect(await repo.listNotificationChannels("user-1")).toEqual([]);
|
||||
expect(await repo.listAlertRules("user-1")).toEqual([]);
|
||||
expect(
|
||||
await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }),
|
||||
).toEqual({ firings: [], total: 0 });
|
||||
expect(await repo.listNotificationChannels("user-2")).toHaveLength(1);
|
||||
expect(await repo.listAlertRules("user-2")).toHaveLength(1);
|
||||
expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1);
|
||||
expect(writes).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import {
|
||||
alertFirings,
|
||||
alertRuleChannels,
|
||||
alertRules,
|
||||
hosts,
|
||||
notificationChannels,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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>,
|
||||
) {}
|
||||
|
||||
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(mapChannelRow);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return rows[0] ? mapChannelRow(rows[0]) : null;
|
||||
}
|
||||
|
||||
async createNotificationChannel(input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}): Promise<NotificationChannelRow> {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(notificationChannels)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
enabled: input.enabled,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return mapChannelRow(created);
|
||||
}
|
||||
|
||||
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 [updated] = await this.context.drizzle
|
||||
.update(notificationChannels)
|
||||
.set(input)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updated) return null;
|
||||
await this.afterWrite();
|
||||
return mapChannelRow(updated);
|
||||
}
|
||||
|
||||
async deleteNotificationChannel(
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const deleted = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationChannels.id, id),
|
||||
eq(notificationChannels.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: notificationChannels.id });
|
||||
|
||||
if (deleted.length === 0) return false;
|
||||
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 this.context.drizzle
|
||||
.insert(alertRules)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
hostId: input.hostId,
|
||||
name: input.name,
|
||||
enabled: input.enabled,
|
||||
triggerType: input.triggerType,
|
||||
thresholdValue: input.thresholdValue,
|
||||
thresholdDurationSeconds: input.thresholdDurationSeconds,
|
||||
cooldownMinutes: input.cooldownMinutes,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const 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 this.context.drizzle
|
||||
.update(alertRules)
|
||||
.set({
|
||||
...(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,
|
||||
})
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
||||
.returning();
|
||||
|
||||
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 deleted = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(and(eq(alertRules.id, id), eq(alertRules.userId, userId)))
|
||||
.returning({ id: alertRules.id });
|
||||
|
||||
if (deleted.length === 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();
|
||||
}
|
||||
|
||||
async listEnabledRulesForHost(hostId: number): Promise<AlertEngineRule[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(
|
||||
and(
|
||||
eq(alertRules.enabled, true),
|
||||
or(eq(alertRules.hostId, hostId), isNull(alertRules.hostId)),
|
||||
),
|
||||
);
|
||||
return rows.map(mapEngineRule);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
pruneFiringsOlderThan(userId: string, days: number): void {
|
||||
this.context.sqlite
|
||||
?.prepare(
|
||||
"DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)",
|
||||
)
|
||||
.run(userId, `-${days} days`);
|
||||
}
|
||||
|
||||
async 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 firingRows = await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(eq(alertFirings.userId, userId))
|
||||
.returning({ id: alertFirings.id });
|
||||
|
||||
const linkFilters = [
|
||||
...(ruleIds.length > 0
|
||||
? [inArray(alertRuleChannels.ruleId, ruleIds)]
|
||||
: []),
|
||||
...(channelIds.length > 0
|
||||
? [inArray(alertRuleChannels.channelId, channelIds)]
|
||||
: []),
|
||||
];
|
||||
const linkRows =
|
||||
linkFilters.length === 0
|
||||
? []
|
||||
: await this.context.drizzle
|
||||
.delete(alertRuleChannels)
|
||||
.where(or(...linkFilters))
|
||||
.returning({ id: alertRuleChannels.id });
|
||||
|
||||
const ruleRows = await this.context.drizzle
|
||||
.delete(alertRules)
|
||||
.where(eq(alertRules.userId, userId))
|
||||
.returning({ id: alertRules.id });
|
||||
const channelRows = await this.context.drizzle
|
||||
.delete(notificationChannels)
|
||||
.where(eq(notificationChannels.userId, userId))
|
||||
.returning({ id: notificationChannels.id });
|
||||
|
||||
if (
|
||||
firingRows.length > 0 ||
|
||||
linkRows.length > 0 ||
|
||||
ruleRows.length > 0 ||
|
||||
channelRows.length > 0
|
||||
) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
firingsDeleted: firingRows.length,
|
||||
ruleLinksDeleted: linkRows.length,
|
||||
rulesDeleted: ruleRows.length,
|
||||
channelsDeleted: channelRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async listEnabledChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<AlertEngineChannel[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: notificationChannels.id,
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
|
||||
describe("ApiKeyRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(onWrite?: () => void): Promise<{
|
||||
apiKeys: ApiKeyRepository;
|
||||
}> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'admin', 'hash'),
|
||||
('user-2', 'target', 'hash');
|
||||
`);
|
||||
|
||||
return {
|
||||
apiKeys: new ApiKeyRepository(context, onWrite),
|
||||
};
|
||||
}
|
||||
|
||||
it("creates, lists, finds, updates last used time, and deletes keys", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.apiKeys.create({
|
||||
id: "key-1",
|
||||
userId: "user-2",
|
||||
name: "deploy",
|
||||
tokenHash: "hash",
|
||||
tokenPrefix: "tmx_12345678",
|
||||
createdAt: "2026-06-26T00:00:00.000Z",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
expect((await repo.apiKeys.findById("key-1"))?.name).toBe("deploy");
|
||||
expect(
|
||||
(await repo.apiKeys.listActiveByTokenPrefix("tmx_12345678")).map(
|
||||
(key) => key.id,
|
||||
),
|
||||
).toEqual(["key-1"]);
|
||||
expect(await repo.apiKeys.listAllWithUsers()).toMatchObject([
|
||||
{
|
||||
id: "key-1",
|
||||
userId: "user-2",
|
||||
username: "target",
|
||||
tokenPrefix: "tmx_12345678",
|
||||
},
|
||||
]);
|
||||
|
||||
await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z");
|
||||
expect((await repo.apiKeys.findById("key-1"))?.lastUsedAt).toBe(
|
||||
"2026-06-26T01:00:00.000Z",
|
||||
);
|
||||
|
||||
expect((await repo.apiKeys.delete("key-1"))?.name).toBe("deploy");
|
||||
expect(await repo.apiKeys.findById("key-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("runs the write hook after key writes", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.apiKeys.create({
|
||||
id: "key-1",
|
||||
userId: "user-1",
|
||||
name: "ops",
|
||||
tokenHash: "hash",
|
||||
tokenPrefix: "tmx_87654321",
|
||||
isActive: true,
|
||||
});
|
||||
await repo.apiKeys.updateLastUsedAt("key-1", "2026-06-26T01:00:00.000Z");
|
||||
await repo.apiKeys.delete("key-1");
|
||||
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all API keys for a user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.apiKeys.create({
|
||||
id: "key-1",
|
||||
userId: "user-2",
|
||||
name: "deploy",
|
||||
tokenHash: "hash-1",
|
||||
tokenPrefix: "tmx_11111111",
|
||||
isActive: true,
|
||||
});
|
||||
await repo.apiKeys.create({
|
||||
id: "key-2",
|
||||
userId: "user-2",
|
||||
name: "ops",
|
||||
tokenHash: "hash-2",
|
||||
tokenPrefix: "tmx_22222222",
|
||||
isActive: true,
|
||||
});
|
||||
await repo.apiKeys.create({
|
||||
id: "key-3",
|
||||
userId: "user-1",
|
||||
name: "admin",
|
||||
tokenHash: "hash-3",
|
||||
tokenPrefix: "tmx_33333333",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await expect(repo.apiKeys.deleteByUserId("user-2")).resolves.toBe(2);
|
||||
|
||||
expect(await repo.apiKeys.findById("key-1")).toBeNull();
|
||||
expect(await repo.apiKeys.findById("key-2")).toBeNull();
|
||||
expect((await repo.apiKeys.findById("key-3"))?.userId).toBe("user-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { apiKeys, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type ApiKeyRecord = typeof apiKeys.$inferSelect;
|
||||
export type NewApiKeyRecord = typeof apiKeys.$inferInsert;
|
||||
|
||||
export interface ApiKeyListRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export class ApiKeyRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(apiKey: NewApiKeyRecord): Promise<ApiKeyRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(apiKeys)
|
||||
.values(apiKey)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async listAllWithUsers(): Promise<ApiKeyListRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
userId: apiKeys.userId,
|
||||
username: users.username,
|
||||
tokenPrefix: apiKeys.tokenPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||
.orderBy(apiKeys.createdAt);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ApiKeyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listActiveByTokenPrefix(tokenPrefix: string): Promise<ApiKeyRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(
|
||||
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
|
||||
);
|
||||
}
|
||||
|
||||
async updateLastUsedAt(id: string, lastUsedAt: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt })
|
||||
.where(eq(apiKeys.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<ApiKeyRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(apiKeys)
|
||||
.where(eq(apiKeys.id, id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId))
|
||||
.returning({ id: apiKeys.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
|
||||
describe("AuditLogRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<AuditLogRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
resource_name TEXT,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new AuditLogRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, filters, pages, and lists actions", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "create_host",
|
||||
resourceType: "host",
|
||||
resourceId: "1",
|
||||
success: true,
|
||||
timestamp: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
username: "bob",
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: "2",
|
||||
success: false,
|
||||
timestamp: "2026-06-27T01:00:00.000Z",
|
||||
});
|
||||
|
||||
const page = await repo.listPage({
|
||||
filters: {
|
||||
resourceType: "host",
|
||||
success: false,
|
||||
startDate: "2026-06-27T00:30:00.000Z",
|
||||
},
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.logs[0]).toMatchObject({
|
||||
userId: "user-2",
|
||||
action: "delete_host",
|
||||
success: false,
|
||||
});
|
||||
expect(await repo.listDistinctActions()).toEqual([
|
||||
"create_host",
|
||||
"delete_host",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "login",
|
||||
resourceType: "auth",
|
||||
success: true,
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
username: "bob",
|
||||
action: "login",
|
||||
resourceType: "auth",
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(await repo.deleteByUserId("missing")).toBe(0);
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(writeCount).toBe(3);
|
||||
expect(
|
||||
(
|
||||
await repo.listPage({
|
||||
filters: {},
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
).logs.map((log) => log.userId),
|
||||
).toEqual(["user-2"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
|
||||
import { auditLogs } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type AuditLogRecord = typeof auditLogs.$inferSelect;
|
||||
export type NewAuditLogRecord = typeof auditLogs.$inferInsert;
|
||||
|
||||
export type AuditLogFilters = {
|
||||
userId?: string;
|
||||
action?: string;
|
||||
resourceType?: string;
|
||||
success?: boolean;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
export type AuditLogPage = {
|
||||
logs: AuditLogRecord[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
const PRUNE_MAX = 10000;
|
||||
const PRUNE_TARGET = 9000;
|
||||
|
||||
export class AuditLogRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(entry: NewAuditLogRecord): Promise<void> {
|
||||
await this.context.drizzle.insert(auditLogs).values(entry);
|
||||
await this.pruneIfNeeded();
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listPage(input: {
|
||||
filters: AuditLogFilters;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<AuditLogPage> {
|
||||
const whereClause = this.buildWhere(input.filters);
|
||||
|
||||
const [logs, totalResult] = await Promise.all([
|
||||
this.context.drizzle
|
||||
.select()
|
||||
.from(auditLogs)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(auditLogs.timestamp))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset),
|
||||
this.context.drizzle
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(auditLogs)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: totalResult[0]?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async listDistinctActions(): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.selectDistinct({ action: auditLogs.action })
|
||||
.from(auditLogs)
|
||||
.orderBy(asc(auditLogs.action));
|
||||
|
||||
return rows.map((row) => row.action);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(auditLogs)
|
||||
.where(eq(auditLogs.userId, userId))
|
||||
.returning({ id: auditLogs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private buildWhere(filters: AuditLogFilters) {
|
||||
const conditions = [];
|
||||
|
||||
if (filters.userId) conditions.push(eq(auditLogs.userId, filters.userId));
|
||||
if (filters.action) conditions.push(eq(auditLogs.action, filters.action));
|
||||
if (filters.resourceType) {
|
||||
conditions.push(eq(auditLogs.resourceType, filters.resourceType));
|
||||
}
|
||||
if (filters.success !== undefined) {
|
||||
conditions.push(eq(auditLogs.success, filters.success));
|
||||
}
|
||||
if (filters.startDate) {
|
||||
conditions.push(gte(auditLogs.timestamp, filters.startDate));
|
||||
}
|
||||
if (filters.endDate) {
|
||||
conditions.push(lte(auditLogs.timestamp, filters.endDate));
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
}
|
||||
|
||||
private async pruneIfNeeded(): Promise<void> {
|
||||
const countResult = await this.context.drizzle
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(auditLogs);
|
||||
const count = countResult[0]?.count ?? 0;
|
||||
|
||||
if (count < PRUNE_MAX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteCount = count - PRUNE_TARGET;
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: auditLogs.id })
|
||||
.from(auditLogs)
|
||||
.orderBy(asc(auditLogs.timestamp))
|
||||
.limit(deleteCount);
|
||||
const ids = rows.map((row) => row.id);
|
||||
|
||||
if (ids.length > 0) {
|
||||
await this.context.drizzle
|
||||
.delete(auditLogs)
|
||||
.where(inArray(auditLogs.id, ids));
|
||||
}
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
|
||||
|
||||
describe("C2sTunnelPresetRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<C2sTunnelPresetRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new C2sTunnelPresetRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists presets ordered by name", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.createForUser("user-1", {
|
||||
name: "Zulu",
|
||||
config: "[]",
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
});
|
||||
await repo.createForUser("user-1", { name: "Alpha", config: "[]" });
|
||||
await repo.createForUser("user-2", { name: "Other", config: "[]" });
|
||||
|
||||
const presets = await repo.listByUserId("user-1");
|
||||
|
||||
expect(presets.map((preset) => preset.name)).toEqual(["Alpha", "Zulu"]);
|
||||
expect(presets[1]).toMatchObject({
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds, updates, and deletes user-owned presets", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const preset = await repo.createForUser("user-1", {
|
||||
name: "Home",
|
||||
config: "[]",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", preset.id)).toBeNull();
|
||||
expect(await repo.hasNameForUser("user-1", "Home")).toBe(true);
|
||||
expect(await repo.hasNameForUser("user-1", "Home", preset.id)).toBe(false);
|
||||
|
||||
const updated = await repo.updateForUser("user-1", preset.id, {
|
||||
name: "Renamed",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: preset.id,
|
||||
name: "Renamed",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(
|
||||
await repo.updateForUser("user-2", preset.id, { name: "Nope" }),
|
||||
).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", preset.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", preset.id)).toBe(true);
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all presets for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.createForUser("user-1", { name: "One", config: "[]" });
|
||||
await repo.createForUser("user-1", { name: "Two", config: "[]" });
|
||||
await repo.createForUser("user-2", { name: "Other", config: "[]" });
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserId("user-1")).toEqual([]);
|
||||
expect(
|
||||
(await repo.listByUserId("user-2")).map((preset) => preset.name),
|
||||
).toEqual(["Other"]);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { c2sTunnelPresets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.insert(c2sTunnelPresets)
|
||||
.values({
|
||||
userId,
|
||||
name: input.name,
|
||||
config: input.config,
|
||||
platform: input.platform ?? null,
|
||||
computerName: input.computerName ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: C2sTunnelPresetUpdateInput,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(c2sTunnelPresets)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
)
|
||||
.returning({ id: c2sTunnelPresets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId))
|
||||
.returning({ id: c2sTunnelPresets.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { CommandHistoryRepository } from "./command-history-repository.js";
|
||||
|
||||
describe("CommandHistoryRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<CommandHistoryRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE command_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
`);
|
||||
|
||||
return new CommandHistoryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists unique commands by latest execution", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", 1, "ls", "2026-06-27T00:00:00.000Z");
|
||||
await repo.create("user-1", 1, "pwd", "2026-06-27T01:00:00.000Z");
|
||||
await repo.create("user-1", 1, "ls", "2026-06-27T02:00:00.000Z");
|
||||
await repo.create("user-2", 3, "whoami", "2026-06-27T03:00:00.000Z");
|
||||
|
||||
expect(await repo.listUniqueCommandsForHost("user-1", 1)).toEqual([
|
||||
"ls",
|
||||
"pwd",
|
||||
]);
|
||||
expect(await repo.listCommandsForHost("user-1", 1)).toEqual([
|
||||
"ls",
|
||||
"pwd",
|
||||
"ls",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deletes commands by command, host, host list, and user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create("user-1", 1, "ls");
|
||||
await repo.create("user-1", 1, "ls");
|
||||
await repo.create("user-1", 2, "pwd");
|
||||
await repo.create("user-2", 3, "whoami");
|
||||
expect(writeCount).toBe(4);
|
||||
|
||||
expect(await repo.deleteCommandForHost("user-1", 1, "missing")).toBe(0);
|
||||
expect(writeCount).toBe(4);
|
||||
|
||||
expect(await repo.deleteCommandForHost("user-1", 1, "ls")).toBe(2);
|
||||
expect(writeCount).toBe(5);
|
||||
|
||||
expect(await repo.deleteByUserAndHost("user-1", 2)).toBe(1);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([3])).toBe(1);
|
||||
expect(writeCount).toBe(7);
|
||||
|
||||
await repo.create("user-1", 1, "date");
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(writeCount).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.insert(commandHistory)
|
||||
.values({ userId, hostId, command, executedAt })
|
||||
.returning();
|
||||
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 rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
)
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.hostId, hostId))
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(inArray(commandHistory.hostId, hostIds))
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.userId, userId))
|
||||
.returning({ id: commandHistory.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
|
||||
export type CredentialRecord = typeof sshCredentials.$inferSelect;
|
||||
export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
|
||||
export type CredentialUpdate = Partial<
|
||||
Omit<NewCredentialRecord, "id" | "userId">
|
||||
>;
|
||||
export type CredentialSystemEncryptionUpdate = Pick<
|
||||
CredentialUpdate,
|
||||
"systemPassword" | "systemKey" | "systemKeyPassword" | "updatedAt"
|
||||
>;
|
||||
|
||||
export class CredentialRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values(credential)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async createEncryptedForUser(
|
||||
userId: string,
|
||||
credential: NewCredentialRecord | Record<string, unknown>,
|
||||
): Promise<CredentialRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = credential.id ?? Date.now();
|
||||
const dataWithTempId = { ...credential, id: tempId };
|
||||
const encryptedCredential = await this.encryptCredentialRecordForWrite(
|
||||
dataWithTempId,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (!credential.id) {
|
||||
delete (encryptedCredential as Partial<NewCredentialRecord>).id;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values(encryptedCredential as NewCredentialRecord)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return DataCrypto.decryptRecord(
|
||||
"ssh_credentials",
|
||||
rows[0],
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findById(credentialId: number): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, credentialId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByUserId(userId: string): Promise<CredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.orderBy(desc(sshCredentials.updatedAt));
|
||||
}
|
||||
|
||||
async listMissingSystemEncryptionByUserId(
|
||||
userId: string,
|
||||
): Promise<CredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
or(
|
||||
isNull(sshCredentials.systemPassword),
|
||||
isNull(sshCredentials.systemKey),
|
||||
isNull(sshCredentials.systemKeyPassword),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async existsForImportIdentity(
|
||||
userId: string,
|
||||
name: string,
|
||||
username: string | null,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.name, name),
|
||||
eq(sshCredentials.username, username),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async findDecryptedByIdForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const row = await this.findByIdForUser(userId, credentialId);
|
||||
return this.decryptOne(row, userId);
|
||||
}
|
||||
|
||||
async listDecryptedByUserId(userId: string): Promise<CredentialRecord[]> {
|
||||
const rows = await this.listByUserId(userId);
|
||||
return this.decryptMany(rows, userId);
|
||||
}
|
||||
|
||||
async listFolders(userId: string): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ folder: sshCredentials.folder })
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
|
||||
return [...new Set(rows.map((row) => row.folder).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set({ folder: newName })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.folder, oldName),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
update: CredentialUpdate,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set(update)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateEncryptedForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
update: CredentialUpdate,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const encryptedUpdate = await this.encryptCredentialRecordForWrite(
|
||||
update,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set(encryptedUpdate)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return this.decryptOne(rows[0] ?? null, userId);
|
||||
}
|
||||
|
||||
async updateSystemEncryptionForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
update: CredentialSystemEncryptionUpdate,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set(update)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, credentialId: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async recordUsage(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
hostId: number,
|
||||
usedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle.insert(sshCredentialUsage).values({
|
||||
credentialId,
|
||||
hostId,
|
||||
userId,
|
||||
usedAt,
|
||||
});
|
||||
|
||||
await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set({
|
||||
lastUsed: usedAt,
|
||||
usageCount: sql`${sshCredentials.usageCount} + 1`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
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(
|
||||
"ssh_credentials",
|
||||
record,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private decryptMany<T extends Record<string, unknown>>(
|
||||
records: T[],
|
||||
userId: string,
|
||||
): T[] {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return [];
|
||||
return DataCrypto.decryptRecords(
|
||||
"ssh_credentials",
|
||||
records,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
}
|
||||
|
||||
private async encryptCredentialRecordForWrite<
|
||||
T extends Record<string, unknown>,
|
||||
>(record: T, userId: string, userDataKey: Buffer): Promise<T> {
|
||||
const encryptedRecord = DataCrypto.encryptRecord(
|
||||
"ssh_credentials",
|
||||
record,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
const systemKey =
|
||||
await SystemCrypto.getInstance().getCredentialSharingKey();
|
||||
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
|
||||
"ssh_credentials",
|
||||
record,
|
||||
systemKey,
|
||||
);
|
||||
|
||||
return { ...encryptedRecord, ...systemEncrypted };
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AlertRepository } from "./alert-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentAlertRepository(): AlertRepository {
|
||||
assertRepositoryRolloutDomainEnabled("alerts");
|
||||
|
||||
return new AlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("alert_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentApiKeyRepository(): ApiKeyRepository {
|
||||
assertRepositoryRolloutDomainEnabled("api_keys");
|
||||
|
||||
return new ApiKeyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("api_key_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||
assertRepositoryRolloutDomainEnabled("audit_logs");
|
||||
|
||||
return new AuditLogRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("audit_log_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentC2sTunnelPresetRepository(): C2sTunnelPresetRepository {
|
||||
assertRepositoryRolloutDomainEnabled("c2s_tunnel_presets");
|
||||
|
||||
return new C2sTunnelPresetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("c2s_tunnel_preset_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { CommandHistoryRepository } from "./command-history-repository.js";
|
||||
|
||||
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
|
||||
assertRepositoryRolloutDomainEnabled("command_history");
|
||||
|
||||
return new CommandHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("command_history_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
assertRepositoryRolloutDomainEnabled("credentials");
|
||||
|
||||
return new CredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("credential_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||
|
||||
export function createCurrentDashboardServiceLinkRepository(): DashboardServiceLinkRepository {
|
||||
assertRepositoryRolloutDomainEnabled("dashboard_service_links");
|
||||
|
||||
return new DashboardServiceLinkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dashboard_service_link_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
|
||||
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
|
||||
assertRepositoryRolloutDomainEnabled("dismissed_alerts");
|
||||
|
||||
return new DismissedAlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("dismissed_alert_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository {
|
||||
assertRepositoryRolloutDomainEnabled("file_manager_bookmarks");
|
||||
|
||||
return new FileManagerBookmarkRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HomepageItemRepository } from "./homepage-item-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
|
||||
assertRepositoryRolloutDomainEnabled("homepage_items");
|
||||
|
||||
return new HomepageItemRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("homepage_item_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
|
||||
|
||||
export function createCurrentHomepageLayoutRepository(): HomepageLayoutRepository {
|
||||
assertRepositoryRolloutDomainEnabled("homepage_layouts");
|
||||
|
||||
return new HomepageLayoutRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("homepage_layout_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
assertRepositoryRolloutDomainEnabled("host_folders");
|
||||
|
||||
return new HostFolderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_folder_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { HostHealthRepository } from "./host-health-repository.js";
|
||||
|
||||
export function createCurrentHostHealthRepository(): HostHealthRepository {
|
||||
assertRepositoryRolloutDomainEnabled("host_health");
|
||||
|
||||
return new HostHealthRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_health_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
|
||||
|
||||
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
|
||||
assertRepositoryRolloutDomainEnabled("host_metrics_history");
|
||||
|
||||
return new HostMetricsHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_metrics_history_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
|
||||
|
||||
export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
|
||||
assertRepositoryRolloutDomainEnabled("host_metrics_preferences");
|
||||
|
||||
return new HostMetricsPreferenceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook(
|
||||
"host_metrics_preference_repository_write",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HostRepository } from "./host-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentHostRepository(): HostRepository {
|
||||
assertRepositoryRolloutDomainEnabled("hosts");
|
||||
|
||||
return new HostRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentHostResolutionRepository(): HostResolutionRepository {
|
||||
assertRepositoryRolloutDomainEnabled("host_resolution");
|
||||
|
||||
return new HostResolutionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("host_resolution_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { NetworkTopologyRepository } from "./network-topology-repository.js";
|
||||
|
||||
export function createCurrentNetworkTopologyRepository(): NetworkTopologyRepository {
|
||||
assertRepositoryRolloutDomainEnabled("network_topology");
|
||||
|
||||
return new NetworkTopologyRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("network_topology_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
|
||||
export function createCurrentOpenTabRepository(): OpenTabRepository {
|
||||
assertRepositoryRolloutDomainEnabled("open_tabs");
|
||||
|
||||
return new OpenTabRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("open_tab_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
|
||||
export function createCurrentOpksshTokenRepository(): OpksshTokenRepository {
|
||||
assertRepositoryRolloutDomainEnabled("opkssh_tokens");
|
||||
|
||||
return new OpksshTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("opkssh_token_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
|
||||
export function createCurrentRbacAccessRepository(): RbacAccessRepository {
|
||||
assertRepositoryRolloutDomainEnabled("rbac_access");
|
||||
|
||||
return new RbacAccessRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("rbac_access_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
|
||||
export function createCurrentRecentActivityRepository(): RecentActivityRepository {
|
||||
assertRepositoryRolloutDomainEnabled("recent_activity");
|
||||
|
||||
return new RecentActivityRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("recent_activity_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { getDb, getSqlite } from "../db/index.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export function createCurrentRepositoryContext(): DatabaseContext {
|
||||
return {
|
||||
dialect: "sqlite",
|
||||
drizzle: getDb(),
|
||||
sqlite: getSqlite(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCurrentRepositoryWriteHook(
|
||||
reason: string,
|
||||
): () => Promise<void> {
|
||||
return () => DatabaseSaveTrigger.forceSave(reason);
|
||||
}
|
||||
|
||||
export function getCurrentRepositorySqlite() {
|
||||
return getSqlite();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentRoleRepository(): RoleRepository {
|
||||
assertRepositoryRolloutDomainEnabled("roles");
|
||||
|
||||
return new RoleRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("role_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
|
||||
export function createCurrentSessionRecordingRepository(): SessionRecordingRepository {
|
||||
assertRepositoryRolloutDomainEnabled("session_recordings");
|
||||
|
||||
return new SessionRecordingRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_recording_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentSessionRepository(): SessionRepository {
|
||||
assertRepositoryRolloutDomainEnabled("sessions");
|
||||
|
||||
return new SessionRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
getCurrentRepositorySqlite,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
assertRepositoryRolloutDomainEnabled("settings");
|
||||
|
||||
return new SettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("settings_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCurrentSettingValue(key: string): string | null {
|
||||
assertRepositoryRolloutDomainEnabled("settings");
|
||||
|
||||
const row = getCurrentRepositorySqlite()
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(key) as { value?: string } | undefined;
|
||||
|
||||
return row?.value ?? null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { SharedCredentialRepository } from "./shared-credential-repository.js";
|
||||
|
||||
export function createCurrentSharedCredentialRepository(): SharedCredentialRepository {
|
||||
assertRepositoryRolloutDomainEnabled("shared_credentials");
|
||||
|
||||
return new SharedCredentialRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("shared_credential_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SnippetRepository } from "./snippet-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentSnippetRepository(): SnippetRepository {
|
||||
assertRepositoryRolloutDomainEnabled("snippets");
|
||||
|
||||
return new SnippetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("snippet_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
|
||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||
assertRepositoryRolloutDomainEnabled("ssh_credential_usage");
|
||||
|
||||
return new SshCredentialUsageRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("ssh_credential_usage_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||
|
||||
export function createCurrentSsoProviderRepository(): SsoProviderRepository {
|
||||
assertRepositoryRolloutDomainEnabled("sso_providers");
|
||||
|
||||
return new SsoProviderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sso_provider_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
|
||||
assertRepositoryRolloutDomainEnabled("termix_identity_ca");
|
||||
|
||||
return new TermixIdentityCaRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("termix_identity_ca_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentTermixIdentityRepository(): TermixIdentityRepository {
|
||||
assertRepositoryRolloutDomainEnabled("termix_identity");
|
||||
|
||||
return new TermixIdentityRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("termix_identity_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
|
||||
|
||||
export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepository {
|
||||
assertRepositoryRolloutDomainEnabled("tmux_session_tags");
|
||||
|
||||
return new TmuxSessionTagRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("tmux_session_tag_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { TransferRecentRepository } from "./transfer-recent-repository.js";
|
||||
|
||||
export function createCurrentTransferRecentRepository(): TransferRecentRepository {
|
||||
assertRepositoryRolloutDomainEnabled("transfer_recent");
|
||||
|
||||
return new TransferRecentRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("transfer_recent_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
|
||||
assertRepositoryRolloutDomainEnabled("trusted_devices");
|
||||
|
||||
return new TrustedDeviceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("trusted_device_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import { createCurrentRepositoryContext } from "./current-repository-runtime.js";
|
||||
import { UserDataExportRepository } from "./user-data-export-repository.js";
|
||||
|
||||
export function createCurrentUserDataExportRepository(): UserDataExportRepository {
|
||||
assertRepositoryRolloutDomainEnabled("user_data_exports");
|
||||
|
||||
return new UserDataExportRepository(createCurrentRepositoryContext());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { UserPreferenceRepository } from "./user-preference-repository.js";
|
||||
|
||||
export function createCurrentUserPreferenceRepository(): UserPreferenceRepository {
|
||||
assertRepositoryRolloutDomainEnabled("user_preferences");
|
||||
|
||||
return new UserPreferenceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("user_preference_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { UserRepository } from "./user-repository.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
|
||||
export function createCurrentUserRepository(): UserRepository {
|
||||
assertRepositoryRolloutDomainEnabled("users");
|
||||
|
||||
return new UserRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("user_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { VaultProfileRepository } from "./vault-profile-repository.js";
|
||||
|
||||
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
|
||||
assertRepositoryRolloutDomainEnabled("vault_profiles");
|
||||
|
||||
return new VaultProfileRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_profile_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { assertRepositoryRolloutDomainEnabled } from "./repository-rollout.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
} from "./current-repository-runtime.js";
|
||||
import { VaultTokenRepository } from "./vault-token-repository.js";
|
||||
|
||||
export function createCurrentVaultTokenRepository(): VaultTokenRepository {
|
||||
assertRepositoryRolloutDomainEnabled("vault_tokens");
|
||||
|
||||
return new VaultTokenRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("vault_token_repository_write"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||
|
||||
describe("DashboardServiceLinkRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<DashboardServiceLinkRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE dashboard_service_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new DashboardServiceLinkRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists links ordered by order then id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.createForUser(
|
||||
"user-1",
|
||||
{ label: "Docs", url: "https://docs.example.com" },
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
const second = await repo.createForUser("user-1", {
|
||||
label: "Status",
|
||||
url: "https://status.example.com",
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
label: "Other",
|
||||
url: "https://other.example.com",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
userId: "user-1",
|
||||
label: "Docs",
|
||||
order: 0,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
expect(second.order).toBe(1);
|
||||
expect(
|
||||
(await repo.listByUserId("user-1")).map((link) => link.label),
|
||||
).toEqual(["Docs", "Status"]);
|
||||
});
|
||||
|
||||
it("finds, updates, and deletes a user-owned link", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const link = await repo.createForUser("user-1", {
|
||||
label: "Docs",
|
||||
url: "https://docs.example.com",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", link.id)).toBeNull();
|
||||
const updated = await repo.updateForUser("user-1", link.id, {
|
||||
label: "Docs renamed",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: link.id,
|
||||
label: "Docs renamed",
|
||||
url: "https://docs.example.com",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.updateForUser("user-2", link.id, { label: "Nope" })).toBe(
|
||||
null,
|
||||
);
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", link.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", link.id)).toBe(true);
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all dashboard links for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.createForUser("user-1", {
|
||||
label: "Docs",
|
||||
url: "https://docs.example.com",
|
||||
});
|
||||
await repo.createForUser("user-1", {
|
||||
label: "Status",
|
||||
url: "https://status.example.com",
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
label: "Other",
|
||||
url: "https://other.example.com",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserId("user-1")).toEqual([]);
|
||||
expect(
|
||||
(await repo.listByUserId("user-2")).map((link) => link.label),
|
||||
).toEqual(["Other"]);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.insert(dashboardServiceLinks)
|
||||
.values({
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
createdAt,
|
||||
})
|
||||
.returning();
|
||||
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 this.context.drizzle
|
||||
.update(dashboardServiceLinks)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: dashboardServiceLinks.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(eq(dashboardServiceLinks.userId, userId))
|
||||
.returning({ id: dashboardServiceLinks.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
|
||||
|
||||
describe("DismissedAlertRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<DismissedAlertRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new DismissedAlertRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, lists, and finds dismissed alerts by user", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create("user-1", "alert-1");
|
||||
await repo.create("user-1", "alert-2");
|
||||
await repo.create("user-2", "alert-3");
|
||||
|
||||
expect(await repo.listAlertIdsByUserId("user-1")).toEqual([
|
||||
"alert-1",
|
||||
"alert-2",
|
||||
]);
|
||||
expect((await repo.findForUser("user-1", "alert-1"))?.alertId).toBe(
|
||||
"alert-1",
|
||||
);
|
||||
expect(await repo.findForUser("user-1", "alert-3")).toBeNull();
|
||||
expect(
|
||||
(await repo.listByUserId("user-1")).map((row) => row.alertId),
|
||||
).toEqual(["alert-1", "alert-2"]);
|
||||
});
|
||||
|
||||
it("creates import alerts without duplicating user alert pairs", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.createForImport("user-1", "alert-1", "2026-01-01T00:00:00.000Z"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.createForImport("user-1", "alert-1", "2026-01-02T00:00:00.000Z"),
|
||||
).resolves.toBe(false);
|
||||
|
||||
const alerts = await repo.listByUserId("user-1");
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(alerts[0]).toMatchObject({
|
||||
alertId: "alert-1",
|
||||
dismissedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes dismissed alerts and only triggers writes for changed rows", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create("user-1", "alert-1");
|
||||
await repo.create("user-1", "alert-2");
|
||||
await repo.create("user-2", "alert-3");
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteForUser("user-1", "missing")).toBe(false);
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteForUser("user-1", "alert-1")).toBe(true);
|
||||
expect(writeCount).toBe(4);
|
||||
expect(await repo.listAlertIdsByUserId("user-1")).toEqual(["alert-2"]);
|
||||
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(writeCount).toBe(5);
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(0);
|
||||
expect(writeCount).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 rows = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, userId),
|
||||
eq(dismissedAlerts.alertId, alertId),
|
||||
),
|
||||
)
|
||||
.returning({ id: dismissedAlerts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId))
|
||||
.returning({ id: dismissedAlerts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import crypto from "crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FieldEncryptionBoundary } from "./field-encryption-boundary.js";
|
||||
|
||||
describe("FieldEncryptionBoundary", () => {
|
||||
const userDataKey = crypto.randomBytes(32);
|
||||
|
||||
it("encrypts sensitive host fields while leaving queryable metadata plaintext", () => {
|
||||
const host = {
|
||||
id: 42,
|
||||
userId: "user-1",
|
||||
name: "prod-db",
|
||||
ip: "10.0.0.5",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
rdpPassword: "rdp-secret",
|
||||
};
|
||||
|
||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_data",
|
||||
host,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
expect(encrypted.password).not.toBe("secret");
|
||||
expect(encrypted.rdpPassword).not.toBe("rdp-secret");
|
||||
expect(encrypted.ip).toBe("10.0.0.5");
|
||||
expect(encrypted.name).toBe("prod-db");
|
||||
|
||||
const decrypted = FieldEncryptionBoundary.decryptRecord(
|
||||
"ssh_data",
|
||||
encrypted,
|
||||
userDataKey,
|
||||
);
|
||||
expect(decrypted).toMatchObject(host);
|
||||
});
|
||||
|
||||
it("encrypts credential system secret fields that the legacy map did not cover", () => {
|
||||
const credential = {
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
name: "system credential",
|
||||
authType: "key",
|
||||
systemPassword: "system-password",
|
||||
systemKey: "system-key",
|
||||
systemKeyPassword: "system-key-password",
|
||||
};
|
||||
|
||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_credentials",
|
||||
credential,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
expect(encrypted.systemPassword).not.toBe("system-password");
|
||||
expect(encrypted.systemKey).not.toBe("system-key");
|
||||
expect(encrypted.systemKeyPassword).not.toBe("system-key-password");
|
||||
expect(encrypted.name).toBe("system credential");
|
||||
|
||||
expect(
|
||||
FieldEncryptionBoundary.decryptRecord(
|
||||
"ssh_credentials",
|
||||
encrypted,
|
||||
userDataKey,
|
||||
),
|
||||
).toMatchObject(credential);
|
||||
});
|
||||
|
||||
it("keeps empty and non-string sensitive values unchanged", () => {
|
||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_data",
|
||||
{
|
||||
id: 1,
|
||||
password: "",
|
||||
key: null,
|
||||
},
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
expect(encrypted.password).toBe("");
|
||||
expect(encrypted.key).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a stable record id instead of inventing a temporary encryption context", () => {
|
||||
expect(() =>
|
||||
FieldEncryptionBoundary.encryptRecord(
|
||||
"ssh_data",
|
||||
{ password: "secret" },
|
||||
userDataKey,
|
||||
),
|
||||
).toThrow(/stable record id/);
|
||||
});
|
||||
|
||||
it("classifies sensitive, plaintext, and unknown fields", () => {
|
||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "password")).toBe(
|
||||
"sensitive",
|
||||
);
|
||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "ip")).toBe(
|
||||
"plaintext",
|
||||
);
|
||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "newField")).toBe(
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
|
||||
const FIELD_ENCRYPTION_POLICY = {
|
||||
users: {
|
||||
sensitive: new Set([
|
||||
"passwordHash",
|
||||
"clientSecret",
|
||||
"totpSecret",
|
||||
"totpBackupCodes",
|
||||
"oidcIdentifier",
|
||||
]),
|
||||
plaintext: new Set(["id", "username", "isAdmin", "isOidc"]),
|
||||
},
|
||||
ssh_data: {
|
||||
sensitive: new Set([
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
]),
|
||||
plaintext: new Set([
|
||||
"id",
|
||||
"userId",
|
||||
"connectionType",
|
||||
"name",
|
||||
"ip",
|
||||
"port",
|
||||
"username",
|
||||
"folder",
|
||||
"tags",
|
||||
"authType",
|
||||
"credentialId",
|
||||
]),
|
||||
},
|
||||
ssh_credentials: {
|
||||
sensitive: new Set([
|
||||
"password",
|
||||
"key",
|
||||
"privateKey",
|
||||
"publicKey",
|
||||
"keyPassword",
|
||||
"systemPassword",
|
||||
"systemKey",
|
||||
"systemKeyPassword",
|
||||
]),
|
||||
plaintext: new Set([
|
||||
"id",
|
||||
"userId",
|
||||
"name",
|
||||
"description",
|
||||
"folder",
|
||||
"tags",
|
||||
"authType",
|
||||
"username",
|
||||
"keyType",
|
||||
"detectedKeyType",
|
||||
"usageCount",
|
||||
"lastUsed",
|
||||
]),
|
||||
},
|
||||
opkssh_tokens: {
|
||||
sensitive: new Set(["sshCert", "privateKey"]),
|
||||
plaintext: new Set(["id", "userId", "hostId", "createdAt", "expiresAt"]),
|
||||
},
|
||||
termix_identity_ca: {
|
||||
sensitive: new Set(["privateKey"]),
|
||||
plaintext: new Set(["id", "publicKey", "createdAt", "updatedAt"]),
|
||||
},
|
||||
vault_tokens: {
|
||||
sensitive: new Set(["sshCert", "privateKey"]),
|
||||
plaintext: new Set(["id", "userId", "profileId", "expiresAt"]),
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PolicyTable = keyof typeof FIELD_ENCRYPTION_POLICY;
|
||||
export type FieldClassification = "sensitive" | "plaintext" | "unknown";
|
||||
|
||||
export class FieldEncryptionBoundary {
|
||||
static classifyField(
|
||||
tableName: string,
|
||||
fieldName: string,
|
||||
): FieldClassification {
|
||||
const policy = this.getPolicy(tableName);
|
||||
if (!policy) return "unknown";
|
||||
if (policy.sensitive.has(fieldName)) return "sensitive";
|
||||
if (policy.plaintext.has(fieldName)) return "plaintext";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
static getSensitiveFields(tableName: string): string[] {
|
||||
const policy = this.getPolicy(tableName);
|
||||
return policy ? [...policy.sensitive].sort() : [];
|
||||
}
|
||||
|
||||
static encryptRecord<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T,
|
||||
userDataKey: Buffer,
|
||||
recordId = record.id,
|
||||
): T {
|
||||
const id = this.requireRecordId(recordId);
|
||||
const encryptedRecord: Record<string, unknown> = { ...record };
|
||||
|
||||
for (const fieldName of this.getSensitiveFields(tableName)) {
|
||||
const value = encryptedRecord[fieldName];
|
||||
if (typeof value === "string" && value) {
|
||||
encryptedRecord[fieldName] = FieldCrypto.encryptField(
|
||||
value,
|
||||
userDataKey,
|
||||
id,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedRecord as T;
|
||||
}
|
||||
|
||||
static decryptRecord<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T,
|
||||
userDataKey: Buffer,
|
||||
recordId = record.id,
|
||||
): T {
|
||||
const id = this.requireRecordId(recordId);
|
||||
const decryptedRecord: Record<string, unknown> = { ...record };
|
||||
|
||||
for (const fieldName of this.getSensitiveFields(tableName)) {
|
||||
const value = decryptedRecord[fieldName];
|
||||
if (typeof value === "string" && value) {
|
||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
||||
value,
|
||||
userDataKey,
|
||||
id,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedRecord as T;
|
||||
}
|
||||
|
||||
private static getPolicy(tableName: string) {
|
||||
return FIELD_ENCRYPTION_POLICY[tableName as PolicyTable];
|
||||
}
|
||||
|
||||
private static requireRecordId(recordId: unknown): string {
|
||||
if (recordId === null || recordId === undefined || recordId === "") {
|
||||
throw new Error("Field encryption requires a stable record id.");
|
||||
}
|
||||
return String(recordId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
|
||||
|
||||
describe("FileManagerBookmarkRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<FileManagerBookmarkRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
`);
|
||||
|
||||
return new FileManagerBookmarkRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("upserts and lists recent files by last-opened time", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertRecent(
|
||||
"user-1",
|
||||
{ hostId: 1, path: "/var/log/app.log" },
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
);
|
||||
await repo.upsertRecent(
|
||||
"user-1",
|
||||
{ hostId: 1, path: "/opt/app/config.json", name: "Config" },
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
);
|
||||
await repo.upsertRecent(
|
||||
"user-1",
|
||||
{ hostId: 1, path: "/var/log/app.log" },
|
||||
"2026-01-03T00:00:00.000Z",
|
||||
);
|
||||
|
||||
const recent = await repo.listRecentForHost("user-1", 1);
|
||||
|
||||
expect(recent.map((entry) => entry.path)).toEqual([
|
||||
"/var/log/app.log",
|
||||
"/opt/app/config.json",
|
||||
]);
|
||||
expect(recent[0].lastOpened).toBe("2026-01-03T00:00:00.000Z");
|
||||
expect(recent[0].name).toBe("app.log");
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(
|
||||
await repo.deleteRecentForHostPath("user-1", {
|
||||
hostId: 1,
|
||||
path: "/var/log/app.log",
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
|
||||
it("creates pinned files and shortcuts without duplicating paths", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.createPinned(
|
||||
"user-1",
|
||||
{ hostId: 1, path: "/srv/www", name: "Web" },
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await repo.createPinned("user-1", { hostId: 1, path: "/srv/www" }),
|
||||
).toBe(false);
|
||||
expect((await repo.listPinnedForHost("user-1", 1))[0]).toMatchObject({
|
||||
name: "Web",
|
||||
path: "/srv/www",
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.createShortcut(
|
||||
"user-1",
|
||||
{ hostId: 1, path: "/etc/nginx" },
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await repo.createShortcut("user-1", { hostId: 1, path: "/etc/nginx" }),
|
||||
).toBe(false);
|
||||
expect((await repo.listShortcutsForHost("user-1", 1))[0]).toMatchObject({
|
||||
name: "nginx",
|
||||
path: "/etc/nginx",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(
|
||||
await repo.deletePinnedForHostPath("user-1", {
|
||||
hostId: 1,
|
||||
path: "/srv/www",
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await repo.deleteShortcutForHostPath("user-1", {
|
||||
hostId: 1,
|
||||
path: "/etc/nginx",
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
|
||||
it("creates import bookmarks without duplicating user path/name pairs", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await expect(
|
||||
repo.createRecentForImport(
|
||||
"user-1",
|
||||
{ hostId: 1, path: "/tmp/a.txt", name: "A" },
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.createRecentForImport("user-1", {
|
||||
hostId: 2,
|
||||
path: "/tmp/a.txt",
|
||||
name: "A",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
await expect(
|
||||
repo.createPinnedForImport("user-1", {
|
||||
hostId: 1,
|
||||
path: "/srv/www",
|
||||
name: "Web",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.createPinnedForImport("user-1", {
|
||||
hostId: 2,
|
||||
path: "/srv/www",
|
||||
name: "Web",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
await expect(
|
||||
repo.createShortcutForImport("user-1", {
|
||||
hostId: 1,
|
||||
path: "/etc/nginx",
|
||||
name: "Nginx",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.createShortcutForImport("user-1", {
|
||||
hostId: 2,
|
||||
path: "/etc/nginx",
|
||||
name: "Nginx",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("deletes bookmarks by user, host, and host list", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertRecent("user-1", { hostId: 1, path: "/one" });
|
||||
await repo.createPinned("user-1", { hostId: 2, path: "/two" });
|
||||
await repo.createShortcut("user-2", { hostId: 3, path: "/three" });
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteByHostId(1)).toBe(1);
|
||||
expect(await repo.deleteByHostId(1)).toBe(0);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([2])).toBe(1);
|
||||
expect(writeCount).toBe(5);
|
||||
|
||||
expect(await repo.deleteByUserId("user-2")).toBe(1);
|
||||
expect(await repo.deleteByUserId("user-2")).toBe(0);
|
||||
expect(writeCount).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
fileManagerShortcuts,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, input.hostId),
|
||||
eq(fileManagerRecent.path, input.path),
|
||||
),
|
||||
)
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
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 rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, input.hostId),
|
||||
eq(fileManagerPinned.path, input.path),
|
||||
),
|
||||
)
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
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 rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, input.hostId),
|
||||
eq(fileManagerShortcuts.path, input.path),
|
||||
),
|
||||
)
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
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 rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId))
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deletePinnedByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId))
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteShortcutsByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId))
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteRecentByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.hostId, hostId))
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deletePinnedByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.hostId, hostId))
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.hostId, hostId))
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerRecent)
|
||||
.where(inArray(fileManagerRecent.hostId, hostIds))
|
||||
.returning({ id: fileManagerRecent.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerPinned)
|
||||
.where(inArray(fileManagerPinned.hostId, hostIds))
|
||||
.returning({ id: fileManagerPinned.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(inArray(fileManagerShortcuts.hostId, hostIds))
|
||||
.returning({ id: fileManagerShortcuts.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { HomepageItemRepository } from "./homepage-item-repository.js";
|
||||
|
||||
describe("HomepageItemRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HomepageItemRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE homepage_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new HomepageItemRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists homepage items by id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.createForUser(
|
||||
"user-1",
|
||||
{ typeId: "clock", title: "Clock", config: "{}" },
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
const second = await repo.createForUser("user-1", {
|
||||
typeId: "terminal",
|
||||
title: null,
|
||||
config: '{"hostId":1}',
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
typeId: "other",
|
||||
title: "Other",
|
||||
config: "{}",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
userId: "user-1",
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: "{}",
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
expect((await repo.listByUserId("user-1")).map((item) => item.id)).toEqual([
|
||||
first.id,
|
||||
second.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds, updates, and deletes user-owned homepage items", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const item = await repo.createForUser("user-1", {
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: "{}",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", item.id)).toBeNull();
|
||||
const updated = await repo.updateForUser(
|
||||
"user-1",
|
||||
item.id,
|
||||
{ title: "Clock renamed", config: '{"timezone":"UTC"}' },
|
||||
"2026-06-27T01:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: item.id,
|
||||
title: "Clock renamed",
|
||||
config: '{"timezone":"UTC"}',
|
||||
updatedAt: "2026-06-27T01:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(
|
||||
await repo.updateForUser("user-2", item.id, { title: "Nope" }),
|
||||
).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", item.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", item.id)).toBe(true);
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all homepage items for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.createForUser("user-1", {
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: "{}",
|
||||
});
|
||||
await repo.createForUser("user-1", {
|
||||
typeId: "terminal",
|
||||
title: "Terminal",
|
||||
config: "{}",
|
||||
});
|
||||
await repo.createForUser("user-2", {
|
||||
typeId: "other",
|
||||
title: "Other",
|
||||
config: "{}",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserId("user-1")).toEqual([]);
|
||||
expect(
|
||||
(await repo.listByUserId("user-2")).map((item) => item.title),
|
||||
).toEqual(["Other"]);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.insert(homepageItems)
|
||||
.values({
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
config: input.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
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 this.context.drizzle
|
||||
.update(homepageItems)
|
||||
.set({ ...updates, updatedAt })
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning({ id: homepageItems.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId))
|
||||
.returning({ id: homepageItems.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
|
||||
|
||||
describe("HomepageLayoutRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HomepageLayoutRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE homepage_layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
layout TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new HomepageLayoutRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds, creates, and updates a layout by user id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
|
||||
const created = await repo.upsertForUser(
|
||||
"user-1",
|
||||
JSON.stringify({ entries: [{ id: "w1" }], zoom: 1 }),
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-1",
|
||||
layout: '{"entries":[{"id":"w1"}],"zoom":1}',
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const updated = await repo.upsertForUser(
|
||||
"user-1",
|
||||
JSON.stringify({ entries: [], zoom: 1.25 }),
|
||||
"2026-06-27T01:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: created.id,
|
||||
userId: "user-1",
|
||||
layout: '{"entries":[],"zoom":1.25}',
|
||||
updatedAt: "2026-06-27T01:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers writes after create and update", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertForUser("user-1", "{}");
|
||||
await repo.upsertForUser("user-1", '{"zoom":2}');
|
||||
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes a layout for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertForUser("user-1", '{"zoom":1}');
|
||||
await repo.upsertForUser("user-2", '{"zoom":2}');
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
expect((await repo.findByUserId("user-2"))?.layout).toBe('{"zoom":2}');
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { homepageLayouts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.insert(homepageLayouts)
|
||||
.values({ userId, layout, updatedAt })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(homepageLayouts)
|
||||
.set({ layout, updatedAt })
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.returning({ id: homepageLayouts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import { HostRepository } from "./host-repository.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
|
||||
describe("HostRepository and CredentialRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepositories(
|
||||
onCredentialWrite?: () => void,
|
||||
onHostWrite?: () => void,
|
||||
): Promise<{
|
||||
credentials: CredentialRepository;
|
||||
hosts: HostRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<SqliteDatabaseAdapter["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
detected_key_type TEXT,
|
||||
cert_public_key TEXT,
|
||||
system_password TEXT,
|
||||
system_key TEXT,
|
||||
system_key_password TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
use_warpgate INTEGER NOT NULL DEFAULT 0,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
scp_legacy INTEGER NOT NULL DEFAULT 0,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
enable_tmux_monitor INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
enable_ssh INTEGER NOT NULL DEFAULT 1,
|
||||
enable_rdp INTEGER NOT NULL DEFAULT 0,
|
||||
enable_vnc INTEGER NOT NULL DEFAULT 0,
|
||||
enable_telnet INTEGER NOT NULL DEFAULT 0,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
rdp_port INTEGER DEFAULT 3389,
|
||||
vnc_port INTEGER DEFAULT 5900,
|
||||
telnet_port INTEGER DEFAULT 23,
|
||||
rdp_credential_id INTEGER,
|
||||
rdp_user TEXT,
|
||||
rdp_password TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_security TEXT,
|
||||
rdp_ignore_cert INTEGER DEFAULT 0,
|
||||
vnc_credential_id INTEGER,
|
||||
vnc_password TEXT,
|
||||
vnc_user TEXT,
|
||||
telnet_user TEXT,
|
||||
telnet_password TEXT,
|
||||
telnet_credential_id INTEGER,
|
||||
rdp_auth_type TEXT,
|
||||
vnc_auth_type TEXT,
|
||||
telnet_auth_type TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
mac_address TEXT,
|
||||
wol_broadcast_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_type TEXT,
|
||||
host_key_algorithm TEXT DEFAULT 'sha256',
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (override_credential_id) REFERENCES ssh_credentials(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('user-1', 'user', 'hash'),
|
||||
('user-2', 'other', 'hash');
|
||||
`);
|
||||
|
||||
return {
|
||||
credentials: new CredentialRepository(context, onCredentialWrite),
|
||||
hosts: new HostRepository(context, onHostWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
it("creates, finds, updates, lists, and deletes credentials", async () => {
|
||||
const repo = await createRepositories();
|
||||
|
||||
const created = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
folder: "prod",
|
||||
});
|
||||
|
||||
expect(created.id).toBeGreaterThan(0);
|
||||
expect(await repo.credentials.listFolders("user-1")).toEqual(["prod"]);
|
||||
expect(
|
||||
(await repo.credentials.findByIdForUser("user-1", created.id))?.name,
|
||||
).toBe("primary");
|
||||
expect((await repo.credentials.findById(created.id))?.name).toBe("primary");
|
||||
|
||||
const updated = await repo.credentials.updateForUser("user-1", created.id, {
|
||||
folder: "ops",
|
||||
tags: "linux,admin",
|
||||
});
|
||||
expect(updated?.folder).toBe("ops");
|
||||
|
||||
expect(
|
||||
await repo.credentials.findByIdForUser("user-2", created.id),
|
||||
).toBeNull();
|
||||
expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await repo.credentials.findByIdForUser("user-1", created.id),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes user credentials through the cleanup boundary", async () => {
|
||||
const onWrite = vi.fn();
|
||||
const repo = await createRepositories(onWrite);
|
||||
|
||||
await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
});
|
||||
await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "secondary",
|
||||
authType: "key",
|
||||
});
|
||||
await repo.credentials.create({
|
||||
userId: "user-2",
|
||||
name: "other",
|
||||
authType: "password",
|
||||
});
|
||||
onWrite.mockClear();
|
||||
|
||||
await expect(repo.credentials.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
|
||||
expect(await repo.credentials.listByUserId("user-1")).toEqual([]);
|
||||
expect((await repo.credentials.listByUserId("user-2")).length).toBe(1);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads credentials through the decryption boundary", async () => {
|
||||
const repo = await createRepositories();
|
||||
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
vi.spyOn(DataCrypto, "decryptRecords").mockImplementation(
|
||||
(_tableName, records) => records,
|
||||
);
|
||||
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
|
||||
(_tableName, record) => record,
|
||||
);
|
||||
|
||||
const created = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
folder: "prod",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.credentials.listDecryptedByUserId("user-1"),
|
||||
).resolves.toMatchObject([{ id: created.id, password: "secret" }]);
|
||||
await expect(
|
||||
repo.credentials.findDecryptedByIdForUser("user-1", created.id),
|
||||
).resolves.toMatchObject({ id: created.id, password: "secret" });
|
||||
expect(DataCrypto.decryptRecords).toHaveBeenCalledWith(
|
||||
"ssh_credentials",
|
||||
expect.arrayContaining([expect.objectContaining({ id: created.id })]),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_credentials",
|
||||
expect.objectContaining({ id: created.id }),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("encrypts credential writes with user and system keys", async () => {
|
||||
const repo = await createRepositories();
|
||||
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
vi.spyOn(DataCrypto, "encryptRecord").mockImplementation(
|
||||
(_tableName, record) =>
|
||||
({
|
||||
...record,
|
||||
password: "user-encrypted-password",
|
||||
}) as typeof record,
|
||||
);
|
||||
vi.spyOn(DataCrypto, "encryptRecordWithSystemKey").mockResolvedValue({
|
||||
systemPassword: "system-encrypted-password",
|
||||
});
|
||||
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
|
||||
(_tableName, record) => record,
|
||||
);
|
||||
vi.spyOn(
|
||||
SystemCrypto.getInstance(),
|
||||
"getCredentialSharingKey",
|
||||
).mockResolvedValue(Buffer.from("system-key"));
|
||||
|
||||
const created = await repo.credentials.createEncryptedForUser("user-1", {
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
const raw = repo.sqlite
|
||||
.prepare(
|
||||
"SELECT password, system_password FROM ssh_credentials WHERE id = ?",
|
||||
)
|
||||
.get(created.id) as { password: string; system_password: string };
|
||||
|
||||
expect(raw.password).toBe("user-encrypted-password");
|
||||
expect(raw.system_password).toBe("system-encrypted-password");
|
||||
|
||||
await repo.credentials.updateEncryptedForUser("user-1", created.id, {
|
||||
password: "updated-secret",
|
||||
});
|
||||
|
||||
const updatedRaw = repo.sqlite
|
||||
.prepare(
|
||||
"SELECT password, system_password FROM ssh_credentials WHERE id = ?",
|
||||
)
|
||||
.get(created.id) as { password: string; system_password: string };
|
||||
|
||||
expect(updatedRaw.password).toBe("user-encrypted-password");
|
||||
expect(updatedRaw.system_password).toBe("system-encrypted-password");
|
||||
expect(DataCrypto.encryptRecordWithSystemKey).toHaveBeenCalledWith(
|
||||
"ssh_credentials",
|
||||
expect.objectContaining({ password: "updated-secret" }),
|
||||
Buffer.from("system-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds and updates credential system encryption migration rows", async () => {
|
||||
const repo = await createRepositories();
|
||||
|
||||
const complete = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "complete",
|
||||
authType: "password",
|
||||
password: "encrypted-password",
|
||||
systemPassword: "system-password",
|
||||
systemKey: "system-key",
|
||||
systemKeyPassword: "system-key-password",
|
||||
});
|
||||
const missing = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "missing",
|
||||
authType: "key",
|
||||
key: "encrypted-key",
|
||||
systemPassword: null,
|
||||
systemKey: null,
|
||||
systemKeyPassword: null,
|
||||
});
|
||||
await repo.credentials.create({
|
||||
userId: "user-2",
|
||||
name: "other",
|
||||
authType: "password",
|
||||
systemPassword: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.credentials.listMissingSystemEncryptionByUserId("user-1"),
|
||||
).resolves.toMatchObject([{ id: missing.id }]);
|
||||
|
||||
await repo.credentials.updateSystemEncryptionForUser("user-1", missing.id, {
|
||||
systemPassword: "new-system-password",
|
||||
systemKey: "new-system-key",
|
||||
systemKeyPassword: "new-system-key-password",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.credentials.listMissingSystemEncryptionByUserId("user-1"),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
repo.credentials.findByIdForUser("user-1", complete.id),
|
||||
).resolves.toMatchObject({ systemPassword: "system-password" });
|
||||
});
|
||||
|
||||
it("checks credential import identity", async () => {
|
||||
const repo = await createRepositories();
|
||||
|
||||
await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
username: "root",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.credentials.existsForImportIdentity("user-1", "primary", "root"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.credentials.existsForImportIdentity("user-1", "primary", "admin"),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("renames credential folders through the write boundary", async () => {
|
||||
const onWrite = vi.fn();
|
||||
const repo = await createRepositories(onWrite);
|
||||
|
||||
await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
folder: "prod",
|
||||
});
|
||||
await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "secondary",
|
||||
authType: "key",
|
||||
folder: "prod",
|
||||
});
|
||||
await repo.credentials.create({
|
||||
userId: "user-2",
|
||||
name: "other",
|
||||
authType: "password",
|
||||
folder: "prod",
|
||||
});
|
||||
onWrite.mockClear();
|
||||
|
||||
await expect(
|
||||
repo.credentials.renameFolder("user-1", "prod", "ops"),
|
||||
).resolves.toBe(2);
|
||||
|
||||
expect(await repo.credentials.listFolders("user-1")).toEqual(["ops"]);
|
||||
expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns empty credential reads when user data is locked", async () => {
|
||||
const repo = await createRepositories();
|
||||
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(null);
|
||||
|
||||
const created = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.credentials.listDecryptedByUserId("user-1"),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
repo.credentials.findDecryptedByIdForUser("user-1", created.id),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("creates, finds, updates, lists, and deletes hosts", async () => {
|
||||
const repo = await createRepositories();
|
||||
|
||||
const host = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
|
||||
expect(host.id).toBeGreaterThan(0);
|
||||
expect((await repo.hosts.findById(host.id))?.name).toBe("web-1");
|
||||
expect(
|
||||
(await repo.hosts.listByUserId("user-1")).map((item) => item.id),
|
||||
).toEqual([host.id]);
|
||||
|
||||
const updated = await repo.hosts.updateForUser("user-1", host.id, {
|
||||
name: "web-1-renamed",
|
||||
folder: "prod",
|
||||
});
|
||||
expect(updated?.name).toBe("web-1-renamed");
|
||||
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
|
||||
|
||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
|
||||
expect(await repo.hosts.findById(host.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("encrypts host writes through the repository boundary", async () => {
|
||||
const repo = await createRepositories();
|
||||
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
vi.spyOn(DataCrypto, "encryptRecord").mockImplementation(
|
||||
(_tableName, record) =>
|
||||
({
|
||||
...record,
|
||||
password: "encrypted-host-password",
|
||||
}) as typeof record,
|
||||
);
|
||||
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
|
||||
(_tableName, record) => record,
|
||||
);
|
||||
|
||||
const created = await repo.hosts.createEncryptedForUser("user-1", {
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
const raw = repo.sqlite
|
||||
.prepare("SELECT password FROM ssh_data WHERE id = ?")
|
||||
.get(created.id) as { password: string };
|
||||
|
||||
expect(raw.password).toBe("encrypted-host-password");
|
||||
|
||||
await repo.hosts.updateEncryptedForUser("user-1", created.id, {
|
||||
password: "updated-secret",
|
||||
});
|
||||
|
||||
const updatedRaw = repo.sqlite
|
||||
.prepare("SELECT password FROM ssh_data WHERE id = ?")
|
||||
.get(created.id) as { password: string };
|
||||
|
||||
expect(updatedRaw.password).toBe("encrypted-host-password");
|
||||
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ password: "updated-secret" }),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads hosts through the decryption boundary", async () => {
|
||||
const repo = await createRepositories();
|
||||
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
vi.spyOn(DataCrypto, "decryptRecords").mockImplementation(
|
||||
(_tableName, records) => records,
|
||||
);
|
||||
|
||||
const host = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.hosts.listDecryptedByUserId("user-1"),
|
||||
).resolves.toMatchObject([{ id: host.id, password: "secret" }]);
|
||||
expect(DataCrypto.decryptRecords).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.arrayContaining([expect.objectContaining({ id: host.id })]),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("checks host import identity", async () => {
|
||||
const repo = await createRepositories();
|
||||
|
||||
await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 22, "root"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.hosts.existsForImportIdentity("user-1", "10.0.0.10", 2222, "root"),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("deletes user hosts through the cleanup boundary", async () => {
|
||||
const onWrite = vi.fn();
|
||||
const repo = await createRepositories(undefined, onWrite);
|
||||
|
||||
await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-2",
|
||||
ip: "10.0.0.11",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
await repo.hosts.create({
|
||||
userId: "user-2",
|
||||
name: "other",
|
||||
ip: "10.0.0.12",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
onWrite.mockClear();
|
||||
|
||||
await expect(repo.hosts.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
|
||||
expect(await repo.hosts.listByUserId("user-1")).toEqual([]);
|
||||
expect((await repo.hosts.listByUserId("user-2")).length).toBe(1);
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists bulk update state and updates multiple owned hosts", async () => {
|
||||
const onWrite = vi.fn();
|
||||
const repo = await createRepositories(undefined, onWrite);
|
||||
|
||||
const first = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
statsConfig: JSON.stringify({ cpu: true }),
|
||||
});
|
||||
const second = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-2",
|
||||
ip: "10.0.0.11",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
const other = await repo.hosts.create({
|
||||
userId: "user-2",
|
||||
name: "other",
|
||||
ip: "10.0.0.12",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
onWrite.mockClear();
|
||||
|
||||
const states = await repo.hosts.listBulkUpdateState("user-1", [
|
||||
first.id,
|
||||
second.id,
|
||||
other.id,
|
||||
]);
|
||||
expect(states.map((state) => state.id)).toEqual([first.id, second.id]);
|
||||
|
||||
await expect(
|
||||
repo.hosts.updateManyForUser("user-1", [first.id, second.id, other.id], {
|
||||
folder: "ops",
|
||||
}),
|
||||
).resolves.toBe(2);
|
||||
expect((await repo.hosts.findById(first.id))?.folder).toBe("ops");
|
||||
expect((await repo.hosts.findById(other.id))?.folder).toBeNull();
|
||||
expect(onWrite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records credential usage and increments usage counters", async () => {
|
||||
const repo = await createRepositories();
|
||||
const credential = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
});
|
||||
const host = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "web-1",
|
||||
ip: "10.0.0.10",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "credential",
|
||||
credentialId: credential.id,
|
||||
});
|
||||
|
||||
await repo.credentials.recordUsage(
|
||||
"user-1",
|
||||
credential.id,
|
||||
host.id,
|
||||
"2026-06-26T00:00:00.000Z",
|
||||
);
|
||||
|
||||
const updated = await repo.credentials.findByIdForUser(
|
||||
"user-1",
|
||||
credential.id,
|
||||
);
|
||||
expect(updated?.usageCount).toBe(1);
|
||||
expect(updated?.lastUsed).toBe("2026-06-26T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("cleans host access before deleting a host", async () => {
|
||||
const repo = await createRepositories();
|
||||
const host = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
name: "shared-host",
|
||||
ip: "10.0.0.20",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
|
||||
repo.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO host_access (host_id, user_id, granted_by) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(host.id, "user-2", "user-1");
|
||||
|
||||
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
|
||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { HostFolderRepository } from "./host-folder-repository.js";
|
||||
|
||||
describe("HostFolderRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: HostFolderRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<SqliteDatabaseAdapter["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
use_warpgate INTEGER NOT NULL DEFAULT 0,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
scp_legacy INTEGER NOT NULL DEFAULT 0,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
enable_tmux_monitor INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
enable_ssh INTEGER NOT NULL DEFAULT 1,
|
||||
enable_rdp INTEGER NOT NULL DEFAULT 0,
|
||||
enable_vnc INTEGER NOT NULL DEFAULT 0,
|
||||
enable_telnet INTEGER NOT NULL DEFAULT 0,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
rdp_port INTEGER DEFAULT 3389,
|
||||
vnc_port INTEGER DEFAULT 5900,
|
||||
telnet_port INTEGER DEFAULT 23,
|
||||
rdp_credential_id INTEGER,
|
||||
rdp_user TEXT,
|
||||
rdp_password TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_security TEXT,
|
||||
rdp_ignore_cert INTEGER DEFAULT 0,
|
||||
vnc_credential_id INTEGER,
|
||||
vnc_password TEXT,
|
||||
vnc_user TEXT,
|
||||
telnet_user TEXT,
|
||||
telnet_password TEXT,
|
||||
telnet_credential_id INTEGER,
|
||||
rdp_auth_type TEXT,
|
||||
vnc_auth_type TEXT,
|
||||
telnet_auth_type TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
mac_address TEXT,
|
||||
wol_broadcast_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_type TEXT,
|
||||
host_key_algorithm TEXT DEFAULT 'sha256',
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type)
|
||||
VALUES
|
||||
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'),
|
||||
(2, 'user-1', 'two', '10.0.0.2', 22, 'root', 'prod / api', 'password'),
|
||||
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'prod', 'password');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, folder, auth_type)
|
||||
VALUES
|
||||
(1, 'user-1', 'cred-one', 'prod', 'password'),
|
||||
(2, 'user-1', 'cred-two', 'prod / api', 'password'),
|
||||
(3, 'user-2', 'cred-other', 'prod', 'password');
|
||||
INSERT INTO ssh_folders (id, user_id, name, color, icon)
|
||||
VALUES
|
||||
(1, 'user-1', 'prod', '#111111', 'server'),
|
||||
(2, 'user-1', 'prod / api', '#222222', 'box'),
|
||||
(3, 'user-2', 'prod', '#333333', 'user');
|
||||
`);
|
||||
|
||||
return {
|
||||
repository: new HostFolderRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
it("renames folders across hosts, credentials, and folder records", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.renameFolder(
|
||||
"user-1",
|
||||
"prod",
|
||||
"ops",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toEqual({ updatedHosts: 2, updatedCredentials: 2 });
|
||||
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT folder FROM ssh_data WHERE user_id = ? ORDER BY id")
|
||||
.all("user-1"),
|
||||
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT folder FROM ssh_credentials WHERE user_id = ? ORDER BY id",
|
||||
)
|
||||
.all("user-1"),
|
||||
).toEqual([{ folder: "ops" }, { folder: "ops / api" }]);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare("SELECT name FROM ssh_folders WHERE user_id = ? ORDER BY id")
|
||||
.all("user-1"),
|
||||
).toEqual([{ name: "ops" }, { name: "ops / api" }]);
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("lists folders and upserts metadata", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(repository.listFolders("user-1")).resolves.toHaveLength(2);
|
||||
await expect(
|
||||
repository.upsertMetadata(
|
||||
"user-1",
|
||||
"prod",
|
||||
"#abcdef",
|
||||
"folder",
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
created: false,
|
||||
folder: { color: "#abcdef", icon: "folder" },
|
||||
});
|
||||
await expect(
|
||||
repository.upsertMetadata(
|
||||
"user-1",
|
||||
"new",
|
||||
null,
|
||||
null,
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
created: true,
|
||||
folder: { name: "new" },
|
||||
});
|
||||
expect(writes).toBe(2);
|
||||
});
|
||||
|
||||
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const hostsToDelete = await repository.listHostsInFolder("user-1", "prod");
|
||||
expect(hostsToDelete.map((host) => host.id)).toEqual([1, 2]);
|
||||
|
||||
await repository.deleteHostsAndFolderRecords("user-1", "prod");
|
||||
|
||||
expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual(
|
||||
[{ id: 3 }],
|
||||
);
|
||||
expect(
|
||||
sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(),
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes folder records for a user", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(repository.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
|
||||
expect(sqlite.prepare("SELECT id FROM ssh_data ORDER BY id").all()).toEqual(
|
||||
[{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
);
|
||||
expect(
|
||||
sqlite.prepare("SELECT id FROM ssh_folders ORDER BY id").all(),
|
||||
).toEqual([{ id: 3 }]);
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { and, eq, like, or, sql } from "drizzle-orm";
|
||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type HostFolderRecord = typeof sshFolders.$inferSelect;
|
||||
export type HostFolderHostRecord = typeof hosts.$inferSelect;
|
||||
|
||||
export interface RenameFolderResult {
|
||||
updatedHosts: number;
|
||||
updatedCredentials: number;
|
||||
}
|
||||
|
||||
export class HostFolderRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RenameFolderResult> {
|
||||
const oldPrefix = `${oldName} / `;
|
||||
const newPrefix = `${newName} / `;
|
||||
const childLike = `${oldPrefix}%`;
|
||||
const renameExpr = (col: SQLiteColumn) =>
|
||||
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`;
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, oldName), like(col, childLike));
|
||||
|
||||
const updatedHosts = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
const updatedCredentials = await this.context.drizzle
|
||||
.update(sshCredentials)
|
||||
.set({ folder: renameExpr(sshCredentials.folder), updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
folderMatch(sshCredentials.folder),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
|
||||
await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ name: renameExpr(sshFolders.name), updatedAt: now })
|
||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
|
||||
|
||||
await this.afterWrite();
|
||||
return {
|
||||
updatedHosts: updatedHosts.length,
|
||||
updatedCredentials: updatedCredentials.length,
|
||||
};
|
||||
}
|
||||
|
||||
async listFolders(userId: string): Promise<HostFolderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
}
|
||||
|
||||
async upsertMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
||||
const existing = await this.findFolder(userId, name);
|
||||
if (existing) {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ color, icon, updatedAt: now })
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return { folder: updated, created: false };
|
||||
}
|
||||
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sshFolders)
|
||||
.values({
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return { folder: created, created: true };
|
||||
}
|
||||
|
||||
async listHostsInFolder(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<HostFolderHostRecord[]> {
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
async deleteHostsAndFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<void> {
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
|
||||
const hostsToDelete = await this.listHostsInFolder(userId, folderName);
|
||||
if (hostsToDelete.length > 0) {
|
||||
await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId))
|
||||
.returning({ id: sshFolders.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async findFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<HostFolderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { HostHealthRepository } from "./host-health-repository.js";
|
||||
|
||||
describe("HostHealthRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostHealthRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE host_health_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
checks TEXT NOT NULL,
|
||||
interval_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE host_health_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
check_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
detail TEXT
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two');
|
||||
INSERT INTO host_health_checks (
|
||||
user_id, host_id, checks, interval_seconds, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'user-1',
|
||||
1,
|
||||
'[{"id":"tcp","name":"TCP","type":"tcp","target":"localhost","port":22}]',
|
||||
300,
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
|
||||
return new HostHealthRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds and upserts check configuration", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const existing = await repo.findChecksByUserAndHost("user-1", 1);
|
||||
expect(existing?.intervalSeconds).toBe(300);
|
||||
|
||||
const updated = await repo.upsertChecks(
|
||||
"user-1",
|
||||
1,
|
||||
'[{"id":"http"}]',
|
||||
60,
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: existing?.id,
|
||||
checks: '[{"id":"http"}]',
|
||||
intervalSeconds: 60,
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsertChecks(
|
||||
"user-2",
|
||||
2,
|
||||
'[{"id":"tcp"}]',
|
||||
120,
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
hostId: 2,
|
||||
checks: '[{"id":"tcp"}]',
|
||||
intervalSeconds: 120,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("records and prunes history", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
|
||||
1,
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
|
||||
1,
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
const history = await repo.listHistory("user-1", 1, 10);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
checkId: "two",
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
detail: "closed",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all health checks and history for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertChecks("user-2", 2, '[{"id":"tcp"}]', 120);
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
|
||||
10,
|
||||
);
|
||||
await repo.recordHistory(
|
||||
"user-2",
|
||||
2,
|
||||
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
|
||||
10,
|
||||
);
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
|
||||
checksDeleted: 1,
|
||||
historyDeleted: 1,
|
||||
});
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
|
||||
checksDeleted: 0,
|
||||
historyDeleted: 0,
|
||||
});
|
||||
|
||||
expect(await repo.findChecksByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect(await repo.listHistory("user-1", 1, 10)).toEqual([]);
|
||||
expect((await repo.findChecksByUserAndHost("user-2", 2))?.hostId).toBe(2);
|
||||
expect(await repo.listHistory("user-2", 2, 10)).toHaveLength(1);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.update(hostHealthChecks)
|
||||
.set({ checks, intervalSeconds, updatedAt: now })
|
||||
.where(eq(hostHealthChecks.id, existing.id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(hostHealthChecks)
|
||||
.values({
|
||||
userId,
|
||||
hostId,
|
||||
checks,
|
||||
intervalSeconds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
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,
|
||||
})),
|
||||
);
|
||||
|
||||
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 historyRows = await this.context.drizzle
|
||||
.delete(hostHealthHistory)
|
||||
.where(eq(hostHealthHistory.userId, userId))
|
||||
.returning({ id: hostHealthHistory.id });
|
||||
|
||||
const checkRows = await this.context.drizzle
|
||||
.delete(hostHealthChecks)
|
||||
.where(eq(hostHealthChecks.userId, userId))
|
||||
.returning({ id: hostHealthChecks.id });
|
||||
|
||||
if (historyRows.length > 0 || checkRows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return {
|
||||
checksDeleted: checkRows.length,
|
||||
historyDeleted: historyRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
private pruneHistory(userId: string, hostId: number, keep: number): void {
|
||||
this.context.sqlite
|
||||
?.prepare(
|
||||
`DELETE FROM host_health_history
|
||||
WHERE id IN (
|
||||
SELECT id FROM host_health_history
|
||||
WHERE user_id = ? AND host_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM host_health_history
|
||||
WHERE user_id = ? AND host_id = ?
|
||||
ORDER BY ts DESC LIMIT ?
|
||||
)
|
||||
)`,
|
||||
)
|
||||
.run(userId, hostId, userId, hostId, keep);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
|
||||
|
||||
describe("HostMetricsHistoryRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostMetricsHistoryRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE host_metrics_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_percent REAL,
|
||||
mem_percent REAL,
|
||||
disk_percent REAL,
|
||||
net_rx_bytes INTEGER,
|
||||
net_tx_bytes INTEGER
|
||||
);
|
||||
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-2', 'two');
|
||||
INSERT INTO host_metrics_history (
|
||||
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
|
||||
)
|
||||
VALUES
|
||||
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
|
||||
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
|
||||
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
|
||||
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
|
||||
`);
|
||||
|
||||
return new HostMetricsHistoryRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists metrics history rows by range", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
hostId: 1,
|
||||
cpuPercent: 12,
|
||||
memPercent: 22,
|
||||
diskPercent: 32,
|
||||
netRxBytes: 102,
|
||||
netTxBytes: 202,
|
||||
});
|
||||
|
||||
const rows = await repo.listRange(
|
||||
1,
|
||||
"2026-01-01 00:00:00",
|
||||
"2026-01-02 23:59:59",
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("prunes old history for a host only", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
repo.pruneOlderThan(1, 1);
|
||||
|
||||
const rows = await repo.listRange(
|
||||
1,
|
||||
"2000-01-01 00:00:00",
|
||||
"2999-12-31 23:59:59",
|
||||
);
|
||||
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
|
||||
expect(
|
||||
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, asc, eq, gte, lte } from "drizzle-orm";
|
||||
import { hostMetricsHistory } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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();
|
||||
}
|
||||
|
||||
pruneOlderThan(hostId: number, retentionDays: number): void {
|
||||
this.context.sqlite
|
||||
?.prepare(
|
||||
"DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)",
|
||||
)
|
||||
.run(hostId, `-${retentionDays} days`);
|
||||
}
|
||||
|
||||
async 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?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
|
||||
|
||||
describe("HostMetricsPreferenceRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostMetricsPreferenceRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
stats_config TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE host_metrics_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
layout TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, stats_config)
|
||||
VALUES (1, 'user-1', 'one', '{}'), (2, 'user-2', 'two', '{}');
|
||||
INSERT INTO host_metrics_preferences (
|
||||
user_id, host_id, layout, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'user-1',
|
||||
1,
|
||||
'{"slots":[],"columns":3}',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
|
||||
return new HostMetricsPreferenceRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds a saved layout by user and host", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const existing = await repo.findByUserAndHost("user-1", 1);
|
||||
expect(existing?.layout).toBe('{"slots":[],"columns":3}');
|
||||
expect(await repo.findByUserAndHost("user-1", 2)).toBeNull();
|
||||
});
|
||||
|
||||
it("updates and inserts layouts with write notifications", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.upsertLayout(
|
||||
"user-1",
|
||||
1,
|
||||
'{"slots":[{"id":"cpu"}],"columns":2}',
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: 1,
|
||||
layout: '{"slots":[{"id":"cpu"}],"columns":2}',
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsertLayout(
|
||||
"user-2",
|
||||
2,
|
||||
'{"slots":[{"id":"mem"}],"columns":1}',
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
hostId: 2,
|
||||
layout: '{"slots":[{"id":"mem"}],"columns":1}',
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("updates host stats config for the owning user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.updateHostStatsConfig(
|
||||
"user-1",
|
||||
1,
|
||||
'{"enabledWidgets":["cpu","memory"]}',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes all metric preferences for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}');
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe(
|
||||
'{"slots":["mem"]}',
|
||||
);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { hostMetricsPreferences, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.update(hostMetricsPreferences)
|
||||
.set({ layout, updatedAt: now })
|
||||
.where(eq(hostMetricsPreferences.id, existing.id))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(hostMetricsPreferences)
|
||||
.values({
|
||||
userId,
|
||||
hostId,
|
||||
layout,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateHostStatsConfig(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
statsConfig: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ statsConfig })
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
if (rows.length === 0) return false;
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostMetricsPreferences)
|
||||
.where(eq(hostMetricsPreferences.userId, userId))
|
||||
.returning({ id: hostMetricsPreferences.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
export type HostRecord = typeof hosts.$inferSelect;
|
||||
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 {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(host: NewHostRecord): Promise<HostRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(hosts)
|
||||
.values(host)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async createEncryptedForUser(
|
||||
userId: string,
|
||||
host: NewHostRecord | Record<string, unknown>,
|
||||
): Promise<HostRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = host.id ?? Date.now();
|
||||
const dataWithTempId = { ...host, id: tempId };
|
||||
const encryptedHost = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
dataWithTempId,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (!host.id) {
|
||||
delete (encryptedHost as Partial<NewHostRecord>).id;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.insert(hosts)
|
||||
.values(encryptedHost as NewHostRecord)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey);
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<HostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByUserId(userId: string): Promise<HostRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
}
|
||||
|
||||
async listDecryptedByUserId(userId: string): Promise<HostRecord[]> {
|
||||
const rows = await this.listByUserId(userId);
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return [];
|
||||
return DataCrypto.decryptRecords("ssh_data", rows, userId, userDataKey);
|
||||
}
|
||||
|
||||
async existsForImportIdentity(
|
||||
userId: string,
|
||||
ip: string,
|
||||
port: number,
|
||||
username: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, userId),
|
||||
eq(hosts.ip, ip),
|
||||
eq(hosts.port, port),
|
||||
eq(hosts.username, username),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
update: HostUpdate,
|
||||
): Promise<HostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(update)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateEncryptedForUser(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
update: HostUpdate,
|
||||
): Promise<HostRecord | null> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const encryptedUpdate = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
update,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(encryptedUpdate)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0]
|
||||
? DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
async listBulkUpdateState(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
): Promise<HostBulkUpdateState[]> {
|
||||
if (hostIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
async updateManyForUser(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
update: HostUpdate,
|
||||
): Promise<number> {
|
||||
if (hostIds.length === 0 || Object.keys(update).length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set(update)
|
||||
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, hostId: number): Promise<boolean> {
|
||||
await this.deleteAccessForHost(hostId);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(eq(hosts.userId, userId))
|
||||
.returning({ id: hosts.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteAccessForHost(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { HostResolutionRepository } from "./host-resolution-repository.js";
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: vi.fn(),
|
||||
decryptRecord: vi.fn((_tableName, record) => record),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("HostResolutionRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReset();
|
||||
vi.mocked(DataCrypto.decryptRecord).mockClear();
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostResolutionRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
use_warpgate INTEGER NOT NULL DEFAULT 0,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
scp_legacy INTEGER NOT NULL DEFAULT 0,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
enable_tmux_monitor INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
enable_proxmox INTEGER NOT NULL DEFAULT 0,
|
||||
proxmox_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
enable_ssh INTEGER NOT NULL DEFAULT 1,
|
||||
enable_rdp INTEGER NOT NULL DEFAULT 0,
|
||||
enable_vnc INTEGER NOT NULL DEFAULT 0,
|
||||
enable_telnet INTEGER NOT NULL DEFAULT 0,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
rdp_port INTEGER DEFAULT 3389,
|
||||
vnc_port INTEGER DEFAULT 5900,
|
||||
telnet_port INTEGER DEFAULT 23,
|
||||
rdp_credential_id INTEGER,
|
||||
rdp_user TEXT,
|
||||
rdp_password TEXT,
|
||||
rdp_domain TEXT,
|
||||
rdp_security TEXT,
|
||||
rdp_ignore_cert INTEGER DEFAULT 0,
|
||||
vnc_credential_id INTEGER,
|
||||
vnc_password TEXT,
|
||||
vnc_user TEXT,
|
||||
telnet_user TEXT,
|
||||
telnet_password TEXT,
|
||||
telnet_credential_id INTEGER,
|
||||
rdp_auth_type TEXT,
|
||||
vnc_auth_type TEXT,
|
||||
telnet_auth_type TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
mac_address TEXT,
|
||||
wol_broadcast_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_type TEXT,
|
||||
host_key_algorithm TEXT DEFAULT 'sha256',
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
detected_key_type TEXT,
|
||||
cert_public_key TEXT,
|
||||
system_password TEXT,
|
||||
system_key TEXT,
|
||||
system_key_password TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (
|
||||
id, user_id, name, ip, port, username, auth_type, credential_id,
|
||||
tunnel_connections
|
||||
)
|
||||
VALUES
|
||||
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'),
|
||||
(2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL),
|
||||
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]');
|
||||
INSERT INTO ssh_credentials (
|
||||
id, user_id, name, auth_type, username, password, private_key, key_password
|
||||
)
|
||||
VALUES
|
||||
(7, 'user-1', 'owner', 'password', 'root', 'secret', NULL, NULL),
|
||||
(8, 'user-2', 'override', 'key', 'alice', NULL, 'private', 'pass');
|
||||
INSERT INTO host_access (
|
||||
host_id, user_id, granted_by, permission_level, override_credential_id
|
||||
)
|
||||
VALUES (1, 'user-2', 'user-1', 'execute', 8);
|
||||
`);
|
||||
|
||||
return new HostResolutionRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("loads host and credential rows through the decryption boundary", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(repository.findHostById(1, "user-1")).resolves.toMatchObject({
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "web",
|
||||
credentialId: 7,
|
||||
});
|
||||
await expect(
|
||||
repository.findHostByIdForUser(1, "user-1"),
|
||||
).resolves.toMatchObject({
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
name: "web",
|
||||
credentialId: 7,
|
||||
});
|
||||
await expect(
|
||||
repository.findHostByIdForUser(3, "user-1"),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findCredentialByIdForUser(7, "user-1"),
|
||||
).resolves.toMatchObject({
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
});
|
||||
await expect(
|
||||
repository.findCredentialByIdForOwnerDecryptedAs(7, "user-1", "user-2"),
|
||||
).resolves.toMatchObject({
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
username: "root",
|
||||
password: "secret",
|
||||
});
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 1 }),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_credentials",
|
||||
expect.objectContaining({ id: 7 }),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_credentials",
|
||||
expect.objectContaining({ id: 7 }),
|
||||
"user-2",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists user-owned hosts through the decryption boundary", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.findHostsByUserId("user-1");
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([1, 2]);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 1 }),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 2 }),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists raw own and shared host rows for access list assembly", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.listHostRowsForAccessList("user-2", [
|
||||
{ hostId: 1, permissionLevel: "execute", expiresAt: null },
|
||||
{ hostId: 3, permissionLevel: "view", expiresAt: null },
|
||||
{ hostId: 999, permissionLevel: "view", expiresAt: null },
|
||||
]);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: 3,
|
||||
userId: "user-2",
|
||||
ownerId: "user-2",
|
||||
isShared: false,
|
||||
permissionLevel: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
ownerId: "user-1",
|
||||
isShared: true,
|
||||
permissionLevel: "execute",
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads host owner metadata without decrypting host data", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(repository.findHostOwnerId(1)).resolves.toBe("user-1");
|
||||
await expect(repository.findHostOwnerId(999)).resolves.toBeNull();
|
||||
await expect(repository.isHostOwnedByUser(1, "user-1")).resolves.toBe(true);
|
||||
await expect(repository.isHostOwnedByUser(1, "user-2")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads host update state without decrypting host data", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(repository.findHostUpdateState(1)).resolves.toEqual({
|
||||
userId: "user-1",
|
||||
credentialId: 7,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
authType: "password",
|
||||
});
|
||||
await expect(repository.findHostUpdateState(999)).resolves.toBeNull();
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists hosts using a credential through the decryption boundary", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.listHostsUsingCredentialForUser("user-1", 7);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([1]);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 1 }),
|
||||
"user-1",
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists all hosts through each owner decryption boundary", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
|
||||
Buffer.from(`${userId}-key`),
|
||||
);
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.listAllHosts();
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([1, 2, 3]);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 1 }),
|
||||
"user-1",
|
||||
Buffer.from("user-1-key"),
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 3 }),
|
||||
"user-2",
|
||||
Buffer.from("user-2-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists tunnel-enabled hosts with tunnel data through each owner decryption boundary", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
|
||||
Buffer.from(`${userId}-key`),
|
||||
);
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.listHostsWithTunnelConnections();
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([1, 3]);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 1 }),
|
||||
"user-1",
|
||||
Buffer.from("user-1-key"),
|
||||
);
|
||||
expect(DataCrypto.decryptRecord).toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 3 }),
|
||||
"user-2",
|
||||
Buffer.from("user-2-key"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips owner-scoped host list rows when that user's data is locked", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockImplementation((userId) =>
|
||||
userId === "user-1" ? Buffer.from("user-1-key") : null,
|
||||
);
|
||||
const repository = await createRepository();
|
||||
|
||||
const rows = await repository.listAllHosts();
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual([1, 2]);
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalledWith(
|
||||
"ssh_data",
|
||||
expect.objectContaining({ id: 3 }),
|
||||
expect.any(String),
|
||||
expect.any(Buffer),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads host key verification metadata without decrypting credentials", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
const row = await repository.findHostKeyVerificationData(1);
|
||||
|
||||
expect(row).toMatchObject({
|
||||
hostKeyFingerprint: null,
|
||||
hostKeyType: null,
|
||||
hostKeyAlgorithm: "sha256",
|
||||
hostKeyChangedCount: 0,
|
||||
name: "web",
|
||||
});
|
||||
expect(DataCrypto.decryptRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores and updates host key verification metadata through the write boundary", async () => {
|
||||
const onWrite = vi.fn();
|
||||
const repository = await createRepository(onWrite);
|
||||
|
||||
await repository.storeHostKey(
|
||||
1,
|
||||
"fingerprint-1",
|
||||
"ssh-rsa",
|
||||
"sha256",
|
||||
"t1",
|
||||
);
|
||||
await expect(
|
||||
repository.findHostKeyVerificationData(1),
|
||||
).resolves.toMatchObject({
|
||||
hostKeyFingerprint: "fingerprint-1",
|
||||
hostKeyType: "ssh-rsa",
|
||||
hostKeyAlgorithm: "sha256",
|
||||
hostKeyChangedCount: 0,
|
||||
});
|
||||
|
||||
await repository.touchHostKeyLastVerified(1, "t2");
|
||||
await repository.updateHostKey(
|
||||
1,
|
||||
"fingerprint-2",
|
||||
"ssh-ed25519",
|
||||
"sha256",
|
||||
0,
|
||||
"t3",
|
||||
);
|
||||
|
||||
await expect(
|
||||
repository.findHostKeyVerificationData(1),
|
||||
).resolves.toMatchObject({
|
||||
hostKeyFingerprint: "fingerprint-2",
|
||||
hostKeyType: "ssh-ed25519",
|
||||
hostKeyAlgorithm: "sha256",
|
||||
hostKeyChangedCount: 1,
|
||||
});
|
||||
expect(onWrite).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("returns null when user data is locked", async () => {
|
||||
vi.mocked(DataCrypto.getUserDataKey).mockReturnValue(null);
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(repository.findHostById(1, "user-1")).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.findHostByIdForUser(1, "user-1"),
|
||||
).resolves.toBeNull();
|
||||
await expect(repository.findHostsByUserId("user-1")).resolves.toEqual([]);
|
||||
await expect(
|
||||
repository.listHostsUsingCredentialForUser("user-1", 7),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
repository.findCredentialByIdForUser(7, "user-1"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("loads override credential ids for shared host resolution", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await expect(
|
||||
repository.findOverrideCredentialId(1, "user-2"),
|
||||
).resolves.toBe(8);
|
||||
await expect(
|
||||
repository.findOverrideCredentialId(1, "user-1"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
||||
import { hostAccess, hosts, sshCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
|
||||
export type HostResolutionHostRecord = typeof hosts.$inferSelect;
|
||||
export type HostResolutionCredentialRecord = typeof sshCredentials.$inferSelect;
|
||||
export interface HostKeyVerificationRecord {
|
||||
hostKeyFingerprint: string | null;
|
||||
hostKeyType: string | null;
|
||||
hostKeyAlgorithm: string | null;
|
||||
hostKeyChangedCount: number | null;
|
||||
name: string | null;
|
||||
}
|
||||
export interface HostUpdateStateRecord {
|
||||
userId: string;
|
||||
credentialId: number | null;
|
||||
rdpCredentialId: number | null;
|
||||
vncCredentialId: number | null;
|
||||
telnetCredentialId: number | null;
|
||||
authType: string;
|
||||
}
|
||||
export interface HostListAccessEntry {
|
||||
hostId: number;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
export type HostListRow = HostResolutionHostRecord & {
|
||||
ownerId: string;
|
||||
isShared: boolean;
|
||||
permissionLevel?: string;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
export class HostResolutionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async findHostById(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<HostResolutionHostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_data", rows[0], userId);
|
||||
}
|
||||
|
||||
async findHostByIdForUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<HostResolutionHostRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_data", rows[0], userId);
|
||||
}
|
||||
|
||||
async findHostUpdateState(
|
||||
hostId: number,
|
||||
): Promise<HostUpdateStateRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
userId: hosts.userId,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
authType: hosts.authType,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findHostsByUserId(userId: string): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
return this.decryptMany("ssh_data", rows, userId);
|
||||
}
|
||||
|
||||
async listHostRowsForAccessList(
|
||||
userId: string,
|
||||
accessEntries: HostListAccessEntry[],
|
||||
): Promise<HostListRow[]> {
|
||||
const ownHostRows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
const sharedHostIds = Array.from(
|
||||
new Set(accessEntries.map((access) => access.hostId)),
|
||||
);
|
||||
const sharedHostRows =
|
||||
sharedHostIds.length > 0
|
||||
? await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(inArray(hosts.id, sharedHostIds))
|
||||
: [];
|
||||
const sharedHostsById = new Map(
|
||||
sharedHostRows.map((host) => [host.id, host]),
|
||||
);
|
||||
|
||||
return [
|
||||
...ownHostRows.map((host) => ({
|
||||
...host,
|
||||
ownerId: host.userId,
|
||||
isShared: false,
|
||||
permissionLevel: undefined,
|
||||
expiresAt: undefined,
|
||||
})),
|
||||
...accessEntries.flatMap((access) => {
|
||||
const host = sharedHostsById.get(access.hostId);
|
||||
if (!host || host.userId === userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...host,
|
||||
ownerId: host.userId,
|
||||
isShared: host.userId !== userId,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
},
|
||||
];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async findHostOwnerId(hostId: number): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ ownerId: hosts.userId })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.ownerId ?? null;
|
||||
}
|
||||
|
||||
async isHostOwnedByUser(hostId: number, userId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle.select().from(hosts);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
}
|
||||
|
||||
async listHostsWithTunnelConnections(): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
|
||||
);
|
||||
|
||||
return this.decryptManyByOwner("ssh_data", rows);
|
||||
}
|
||||
|
||||
async listHostsUsingCredentialForUser(
|
||||
userId: string,
|
||||
credentialId: number,
|
||||
): Promise<HostResolutionHostRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.credentialId, credentialId), eq(hosts.userId, userId)),
|
||||
);
|
||||
|
||||
return this.decryptMany("ssh_data", rows, userId);
|
||||
}
|
||||
|
||||
async findHostKeyVerificationData(
|
||||
hostId: number,
|
||||
): Promise<HostKeyVerificationRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
hostKeyFingerprint: hosts.hostKeyFingerprint,
|
||||
hostKeyType: hosts.hostKeyType,
|
||||
hostKeyAlgorithm: hosts.hostKeyAlgorithm,
|
||||
hostKeyChangedCount: hosts.hostKeyChangedCount,
|
||||
name: hosts.name,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async storeHostKey(
|
||||
hostId: number,
|
||||
fingerprint: string,
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyFirstSeen: now,
|
||||
hostKeyLastVerified: now,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async updateHostKey(
|
||||
hostId: number,
|
||||
fingerprint: string,
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
currentChangeCount: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyLastVerified: now,
|
||||
hostKeyChangedCount: currentChangeCount + 1,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async touchHostKeyLastVerified(
|
||||
hostId: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ hostKeyLastVerified: now })
|
||||
.where(eq(hosts.id, hostId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async findCredentialByIdForUser(
|
||||
credentialId: number,
|
||||
userId: string,
|
||||
): Promise<HostResolutionCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_credentials", rows[0], userId);
|
||||
}
|
||||
|
||||
async findCredentialByIdForOwnerDecryptedAs(
|
||||
credentialId: number,
|
||||
ownerUserId: string,
|
||||
decryptUserId: string,
|
||||
): Promise<HostResolutionCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, ownerUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return this.decryptOne("ssh_credentials", rows[0], decryptUserId);
|
||||
}
|
||||
|
||||
async findOverrideCredentialId(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ overrideCredentialId: hostAccess.overrideCredentialId })
|
||||
.from(hostAccess)
|
||||
.where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.overrideCredentialId ?? null;
|
||||
}
|
||||
|
||||
private decryptOne<T extends Record<string, unknown>>(
|
||||
tableName: "ssh_data" | "ssh_credentials",
|
||||
record: T | undefined,
|
||||
userId: string,
|
||||
): T | null {
|
||||
if (!record) return null;
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return null;
|
||||
return DataCrypto.decryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
private decryptMany<T extends Record<string, unknown>>(
|
||||
tableName: "ssh_data" | "ssh_credentials",
|
||||
records: T[],
|
||||
userId: string,
|
||||
): T[] {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return [];
|
||||
return records.map((record) =>
|
||||
DataCrypto.decryptRecord(tableName, record, userId, userDataKey),
|
||||
);
|
||||
}
|
||||
|
||||
private decryptManyByOwner<
|
||||
T extends Record<string, unknown> & { userId: string },
|
||||
>(tableName: "ssh_data" | "ssh_credentials", records: T[]): T[] {
|
||||
return records.flatMap((record) => {
|
||||
const userDataKey = DataCrypto.getUserDataKey(record.userId);
|
||||
if (!userDataKey) return [];
|
||||
return [
|
||||
DataCrypto.decryptRecord(tableName, record, record.userId, userDataKey),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { NetworkTopologyRepository } from "./network-topology-repository.js";
|
||||
|
||||
describe("NetworkTopologyRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<NetworkTopologyRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE network_topology (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
topology TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new NetworkTopologyRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds, creates, and updates topology by user id", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
|
||||
await repo.upsertForUser(
|
||||
"user-1",
|
||||
JSON.stringify({ nodes: [{ id: "host-1" }], edges: [] }),
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
expect(await repo.findByUserId("user-1")).toMatchObject({
|
||||
userId: "user-1",
|
||||
topology: '{"nodes":[{"id":"host-1"}],"edges":[]}',
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await repo.upsertForUser(
|
||||
"user-1",
|
||||
JSON.stringify({ nodes: [], edges: [{ id: "edge-1" }] }),
|
||||
"2026-06-27T01:00:00.000Z",
|
||||
);
|
||||
expect(await repo.findByUserId("user-1")).toMatchObject({
|
||||
userId: "user-1",
|
||||
topology: '{"nodes":[],"edges":[{"id":"edge-1"}]}',
|
||||
updatedAt: "2026-06-27T01:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes topology and only triggers writes for changed rows", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertForUser("user-1", "{}");
|
||||
await repo.upsertForUser("user-1", '{"nodes":[]}');
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteByUserId("missing")).toBe(0);
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(writeCount).toBe(3);
|
||||
expect(await repo.findByUserId("user-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { networkTopology } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 rows = await this.context.drizzle
|
||||
.delete(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId))
|
||||
.returning({ id: networkTopology.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
|
||||
describe("OpenTabRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<OpenTabRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE user_open_tabs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
tab_type TEXT NOT NULL,
|
||||
host_id INTEGER,
|
||||
label TEXT NOT NULL,
|
||||
tab_order INTEGER NOT NULL DEFAULT 0,
|
||||
backend_session_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new OpenTabRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("lists recent tabs ordered by tab order", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.upsertForUser(
|
||||
"user-1",
|
||||
{
|
||||
id: "tab-old",
|
||||
tabType: "terminal",
|
||||
label: "Old",
|
||||
tabOrder: 1,
|
||||
},
|
||||
"2026-06-27T00:00:00.000Z",
|
||||
);
|
||||
await repo.upsertForUser(
|
||||
"user-1",
|
||||
{
|
||||
id: "tab-new",
|
||||
tabType: "stats",
|
||||
label: "New",
|
||||
tabOrder: 0,
|
||||
},
|
||||
"2026-06-27T01:00:00.000Z",
|
||||
);
|
||||
await repo.upsertForUser(
|
||||
"user-2",
|
||||
{
|
||||
id: "other",
|
||||
tabType: "terminal",
|
||||
label: "Other",
|
||||
tabOrder: 0,
|
||||
},
|
||||
"2026-06-27T02:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(
|
||||
(await repo.listRecentForUser("user-1", "2026-06-27T00:30:00.000Z")).map(
|
||||
(tab) => tab.id,
|
||||
),
|
||||
).toEqual(["tab-new"]);
|
||||
});
|
||||
|
||||
it("upserts tabs and preserves backend session when omitted", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.upsertForUser("user-1", {
|
||||
id: "tab-1",
|
||||
tabType: "terminal",
|
||||
hostId: 1,
|
||||
label: "Server",
|
||||
tabOrder: 0,
|
||||
backendSessionId: "session-1",
|
||||
});
|
||||
await repo.upsertForUser("user-1", {
|
||||
id: "tab-1",
|
||||
tabType: "terminal",
|
||||
hostId: 2,
|
||||
label: "Server renamed",
|
||||
tabOrder: 1,
|
||||
});
|
||||
|
||||
expect(
|
||||
(await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z"))[0],
|
||||
).toMatchObject({
|
||||
id: "tab-1",
|
||||
hostId: 2,
|
||||
label: "Server renamed",
|
||||
tabOrder: 1,
|
||||
backendSessionId: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces, updates, and deletes tabs for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.replaceForUser("user-1", [
|
||||
{ id: "tab-1", tabType: "terminal", label: "One", tabOrder: 1 },
|
||||
{ id: "tab-2", tabType: "settings", label: "Two", tabOrder: 0 },
|
||||
]);
|
||||
expect(
|
||||
(await repo.listRecentForUser("user-1", "2000-01-01T00:00:00.000Z")).map(
|
||||
(tab) => tab.id,
|
||||
),
|
||||
).toEqual(["tab-2", "tab-1"]);
|
||||
|
||||
expect(
|
||||
await repo.updateForUser("user-1", "missing", { label: "Missing" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await repo.updateForUser("user-1", "tab-1", { label: "Renamed" }),
|
||||
).toBe(true);
|
||||
expect(await repo.deleteForUser("user-1", "tab-2")).toBe(1);
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(1);
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(0);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { userOpenTabs } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type OpenTabRecord = typeof userOpenTabs.$inferSelect;
|
||||
export type NewOpenTabRecord = typeof userOpenTabs.$inferInsert;
|
||||
export type OpenTabUpdate = Partial<
|
||||
Pick<NewOpenTabRecord, "label" | "tabOrder" | "backendSessionId">
|
||||
>;
|
||||
|
||||
export type OpenTabUpsertInput = Pick<
|
||||
NewOpenTabRecord,
|
||||
"id" | "tabType" | "label" | "tabOrder"
|
||||
> & {
|
||||
hostId?: number | null;
|
||||
backendSessionId?: string | null;
|
||||
};
|
||||
|
||||
export class OpenTabRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRecentForUser(
|
||||
userId: string,
|
||||
updatedAfter: string,
|
||||
): Promise<OpenTabRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOpenTabs.userId, userId),
|
||||
gt(userOpenTabs.updatedAt, updatedAfter),
|
||||
),
|
||||
)
|
||||
.orderBy(userOpenTabs.tabOrder);
|
||||
}
|
||||
|
||||
async upsertForUser(
|
||||
userId: string,
|
||||
input: OpenTabUpsertInput,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const existing = await this.findByIdForUser(userId, input.id);
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(userOpenTabs)
|
||||
.set({
|
||||
tabType: input.tabType,
|
||||
hostId: input.hostId ?? null,
|
||||
label: input.label,
|
||||
tabOrder: input.tabOrder,
|
||||
backendSessionId:
|
||||
input.backendSessionId !== undefined
|
||||
? input.backendSessionId
|
||||
: existing.backendSessionId,
|
||||
updatedAt,
|
||||
})
|
||||
.where(
|
||||
and(eq(userOpenTabs.id, input.id), eq(userOpenTabs.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(userOpenTabs).values({
|
||||
id: input.id,
|
||||
userId,
|
||||
tabType: input.tabType,
|
||||
hostId: input.hostId ?? null,
|
||||
label: input.label,
|
||||
tabOrder: input.tabOrder,
|
||||
backendSessionId: input.backendSessionId ?? null,
|
||||
updatedAt,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async replaceForUser(
|
||||
userId: string,
|
||||
tabs: OpenTabUpsertInput[],
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(userOpenTabs)
|
||||
.where(eq(userOpenTabs.userId, userId));
|
||||
|
||||
if (tabs.length > 0) {
|
||||
await this.context.drizzle.insert(userOpenTabs).values(
|
||||
tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
userId,
|
||||
tabType: tab.tabType,
|
||||
hostId: tab.hostId ?? null,
|
||||
label: tab.label,
|
||||
tabOrder: tab.tabOrder,
|
||||
backendSessionId: tab.backendSessionId ?? null,
|
||||
updatedAt,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: string,
|
||||
update: OpenTabUpdate,
|
||||
updatedAt = new Date().toISOString(),
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(userOpenTabs)
|
||||
.set({ ...update, updatedAt })
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.returning({ id: userOpenTabs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.returning({ id: userOpenTabs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userOpenTabs)
|
||||
.where(eq(userOpenTabs.userId, userId))
|
||||
.returning({ id: userOpenTabs.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async findByIdForUser(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<OpenTabRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
|
||||
describe("OpksshTokenRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<OpksshTokenRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE opkssh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
email TEXT,
|
||||
sub TEXT,
|
||||
issuer TEXT,
|
||||
audience TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, host_id)
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
INSERT INTO opkssh_tokens (
|
||||
user_id, host_id, ssh_cert, private_key, email, expires_at
|
||||
)
|
||||
VALUES
|
||||
('user-1', 1, 'cert-1', 'key-1', 'alice@example.com', '2099-01-01T00:00:00.000Z'),
|
||||
('user-1', 2, 'cert-2', 'key-2', 'alice2@example.com', '2099-01-01T00:00:00.000Z'),
|
||||
('user-2', 3, 'cert-3', 'key-3', 'bob@example.com', '2099-01-01T00:00:00.000Z');
|
||||
`);
|
||||
|
||||
return new OpksshTokenRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds and upserts a token by user and host", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const existing = await repo.findByUserAndHost("user-1", 1);
|
||||
expect(existing?.sshCert).toBe("cert-1");
|
||||
|
||||
await repo.upsert({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
email: "new@example.com",
|
||||
sub: "sub",
|
||||
issuer: "issuer",
|
||||
audience: "aud",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const updated = await repo.findByUserAndHost("user-1", 1);
|
||||
expect(updated).toMatchObject({
|
||||
sshCert: "new-cert",
|
||||
privateKey: "new-key",
|
||||
email: "new@example.com",
|
||||
sub: "sub",
|
||||
issuer: "issuer",
|
||||
audience: "aud",
|
||||
expiresAt: "2099-02-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("updates last-used and deletes one token", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.updateLastUsed("user-1", 1, "2026-01-02T00:00:00.000Z"),
|
||||
).toBe(true);
|
||||
expect(await repo.updateLastUsed("missing", 1)).toBe(false);
|
||||
expect((await repo.findByUserAndHost("user-1", 1))?.lastUsed).toBe(
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(true);
|
||||
expect(await repo.deleteByUserAndHost("user-1", 1)).toBe(false);
|
||||
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes tokens by user id", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(2);
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(0);
|
||||
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect(await repo.findByUserAndHost("user-2", 3)).not.toBeNull();
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { opksshTokens } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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 this.context.drizzle
|
||||
.insert(opksshTokens)
|
||||
.values({
|
||||
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,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
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 rows = await this.context.drizzle
|
||||
.update(opksshTokens)
|
||||
.set({ lastUsed })
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.returning({ id: opksshTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserAndHost(userId: string, hostId: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.returning({ id: opksshTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(opksshTokens)
|
||||
.where(eq(opksshTokens.userId, userId))
|
||||
.returning({ id: opksshTokens.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||
|
||||
describe("RbacAccessRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
const activeAccessTime = "2026-06-26T12:00:00.000Z";
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<RbacAccessRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
override_credential_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE shared_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
needs_re_encryption INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
credential_id INTEGER,
|
||||
rdp_credential_id INTEGER,
|
||||
vnc_credential_id INTEGER,
|
||||
telnet_credential_id INTEGER,
|
||||
folder TEXT,
|
||||
tags TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
host_filter TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE snippet_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
||||
VALUES
|
||||
('admin', 'admin', 'hash', 1, 0),
|
||||
('user-1', 'alice', 'hash', 0, 0),
|
||||
('owner-1', 'owner', 'hash', 0, 0);
|
||||
|
||||
INSERT INTO roles (id, name, display_name, is_system)
|
||||
VALUES (7, 'ops', 'Operations', 0);
|
||||
|
||||
INSERT INTO ssh_data (
|
||||
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags
|
||||
)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux');
|
||||
|
||||
INSERT INTO host_access (
|
||||
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
||||
)
|
||||
VALUES
|
||||
(1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
|
||||
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
|
||||
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
|
||||
|
||||
INSERT INTO shared_credentials (
|
||||
id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type, needs_re_encryption
|
||||
)
|
||||
VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth', 0);
|
||||
|
||||
INSERT INTO snippets (id, user_id, name, content)
|
||||
VALUES (99, 'owner-1', 'deploy', 'echo deploy');
|
||||
|
||||
INSERT INTO snippet_access (
|
||||
id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
||||
)
|
||||
VALUES
|
||||
(3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
|
||||
(4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z');
|
||||
`);
|
||||
|
||||
return new RbacAccessRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("lists host access with user and role target metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const accessList = await repo.listHostAccess(42);
|
||||
|
||||
expect(accessList).toMatchObject([
|
||||
{
|
||||
id: 2,
|
||||
targetType: "role",
|
||||
userId: null,
|
||||
roleId: 7,
|
||||
username: null,
|
||||
roleName: "ops",
|
||||
roleDisplayName: "Operations",
|
||||
grantedByUsername: "admin",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
targetType: "user",
|
||||
userId: "user-1",
|
||||
roleId: null,
|
||||
username: "alice",
|
||||
roleName: null,
|
||||
roleDisplayName: null,
|
||||
grantedByUsername: "admin",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists snippet access with user and role target metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const accessList = await repo.listSnippetAccess(99);
|
||||
|
||||
expect(accessList.map((access) => access.targetType)).toEqual([
|
||||
"role",
|
||||
"user",
|
||||
]);
|
||||
expect(accessList[0]).toMatchObject({
|
||||
id: 4,
|
||||
roleId: 7,
|
||||
roleName: "ops",
|
||||
grantedByUsername: "admin",
|
||||
});
|
||||
expect(accessList[1]).toMatchObject({
|
||||
id: 3,
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
grantedByUsername: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists shared hosts for direct and role access", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const sharedHosts = await repo.listSharedHosts(
|
||||
"user-1",
|
||||
[7],
|
||||
activeAccessTime,
|
||||
);
|
||||
|
||||
expect(sharedHosts).toMatchObject([
|
||||
{
|
||||
id: 42,
|
||||
name: "prod",
|
||||
ip: "10.0.0.42",
|
||||
ownerUsername: "owner",
|
||||
permissionLevel: "view",
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
name: "prod",
|
||||
ip: "10.0.0.42",
|
||||
ownerUsername: "owner",
|
||||
permissionLevel: "view",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists visible host access entries for host list access checks", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await expect(
|
||||
repo.listVisibleHostAccessEntries("user-1", [7], activeAccessTime),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
hostId: 42,
|
||||
permissionLevel: "view",
|
||||
expiresAt: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
hostId: 42,
|
||||
permissionLevel: "view",
|
||||
expiresAt: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists role host access credential sources for role assignment", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await expect(repo.listRoleHostAccessCredentialSources(7)).resolves.toEqual([
|
||||
{
|
||||
hostAccessId: 2,
|
||||
credentialId: 123,
|
||||
rdpCredentialId: 124,
|
||||
vncCredentialId: 125,
|
||||
telnetCredentialId: 126,
|
||||
hostId: 42,
|
||||
hostOwnerId: "owner-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds shared credential and host access owner for shared credential reads", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await expect(
|
||||
repo.findSharedCredentialForHostAndUser(42, "user-1"),
|
||||
).resolves.toMatchObject({
|
||||
id: 8,
|
||||
hostAccessId: 2,
|
||||
originalCredentialId: 123,
|
||||
targetUserId: "user-1",
|
||||
encryptedUsername: "enc-user",
|
||||
encryptedAuthType: "enc-auth",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.findSharedCredentialForHostAndUser(99, "user-1"),
|
||||
).resolves.toBeNull();
|
||||
await expect(repo.findHostAccessOwnerId(2)).resolves.toBe("owner-1");
|
||||
await expect(repo.findHostAccessOwnerId(999)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const sharedSnippets = await repo.listSharedSnippets(
|
||||
"user-1",
|
||||
[7],
|
||||
activeAccessTime,
|
||||
);
|
||||
|
||||
expect(sharedSnippets).toHaveLength(1);
|
||||
expect(sharedSnippets[0]).toMatchObject({
|
||||
id: 99,
|
||||
name: "deploy",
|
||||
ownerUsername: "owner",
|
||||
permissionLevel: "view",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists visible shared snippets for the main snippets route", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const sharedSnippets = await repo.listVisibleSharedSnippets(
|
||||
"user-1",
|
||||
[7],
|
||||
activeAccessTime,
|
||||
);
|
||||
|
||||
expect(sharedSnippets.map((snippet) => snippet.id)).toEqual([99, 99]);
|
||||
expect(sharedSnippets[0]).toMatchObject({
|
||||
userId: "owner-1",
|
||||
name: "deploy",
|
||||
content: "echo deploy",
|
||||
ownerUsername: "owner",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds an accessible shared snippet for direct or role access", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await expect(
|
||||
repo.findAccessibleSharedSnippet(99, "user-2", [7], activeAccessTime),
|
||||
).resolves.toMatchObject({
|
||||
id: 99,
|
||||
userId: "owner-1",
|
||||
name: "deploy",
|
||||
content: "echo deploy",
|
||||
ownerUsername: "owner",
|
||||
permissionLevel: "view",
|
||||
hostFilter: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.findAccessibleSharedSnippet(
|
||||
99,
|
||||
"user-2",
|
||||
[7],
|
||||
"2026-06-28T00:00:00.000Z",
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("upserts host access and updates overrides", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.upsertHostAccess({
|
||||
hostId: 42,
|
||||
targetType: "user",
|
||||
targetUserId: "user-1",
|
||||
grantedBy: "admin",
|
||||
permissionLevel: "view",
|
||||
expiresAt: "2026-06-28T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(updated).toEqual({ id: 1, created: false });
|
||||
expect(
|
||||
(await repo.listHostAccess(42)).find((row) => row.id === 1),
|
||||
).toMatchObject({
|
||||
expiresAt: "2026-06-28T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsertHostAccess({
|
||||
hostId: 43,
|
||||
targetType: "role",
|
||||
targetRoleId: 7,
|
||||
grantedBy: "admin",
|
||||
permissionLevel: "view",
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(created.created).toBe(true);
|
||||
|
||||
const directAccess = await repo.findDirectHostAccess(42, "user-1");
|
||||
expect(directAccess?.id).toBe(1);
|
||||
|
||||
await repo.updateHostAccessOverrideCredential(1, 123);
|
||||
expect(
|
||||
(await repo.findDirectHostAccess(42, "user-1"))?.overrideCredentialId,
|
||||
).toBe(123);
|
||||
|
||||
await repo.touchHostAccess(1, "2026-06-26T03:00:00.000Z");
|
||||
expect(
|
||||
(await repo.findDirectHostAccess(42, "user-1"))?.lastAccessedAt,
|
||||
).toBe("2026-06-26T03:00:00.000Z");
|
||||
|
||||
await repo.revokeHostAccess(1, 42);
|
||||
expect(await repo.findDirectHostAccess(42, "user-1")).toBeNull();
|
||||
expect(writeCount).toBe(5);
|
||||
});
|
||||
|
||||
it("finds active host access and deletes expired host access", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.findActiveHostAccess(
|
||||
42,
|
||||
"user-1",
|
||||
[7],
|
||||
"2026-06-26T12:00:00.000Z",
|
||||
),
|
||||
).toMatchObject({ id: 1 });
|
||||
expect(
|
||||
await repo.findActiveHostAccess(
|
||||
44,
|
||||
"user-1",
|
||||
[],
|
||||
"2026-06-26T12:00:00.000Z",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
expect(await repo.deleteExpiredHostAccess("2026-06-26T12:00:00.000Z")).toBe(
|
||||
1,
|
||||
);
|
||||
expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull();
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes host access for a host and only saves when rows changed", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.deleteHostAccessForHost(42)).toBe(2);
|
||||
expect(await repo.listHostAccess(42)).toEqual([]);
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.deleteHostAccessForHost(42)).toBe(0);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes host access for multiple hosts", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.deleteHostAccessForHosts([])).toBe(0);
|
||||
expect(writeCount).toBe(0);
|
||||
|
||||
expect(await repo.deleteHostAccessForHosts([42, 44])).toBe(3);
|
||||
expect(await repo.listHostAccess(42)).toEqual([]);
|
||||
expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull();
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes host access that references a user directly or as grantor", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(3);
|
||||
expect(await repo.listHostAccess(42)).toEqual([]);
|
||||
expect(await repo.findDirectHostAccess(44, "user-1")).toBeNull();
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("upserts and revokes snippet access", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const updated = await repo.upsertSnippetAccess({
|
||||
snippetId: 99,
|
||||
targetType: "user",
|
||||
targetUserId: "user-1",
|
||||
grantedBy: "admin",
|
||||
expiresAt: "2026-06-28T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(updated).toEqual({ id: 3, created: false });
|
||||
expect(
|
||||
(await repo.listSnippetAccess(99)).find((row) => row.id === 3),
|
||||
).toMatchObject({ expiresAt: "2026-06-28T00:00:00.000Z" });
|
||||
|
||||
const created = await repo.upsertSnippetAccess({
|
||||
snippetId: 100,
|
||||
targetType: "role",
|
||||
targetRoleId: 7,
|
||||
grantedBy: "admin",
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(created.created).toBe(true);
|
||||
|
||||
await repo.revokeSnippetAccess(3, 99);
|
||||
expect((await repo.listSnippetAccess(99)).map((row) => row.id)).toEqual([
|
||||
4,
|
||||
]);
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,677 @@
|
||||
import { and, desc, eq, gte, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
hostAccess,
|
||||
hosts,
|
||||
roles,
|
||||
sharedCredentials,
|
||||
snippetAccess,
|
||||
snippets,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type RbacAccessTargetType = "user" | "role";
|
||||
|
||||
export interface RbacAccessListItem {
|
||||
id: number;
|
||||
targetType: RbacAccessTargetType;
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
username: string | null;
|
||||
roleName: string | null;
|
||||
roleDisplayName: string | null;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string | null;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RbacSharedHost {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string | null;
|
||||
tags: string | null;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
grantedBy: string;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface RbacVisibleHostAccessEntry {
|
||||
hostId: number;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export type RbacAccessTarget =
|
||||
| { targetType: "user"; targetUserId: string }
|
||||
| { targetType: "role"; targetRoleId: number };
|
||||
|
||||
export type UpsertHostAccessInput = RbacAccessTarget & {
|
||||
hostId: number;
|
||||
grantedBy: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
export type UpsertSnippetAccessInput = RbacAccessTarget & {
|
||||
snippetId: number;
|
||||
grantedBy: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
|
||||
|
||||
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
|
||||
return {
|
||||
...access,
|
||||
targetType: access.userId ? "user" : "role",
|
||||
};
|
||||
}
|
||||
|
||||
export class RbacAccessRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listHostAccess(hostId: number): Promise<RbacAccessListItem[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
id: hostAccess.id,
|
||||
userId: hostAccess.userId,
|
||||
roleId: hostAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
grantedByUsername: sql<
|
||||
string | null
|
||||
>`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
createdAt: hostAccess.createdAt,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.leftJoin(users, eq(hostAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(hostAccess.roleId, roles.id))
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
|
||||
return rows.map(toAccessListItem);
|
||||
}
|
||||
|
||||
async upsertHostAccess(input: UpsertHostAccessInput): Promise<{
|
||||
id: number;
|
||||
created: boolean;
|
||||
}> {
|
||||
const existing = await this.findHostAccess(input.hostId, input);
|
||||
|
||||
if (existing) {
|
||||
await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set({
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
})
|
||||
.where(eq(hostAccess.id, existing.id));
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.hostAccessId, existing.id));
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: existing.id, created: false };
|
||||
}
|
||||
|
||||
const result = await this.context.drizzle.insert(hostAccess).values({
|
||||
hostId: input.hostId,
|
||||
userId: input.targetType === "user" ? input.targetUserId : null,
|
||||
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
||||
grantedBy: input.grantedBy,
|
||||
permissionLevel: input.permissionLevel,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return { id: Number(result.lastInsertRowid), created: true };
|
||||
}
|
||||
|
||||
async revokeHostAccess(accessId: number, hostId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteHostAccessForHost(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteHostAccessForHosts(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(inArray(hostAccess.hostId, hostIds))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
|
||||
const directRows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.userId, userId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
const grantedRows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.grantedBy, userId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
const deletedCount = directRows.length + grantedRows.length;
|
||||
if (deletedCount > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
async findDirectHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<typeof hostAccess.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateHostAccessOverrideCredential(
|
||||
accessId: number,
|
||||
credentialId: number | null,
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set({ overrideCredentialId: credentialId })
|
||||
.where(eq(hostAccess.id, accessId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
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 result = await this.context.drizzle.insert(snippetAccess).values({
|
||||
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: Number(result.lastInsertRowid), 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[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacSharedHost[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: hosts.id,
|
||||
name: hosts.name,
|
||||
ip: hosts.ip,
|
||||
port: hosts.port,
|
||||
username: hosts.username,
|
||||
folder: hosts.folder,
|
||||
tags: hosts.tags,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.innerJoin(users, eq(hosts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
this.userOrRoleHostAccessFilter(userId, roleIds),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
}
|
||||
|
||||
async listVisibleHostAccessEntries(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacVisibleHostAccessEntry[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
hostId: hostAccess.hostId,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
this.userOrRoleHostAccessFilter(userId, roleIds),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.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,
|
||||
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,
|
||||
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> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
sql`${hostAccess.expiresAt} IS NOT NULL`,
|
||||
sql`${hostAccess.expiresAt} <= ${now}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async findActiveHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<typeof hostAccess.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
this.userOrRoleHostAccessFilter(userId, roleIds),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async touchHostAccess(
|
||||
accessId: number,
|
||||
lastAccessedAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(hostAccess)
|
||||
.set({ lastAccessedAt })
|
||||
.where(eq(hostAccess.id, accessId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listRoleHostAccessCredentialSources(
|
||||
roleId: number,
|
||||
): Promise<RbacRoleHostAccessCredentialSource[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
hostAccessId: hostAccess.id,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
hostId: hosts.id,
|
||||
hostOwnerId: hosts.userId,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
}
|
||||
|
||||
async findSharedCredentialForHostAndUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<typeof sharedCredentials.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({
|
||||
sharedCredential: sharedCredentials,
|
||||
})
|
||||
.from(sharedCredentials)
|
||||
.innerJoin(hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
eq(sharedCredentials.targetUserId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.sharedCredential ?? null;
|
||||
}
|
||||
|
||||
async findHostAccessOwnerId(hostAccessId: number): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ ownerId: hosts.userId })
|
||||
.from(hostAccess)
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.where(eq(hostAccess.id, hostAccessId))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.ownerId ?? null;
|
||||
}
|
||||
|
||||
private userOrRoleHostAccessFilter(userId: string, roleIds: number[]) {
|
||||
if (roleIds.length === 0) {
|
||||
return eq(hostAccess.userId, userId);
|
||||
}
|
||||
|
||||
return or(
|
||||
eq(hostAccess.userId, userId),
|
||||
inArray(hostAccess.roleId, roleIds),
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
target.targetType === "user"
|
||||
? eq(hostAccess.userId, target.targetUserId)
|
||||
: eq(hostAccess.roleId, target.targetRoleId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
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,131 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
|
||||
describe("RecentActivityRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: RecentActivityRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<SqliteDatabaseAdapter["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recent_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO hosts (id, user_id, name)
|
||||
VALUES (1, 'user-1', 'one'), (2, 'user-1', 'two'), (3, 'user-2', 'other');
|
||||
INSERT INTO recent_activity (id, user_id, type, host_id, host_name, timestamp)
|
||||
VALUES
|
||||
(1, 'user-1', 'connect', 1, 'one', '2026-06-26T00:00:00.000Z'),
|
||||
(2, 'user-1', 'disconnect', 2, 'two', '2026-06-26T00:01:00.000Z'),
|
||||
(3, 'user-2', 'connect', 3, 'other', '2026-06-26T00:02:00.000Z');
|
||||
`);
|
||||
|
||||
return {
|
||||
repository: new RecentActivityRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
it("lists, creates, and trims recent activity", async () => {
|
||||
let writeCount = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
(await repository.listByUserId("user-1", 2)).map((row) => row.id),
|
||||
).toEqual([2, 1]);
|
||||
|
||||
const created = await repository.create({
|
||||
userId: "user-1",
|
||||
type: "terminal",
|
||||
hostId: 1,
|
||||
hostName: "one",
|
||||
timestamp: "2026-06-26T00:03:00.000Z",
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-1",
|
||||
type: "terminal",
|
||||
hostId: 1,
|
||||
});
|
||||
|
||||
expect(await repository.trimUserActivity("user-1", 2)).toBe(1);
|
||||
expect(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT id FROM recent_activity WHERE user_id = ? ORDER BY timestamp DESC",
|
||||
)
|
||||
.all("user-1"),
|
||||
).toEqual([{ id: created.id }, { id: 2 }]);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes activity by user id and only triggers writes for changed rows", async () => {
|
||||
let writeCount = 0;
|
||||
const { repository: repo } = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.deleteByUserId("missing")).toBe(0);
|
||||
expect(writeCount).toBe(0);
|
||||
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(2);
|
||||
expect(writeCount).toBe(1);
|
||||
expect(await repo.deleteByUserId("user-1")).toBe(0);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("deletes activity by host id and host id list", async () => {
|
||||
let writeCount = 0;
|
||||
const { repository: repo } = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.deleteByHostId(1)).toBe(1);
|
||||
expect(await repo.deleteByHostId(1)).toBe(0);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { recentActivity } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
|
||||
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
|
||||
|
||||
export class RecentActivityRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(
|
||||
userId: string,
|
||||
limit: number,
|
||||
): Promise<RecentActivityRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async create(
|
||||
activity: NewRecentActivityRecord,
|
||||
): Promise<RecentActivityRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(recentActivity)
|
||||
.values(activity)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async trimUserActivity(userId: string, keepCount: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: recentActivity.id })
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp));
|
||||
|
||||
const idsToDelete = rows
|
||||
.slice(keepCount)
|
||||
.map((row) => row.id)
|
||||
.filter((id) => typeof id === "number");
|
||||
|
||||
if (idsToDelete.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const deletedRows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(inArray(recentActivity.id, idsToDelete))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (deletedRows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return deletedRows.length;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.hostId, hostId))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(recentActivity)
|
||||
.where(inArray(recentActivity.hostId, hostIds))
|
||||
.returning({ id: recentActivity.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRepositoryRolloutStatus,
|
||||
getRepositoryRolloutWarnings,
|
||||
isRepositoryRolloutDomainEnabled,
|
||||
parseRepositoryRolloutConfig,
|
||||
REPOSITORY_ROLLOUT_ENV,
|
||||
} from "./repository-rollout.js";
|
||||
|
||||
describe("parseRepositoryRolloutConfig", () => {
|
||||
it("defaults to the current migrated repository slice", () => {
|
||||
const config = parseRepositoryRolloutConfig({});
|
||||
|
||||
expect(config).toEqual({
|
||||
mode: "all",
|
||||
enabledDomains: [
|
||||
"settings",
|
||||
"users",
|
||||
"sessions",
|
||||
"api_keys",
|
||||
"trusted_devices",
|
||||
"credentials",
|
||||
"termix_identity",
|
||||
"termix_identity_ca",
|
||||
"hosts",
|
||||
"snippets",
|
||||
"roles",
|
||||
"rbac_access",
|
||||
"shared_credentials",
|
||||
"sso_providers",
|
||||
"audit_logs",
|
||||
"user_preferences",
|
||||
"open_tabs",
|
||||
"dismissed_alerts",
|
||||
"homepage_layouts",
|
||||
"homepage_items",
|
||||
"network_topology",
|
||||
"dashboard_service_links",
|
||||
"session_recordings",
|
||||
"command_history",
|
||||
"recent_activity",
|
||||
"ssh_credential_usage",
|
||||
"transfer_recent",
|
||||
"file_manager_bookmarks",
|
||||
"c2s_tunnel_presets",
|
||||
"tmux_session_tags",
|
||||
"opkssh_tokens",
|
||||
"vault_tokens",
|
||||
"vault_profiles",
|
||||
"host_metrics_preferences",
|
||||
"host_health",
|
||||
"host_metrics_history",
|
||||
"alerts",
|
||||
"user_data_exports",
|
||||
"host_folders",
|
||||
"host_resolution",
|
||||
],
|
||||
explicit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows explicitly disabling all migrated repository domains", () => {
|
||||
const config = parseRepositoryRolloutConfig({
|
||||
[REPOSITORY_ROLLOUT_ENV]: "off",
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
mode: "none",
|
||||
enabledDomains: [],
|
||||
explicit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a partial domain allowlist with aliases", () => {
|
||||
const config = parseRepositoryRolloutConfig({
|
||||
[REPOSITORY_ROLLOUT_ENV]:
|
||||
"settings,user,api-key,credential,termix-id,termix-ca,host,dismissed,alerts,layout,items,topology,dashboard-link,recordings,history,activity,usage,transfer,user-data-export,ssh-folder,host-resolver,shared-credentials,bookmarks,c2s,tmux,opkssh,vault-token,vault-profile,metrics-preferences,host-health,metrics-history",
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
mode: "partial",
|
||||
enabledDomains: [
|
||||
"settings",
|
||||
"users",
|
||||
"api_keys",
|
||||
"credentials",
|
||||
"termix_identity",
|
||||
"termix_identity_ca",
|
||||
"hosts",
|
||||
"dismissed_alerts",
|
||||
"alerts",
|
||||
"homepage_layouts",
|
||||
"homepage_items",
|
||||
"network_topology",
|
||||
"dashboard_service_links",
|
||||
"session_recordings",
|
||||
"command_history",
|
||||
"recent_activity",
|
||||
"ssh_credential_usage",
|
||||
"transfer_recent",
|
||||
"user_data_exports",
|
||||
"host_folders",
|
||||
"host_resolution",
|
||||
"shared_credentials",
|
||||
"file_manager_bookmarks",
|
||||
"c2s_tunnel_presets",
|
||||
"tmux_session_tags",
|
||||
"opkssh_tokens",
|
||||
"vault_tokens",
|
||||
"vault_profiles",
|
||||
"host_metrics_preferences",
|
||||
"host_health",
|
||||
"host_metrics_history",
|
||||
],
|
||||
explicit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates allowlisted domains", () => {
|
||||
const config = parseRepositoryRolloutConfig({
|
||||
[REPOSITORY_ROLLOUT_ENV]: "users,user,users",
|
||||
});
|
||||
|
||||
expect(config.enabledDomains).toEqual(["users"]);
|
||||
});
|
||||
|
||||
it("rejects unknown domains", () => {
|
||||
expect(() =>
|
||||
parseRepositoryRolloutConfig({
|
||||
[REPOSITORY_ROLLOUT_ENV]: "settings,unknown-domain",
|
||||
}),
|
||||
).toThrow("Unsupported DATABASE_LAYER_REPOSITORY_ROLLOUT domain");
|
||||
});
|
||||
|
||||
it("checks whether an individual domain is enabled", () => {
|
||||
const env = { [REPOSITORY_ROLLOUT_ENV]: "sessions" };
|
||||
|
||||
expect(isRepositoryRolloutDomainEnabled("sessions", env)).toBe(true);
|
||||
expect(isRepositoryRolloutDomainEnabled("users", env)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds a status payload for admin visibility", () => {
|
||||
const status = getRepositoryRolloutStatus({
|
||||
[REPOSITORY_ROLLOUT_ENV]: "settings,sessions",
|
||||
});
|
||||
|
||||
expect(status).toEqual({
|
||||
mode: "partial",
|
||||
enabledDomains: ["settings", "sessions"],
|
||||
explicit: true,
|
||||
envKey: REPOSITORY_ROLLOUT_ENV,
|
||||
supportedDomains: [
|
||||
"settings",
|
||||
"users",
|
||||
"sessions",
|
||||
"api_keys",
|
||||
"trusted_devices",
|
||||
"credentials",
|
||||
"termix_identity",
|
||||
"termix_identity_ca",
|
||||
"hosts",
|
||||
"snippets",
|
||||
"roles",
|
||||
"rbac_access",
|
||||
"shared_credentials",
|
||||
"sso_providers",
|
||||
"audit_logs",
|
||||
"user_preferences",
|
||||
"open_tabs",
|
||||
"dismissed_alerts",
|
||||
"homepage_layouts",
|
||||
"homepage_items",
|
||||
"network_topology",
|
||||
"dashboard_service_links",
|
||||
"session_recordings",
|
||||
"command_history",
|
||||
"recent_activity",
|
||||
"ssh_credential_usage",
|
||||
"transfer_recent",
|
||||
"file_manager_bookmarks",
|
||||
"c2s_tunnel_presets",
|
||||
"tmux_session_tags",
|
||||
"opkssh_tokens",
|
||||
"vault_tokens",
|
||||
"vault_profiles",
|
||||
"host_metrics_preferences",
|
||||
"host_health",
|
||||
"host_metrics_history",
|
||||
"alerts",
|
||||
"user_data_exports",
|
||||
"host_folders",
|
||||
"host_resolution",
|
||||
],
|
||||
warnings: [
|
||||
"Partial repository rollout enabled for domains: settings, sessions.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when gray rollout is implicit", () => {
|
||||
const warnings = getRepositoryRolloutWarnings(
|
||||
parseRepositoryRolloutConfig({}),
|
||||
);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
"DATABASE_LAYER_REPOSITORY_ROLLOUT is not explicitly set; gray targets should set it so rollout state is visible in deployment config.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns when migrated repository domains are disabled", () => {
|
||||
const warnings = getRepositoryRolloutWarnings(
|
||||
parseRepositoryRolloutConfig({ [REPOSITORY_ROLLOUT_ENV]: "off" }),
|
||||
);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
"All migrated repository domains are disabled; migrated auth/settings/session paths will fail closed.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
export const REPOSITORY_ROLLOUT_ENV = "DATABASE_LAYER_REPOSITORY_ROLLOUT";
|
||||
|
||||
export const REPOSITORY_ROLLOUT_DOMAINS = [
|
||||
"settings",
|
||||
"users",
|
||||
"sessions",
|
||||
"api_keys",
|
||||
"trusted_devices",
|
||||
"credentials",
|
||||
"termix_identity",
|
||||
"termix_identity_ca",
|
||||
"hosts",
|
||||
"snippets",
|
||||
"roles",
|
||||
"rbac_access",
|
||||
"shared_credentials",
|
||||
"sso_providers",
|
||||
"audit_logs",
|
||||
"user_preferences",
|
||||
"open_tabs",
|
||||
"dismissed_alerts",
|
||||
"homepage_layouts",
|
||||
"homepage_items",
|
||||
"network_topology",
|
||||
"dashboard_service_links",
|
||||
"session_recordings",
|
||||
"command_history",
|
||||
"recent_activity",
|
||||
"ssh_credential_usage",
|
||||
"transfer_recent",
|
||||
"file_manager_bookmarks",
|
||||
"c2s_tunnel_presets",
|
||||
"tmux_session_tags",
|
||||
"opkssh_tokens",
|
||||
"vault_tokens",
|
||||
"vault_profiles",
|
||||
"host_metrics_preferences",
|
||||
"host_health",
|
||||
"host_metrics_history",
|
||||
"alerts",
|
||||
"user_data_exports",
|
||||
"host_folders",
|
||||
"host_resolution",
|
||||
] as const;
|
||||
|
||||
export type RepositoryRolloutDomain =
|
||||
(typeof REPOSITORY_ROLLOUT_DOMAINS)[number];
|
||||
|
||||
export interface RepositoryRolloutConfig {
|
||||
mode: "all" | "none" | "partial";
|
||||
enabledDomains: RepositoryRolloutDomain[];
|
||||
explicit: boolean;
|
||||
}
|
||||
|
||||
export interface RepositoryRolloutStatus extends RepositoryRolloutConfig {
|
||||
envKey: typeof REPOSITORY_ROLLOUT_ENV;
|
||||
supportedDomains: RepositoryRolloutDomain[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
|
||||
const DOMAIN_ALIASES: Record<string, RepositoryRolloutDomain> = {
|
||||
api: "api_keys",
|
||||
api_key: "api_keys",
|
||||
api_keys: "api_keys",
|
||||
apikey: "api_keys",
|
||||
apikeys: "api_keys",
|
||||
audit: "audit_logs",
|
||||
audit_log: "audit_logs",
|
||||
audit_logs: "audit_logs",
|
||||
auditlog: "audit_logs",
|
||||
auditlogs: "audit_logs",
|
||||
alert: "alerts",
|
||||
alerts: "alerts",
|
||||
dashboard_link: "dashboard_service_links",
|
||||
dashboard_links: "dashboard_service_links",
|
||||
dashboard_service_link: "dashboard_service_links",
|
||||
dashboard_service_links: "dashboard_service_links",
|
||||
dashboardlink: "dashboard_service_links",
|
||||
dashboardlinks: "dashboard_service_links",
|
||||
command_history: "command_history",
|
||||
commandhistory: "command_history",
|
||||
credential: "credentials",
|
||||
credentials: "credentials",
|
||||
ca: "termix_identity_ca",
|
||||
history: "command_history",
|
||||
ssh_credential: "credentials",
|
||||
ssh_credentials: "credentials",
|
||||
sshcredential: "credentials",
|
||||
sshcredentials: "credentials",
|
||||
terminal_history: "command_history",
|
||||
termix_ca: "termix_identity_ca",
|
||||
termix_id: "termix_identity",
|
||||
termix_id_ca: "termix_identity_ca",
|
||||
termix_identity: "termix_identity",
|
||||
termix_identity_ca: "termix_identity_ca",
|
||||
termix_identity_cas: "termix_identity_ca",
|
||||
termix_identity_key: "termix_identity",
|
||||
termix_identity_keys: "termix_identity",
|
||||
termix_identity_public_keys: "termix_identity",
|
||||
termix_identities: "termix_identity",
|
||||
termixidca: "termix_identity_ca",
|
||||
termixidentity: "termix_identity",
|
||||
dismissed_alert: "dismissed_alerts",
|
||||
dismissed_alerts: "dismissed_alerts",
|
||||
dismissedalert: "dismissed_alerts",
|
||||
dismissedalerts: "dismissed_alerts",
|
||||
dismissed: "dismissed_alerts",
|
||||
homepage_layout: "homepage_layouts",
|
||||
homepage_layouts: "homepage_layouts",
|
||||
homepagelayout: "homepage_layouts",
|
||||
homepagelayouts: "homepage_layouts",
|
||||
layout: "homepage_layouts",
|
||||
layouts: "homepage_layouts",
|
||||
homepage_item: "homepage_items",
|
||||
homepage_items: "homepage_items",
|
||||
homepageitem: "homepage_items",
|
||||
homepageitems: "homepage_items",
|
||||
host_folder: "host_folders",
|
||||
host_folders: "host_folders",
|
||||
hostfolder: "host_folders",
|
||||
hostfolders: "host_folders",
|
||||
host: "hosts",
|
||||
hosts: "hosts",
|
||||
snippet: "snippets",
|
||||
snippets: "snippets",
|
||||
host_resolution: "host_resolution",
|
||||
host_resolver: "host_resolution",
|
||||
hostresolution: "host_resolution",
|
||||
hostresolver: "host_resolution",
|
||||
resolver: "host_resolution",
|
||||
ssh_folder: "host_folders",
|
||||
ssh_folders: "host_folders",
|
||||
sshfolder: "host_folders",
|
||||
sshfolders: "host_folders",
|
||||
item: "homepage_items",
|
||||
items: "homepage_items",
|
||||
recording: "session_recordings",
|
||||
recordings: "session_recordings",
|
||||
session_recording: "session_recordings",
|
||||
session_recordings: "session_recordings",
|
||||
sessionrecording: "session_recordings",
|
||||
sessionrecordings: "session_recordings",
|
||||
network_topologies: "network_topology",
|
||||
network_topology: "network_topology",
|
||||
networktopologies: "network_topology",
|
||||
networktopology: "network_topology",
|
||||
topology: "network_topology",
|
||||
activity: "recent_activity",
|
||||
recent_activities: "recent_activity",
|
||||
recent_activity: "recent_activity",
|
||||
recentactivity: "recent_activity",
|
||||
credential_usage: "ssh_credential_usage",
|
||||
ssh_credential_usage: "ssh_credential_usage",
|
||||
sshcredentialusage: "ssh_credential_usage",
|
||||
usage: "ssh_credential_usage",
|
||||
transfer: "transfer_recent",
|
||||
transfer_recent: "transfer_recent",
|
||||
transferrecent: "transfer_recent",
|
||||
export: "user_data_exports",
|
||||
exports: "user_data_exports",
|
||||
user_data_export: "user_data_exports",
|
||||
user_data_exports: "user_data_exports",
|
||||
userdataexport: "user_data_exports",
|
||||
userdataexports: "user_data_exports",
|
||||
bookmark: "file_manager_bookmarks",
|
||||
bookmarks: "file_manager_bookmarks",
|
||||
file_bookmarks: "file_manager_bookmarks",
|
||||
file_manager_bookmark: "file_manager_bookmarks",
|
||||
file_manager_bookmarks: "file_manager_bookmarks",
|
||||
filemanagerbookmarks: "file_manager_bookmarks",
|
||||
c2s: "c2s_tunnel_presets",
|
||||
c2s_preset: "c2s_tunnel_presets",
|
||||
c2s_presets: "c2s_tunnel_presets",
|
||||
c2s_tunnel_preset: "c2s_tunnel_presets",
|
||||
c2s_tunnel_presets: "c2s_tunnel_presets",
|
||||
c2stunnelpresets: "c2s_tunnel_presets",
|
||||
tmux: "tmux_session_tags",
|
||||
tmux_tag: "tmux_session_tags",
|
||||
tmux_tags: "tmux_session_tags",
|
||||
tmux_session_tag: "tmux_session_tags",
|
||||
tmux_session_tags: "tmux_session_tags",
|
||||
tmuxsessiontags: "tmux_session_tags",
|
||||
opkssh: "opkssh_tokens",
|
||||
opkssh_token: "opkssh_tokens",
|
||||
opkssh_tokens: "opkssh_tokens",
|
||||
opksshtoken: "opkssh_tokens",
|
||||
opksshtokens: "opkssh_tokens",
|
||||
vault_token: "vault_tokens",
|
||||
vault_tokens: "vault_tokens",
|
||||
vaulttoken: "vault_tokens",
|
||||
vaulttokens: "vault_tokens",
|
||||
vault_profile: "vault_profiles",
|
||||
vault_profiles: "vault_profiles",
|
||||
vaultprofile: "vault_profiles",
|
||||
vaultprofiles: "vault_profiles",
|
||||
host_metrics_preference: "host_metrics_preferences",
|
||||
host_metrics_preferences: "host_metrics_preferences",
|
||||
hostmetricspreference: "host_metrics_preferences",
|
||||
hostmetricspreferences: "host_metrics_preferences",
|
||||
metrics_preferences: "host_metrics_preferences",
|
||||
health: "host_health",
|
||||
host_health: "host_health",
|
||||
host_health_checks: "host_health",
|
||||
host_health_history: "host_health",
|
||||
hosthealth: "host_health",
|
||||
host_metrics_history: "host_metrics_history",
|
||||
hostmetricshistory: "host_metrics_history",
|
||||
metrics_history: "host_metrics_history",
|
||||
open_tab: "open_tabs",
|
||||
open_tabs: "open_tabs",
|
||||
opentab: "open_tabs",
|
||||
opentabs: "open_tabs",
|
||||
setting: "settings",
|
||||
settings: "settings",
|
||||
session: "sessions",
|
||||
sessions: "sessions",
|
||||
trusted_device: "trusted_devices",
|
||||
trusted_devices: "trusted_devices",
|
||||
trusteddevice: "trusted_devices",
|
||||
trusteddevices: "trusted_devices",
|
||||
preference: "user_preferences",
|
||||
preferences: "user_preferences",
|
||||
user_preference: "user_preferences",
|
||||
user_preferences: "user_preferences",
|
||||
userpreference: "user_preferences",
|
||||
userpreferences: "user_preferences",
|
||||
role: "roles",
|
||||
roles: "roles",
|
||||
rbac: "rbac_access",
|
||||
rbac_access: "rbac_access",
|
||||
rbacaccess: "rbac_access",
|
||||
shared_credential: "shared_credentials",
|
||||
shared_credentials: "shared_credentials",
|
||||
sharedcredential: "shared_credentials",
|
||||
sharedcredentials: "shared_credentials",
|
||||
sso: "sso_providers",
|
||||
sso_provider: "sso_providers",
|
||||
sso_providers: "sso_providers",
|
||||
ssoprovider: "sso_providers",
|
||||
ssoproviders: "sso_providers",
|
||||
user: "users",
|
||||
users: "users",
|
||||
};
|
||||
|
||||
const DISABLED_VALUES = new Set(["0", "false", "none", "off", "disabled"]);
|
||||
const ENABLED_VALUES = new Set(["1", "true", "all", "on", "enabled"]);
|
||||
|
||||
function parseDomainList(value: string): RepositoryRolloutDomain[] {
|
||||
const domains = value
|
||||
.split(",")
|
||||
.map((part) => part.trim().toLowerCase().replaceAll("-", "_"))
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const domain = DOMAIN_ALIASES[part];
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
`Unsupported ${REPOSITORY_ROLLOUT_ENV} domain '${part}'. Expected one of: ${REPOSITORY_ROLLOUT_DOMAINS.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return domain;
|
||||
});
|
||||
|
||||
return Array.from(new Set(domains));
|
||||
}
|
||||
|
||||
export function parseRepositoryRolloutConfig(
|
||||
env: EnvLike = process.env,
|
||||
): RepositoryRolloutConfig {
|
||||
const raw = env[REPOSITORY_ROLLOUT_ENV];
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
mode: "all",
|
||||
enabledDomains: [...REPOSITORY_ROLLOUT_DOMAINS],
|
||||
explicit: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (ENABLED_VALUES.has(normalized)) {
|
||||
return {
|
||||
mode: "all",
|
||||
enabledDomains: [...REPOSITORY_ROLLOUT_DOMAINS],
|
||||
explicit: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (DISABLED_VALUES.has(normalized)) {
|
||||
return { mode: "none", enabledDomains: [], explicit: true };
|
||||
}
|
||||
|
||||
const enabledDomains = parseDomainList(normalized);
|
||||
return {
|
||||
mode:
|
||||
enabledDomains.length === REPOSITORY_ROLLOUT_DOMAINS.length
|
||||
? "all"
|
||||
: "partial",
|
||||
enabledDomains,
|
||||
explicit: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isRepositoryRolloutDomainEnabled(
|
||||
domain: RepositoryRolloutDomain,
|
||||
env: EnvLike = process.env,
|
||||
): boolean {
|
||||
return parseRepositoryRolloutConfig(env).enabledDomains.includes(domain);
|
||||
}
|
||||
|
||||
export function getRepositoryRolloutStatus(
|
||||
env: EnvLike = process.env,
|
||||
): RepositoryRolloutStatus {
|
||||
const config = parseRepositoryRolloutConfig(env);
|
||||
return {
|
||||
...config,
|
||||
envKey: REPOSITORY_ROLLOUT_ENV,
|
||||
supportedDomains: [...REPOSITORY_ROLLOUT_DOMAINS],
|
||||
warnings: getRepositoryRolloutWarnings(config),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRepositoryRolloutWarnings(
|
||||
config: RepositoryRolloutConfig,
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!config.explicit) {
|
||||
warnings.push(
|
||||
`${REPOSITORY_ROLLOUT_ENV} is not explicitly set; gray targets should set it so rollout state is visible in deployment config.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.mode === "none") {
|
||||
warnings.push(
|
||||
"All migrated repository domains are disabled; migrated auth/settings/session paths will fail closed.",
|
||||
);
|
||||
}
|
||||
|
||||
if (config.mode === "partial") {
|
||||
warnings.push(
|
||||
`Partial repository rollout enabled for domains: ${config.enabledDomains.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function assertRepositoryRolloutDomainEnabled(
|
||||
domain: RepositoryRolloutDomain,
|
||||
): void {
|
||||
if (isRepositoryRolloutDomainEnabled(domain)) return;
|
||||
|
||||
throw new Error(
|
||||
`Repository domain '${domain}' is disabled by ${REPOSITORY_ROLLOUT_ENV}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function logRepositoryRolloutConfig(env: EnvLike = process.env): void {
|
||||
const config = getRepositoryRolloutStatus(env);
|
||||
databaseLogger.info("Database repository rollout configuration loaded", {
|
||||
operation: "repository_rollout_config",
|
||||
mode: config.mode,
|
||||
enabledDomains: config.enabledDomains,
|
||||
explicit: config.explicit,
|
||||
envKey: REPOSITORY_ROLLOUT_ENV,
|
||||
});
|
||||
|
||||
for (const warning of config.warnings) {
|
||||
databaseLogger.warn(warning, {
|
||||
operation: "repository_rollout_warning",
|
||||
mode: config.mode,
|
||||
enabledDomains: config.enabledDomains,
|
||||
explicit: config.explicit,
|
||||
envKey: REPOSITORY_ROLLOUT_ENV,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
|
||||
describe("RoleRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<RoleRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
|
||||
VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0);
|
||||
`);
|
||||
|
||||
return new RoleRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, lists, updates, and finds roles", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const roleId = await repo.createRole({
|
||||
name: "ops",
|
||||
displayName: "Operations",
|
||||
description: "Ops access",
|
||||
isSystem: false,
|
||||
permissions: null,
|
||||
});
|
||||
|
||||
expect((await repo.findRoleByName("ops"))?.id).toBe(roleId);
|
||||
expect((await repo.findRoleById(roleId))?.displayName).toBe("Operations");
|
||||
|
||||
await repo.updateRole(roleId, {
|
||||
displayName: "Ops",
|
||||
description: null,
|
||||
updatedAt: "2026-06-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const roles = await repo.listRoles();
|
||||
expect(roles.map((role) => role.name)).toEqual(["ops"]);
|
||||
expect(roles[0].displayName).toBe("Ops");
|
||||
expect(roles[0].description).toBeNull();
|
||||
});
|
||||
|
||||
it("assigns, lists, and removes user roles", async () => {
|
||||
const repo = await createRepository();
|
||||
const roleId = await repo.createRole({
|
||||
name: "ops",
|
||||
displayName: "Operations",
|
||||
isSystem: false,
|
||||
permissions: JSON.stringify(["hosts.read", "hosts.*"]),
|
||||
});
|
||||
|
||||
await repo.assignRoleToUser({
|
||||
userId: "user-1",
|
||||
roleId,
|
||||
grantedBy: "admin",
|
||||
});
|
||||
|
||||
expect(await repo.findUserRole("user-1", roleId)).not.toBeNull();
|
||||
expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]);
|
||||
expect(await repo.listRoleUserIds(roleId)).toEqual(["user-1"]);
|
||||
expect((await repo.listUserRoles("user-1"))[0]).toMatchObject({
|
||||
roleId,
|
||||
roleName: "ops",
|
||||
roleDisplayName: "Operations",
|
||||
isSystem: false,
|
||||
});
|
||||
expect(await repo.listUserRolePermissions("user-1")).toEqual([
|
||||
{ permissions: JSON.stringify(["hosts.read", "hosts.*"]) },
|
||||
]);
|
||||
expect(await repo.userHasAnyRoleName("user-1", ["admin", "ops"])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await repo.userHasAnyRoleName("user-1", ["admin"])).toBe(false);
|
||||
expect(await repo.userHasAnyRoleName("user-1", [])).toBe(false);
|
||||
|
||||
await repo.removeRoleFromUser("user-1", roleId);
|
||||
expect(await repo.findUserRole("user-1", roleId)).toBeNull();
|
||||
});
|
||||
|
||||
it("assigns roles by name", async () => {
|
||||
const repo = await createRepository();
|
||||
const roleId = await repo.createRole({
|
||||
name: "user",
|
||||
displayName: "User",
|
||||
isSystem: true,
|
||||
permissions: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.assignRoleNameToUser({
|
||||
userId: "user-1",
|
||||
roleName: "missing",
|
||||
grantedBy: "admin",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
|
||||
|
||||
expect(
|
||||
await repo.assignRoleNameToUser({
|
||||
userId: "user-1",
|
||||
roleName: "user",
|
||||
grantedBy: "admin",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(await repo.listUserRoleIds("user-1")).toEqual([roleId]);
|
||||
});
|
||||
|
||||
it("switches user roles by role name", async () => {
|
||||
const repo = await createRepository();
|
||||
const userRoleId = await repo.createRole({
|
||||
name: "user",
|
||||
displayName: "User",
|
||||
isSystem: true,
|
||||
permissions: null,
|
||||
});
|
||||
const adminRoleId = await repo.createRole({
|
||||
name: "admin",
|
||||
displayName: "Admin",
|
||||
isSystem: true,
|
||||
permissions: null,
|
||||
});
|
||||
await repo.assignRoleToUser({
|
||||
userId: "user-1",
|
||||
roleId: userRoleId,
|
||||
grantedBy: "admin",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.switchUserRoleName({
|
||||
userId: "user-1",
|
||||
addRoleName: "admin",
|
||||
removeRoleName: "user",
|
||||
grantedBy: "admin",
|
||||
}),
|
||||
).resolves.toEqual({ added: true, removed: true });
|
||||
expect(await repo.listUserRoleIds("user-1")).toEqual([adminRoleId]);
|
||||
|
||||
await expect(
|
||||
repo.switchUserRoleName({
|
||||
userId: "user-1",
|
||||
addRoleName: "missing",
|
||||
removeRoleName: "admin",
|
||||
grantedBy: "admin",
|
||||
}),
|
||||
).resolves.toEqual({ added: false, removed: true });
|
||||
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes role assignments and returns affected users", async () => {
|
||||
const repo = await createRepository();
|
||||
const roleId = await repo.createRole({
|
||||
name: "ops",
|
||||
displayName: "Operations",
|
||||
isSystem: false,
|
||||
permissions: null,
|
||||
});
|
||||
await repo.assignRoleToUser({
|
||||
userId: "user-1",
|
||||
roleId,
|
||||
grantedBy: "admin",
|
||||
});
|
||||
|
||||
const result = await repo.deleteRole(roleId);
|
||||
|
||||
expect(result.deletedUserIds).toEqual(["user-1"]);
|
||||
expect(await repo.findRoleById(roleId)).toBeNull();
|
||||
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes all roles for a user only when assignments exist", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
const opsRoleId = await repo.createRole({
|
||||
name: "ops",
|
||||
displayName: "Operations",
|
||||
isSystem: false,
|
||||
permissions: null,
|
||||
});
|
||||
const auditRoleId = await repo.createRole({
|
||||
name: "audit",
|
||||
displayName: "Audit",
|
||||
isSystem: false,
|
||||
permissions: null,
|
||||
});
|
||||
await repo.assignRoleToUser({
|
||||
userId: "user-1",
|
||||
roleId: opsRoleId,
|
||||
grantedBy: "admin",
|
||||
});
|
||||
await repo.assignRoleToUser({
|
||||
userId: "user-1",
|
||||
roleId: auditRoleId,
|
||||
grantedBy: "admin",
|
||||
});
|
||||
|
||||
expect(await repo.removeAllRolesFromUser("missing-user")).toBe(0);
|
||||
expect(writeCount).toBe(4);
|
||||
|
||||
expect(await repo.removeAllRolesFromUser("user-1")).toBe(2);
|
||||
expect(await repo.listUserRoleIds("user-1")).toEqual([]);
|
||||
expect(writeCount).toBe(5);
|
||||
});
|
||||
|
||||
it("runs the write hook after writes", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const roleId = await repo.createRole({
|
||||
name: "ops",
|
||||
displayName: "Operations",
|
||||
isSystem: false,
|
||||
permissions: null,
|
||||
});
|
||||
await repo.assignRoleToUser({
|
||||
userId: "user-1",
|
||||
roleId,
|
||||
grantedBy: "admin",
|
||||
});
|
||||
await repo.updateRole(roleId, { displayName: "Ops" });
|
||||
await repo.removeRoleFromUser("user-1", roleId);
|
||||
await repo.deleteRole(roleId);
|
||||
|
||||
expect(writeCount).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { hostAccess, roles, userRoles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type RoleRecord = typeof roles.$inferSelect;
|
||||
export type NewRoleRecord = typeof roles.$inferInsert;
|
||||
export type RoleUpdate = Pick<
|
||||
Partial<NewRoleRecord>,
|
||||
"displayName" | "description" | "updatedAt"
|
||||
>;
|
||||
|
||||
export type UserRoleWithRole = {
|
||||
id: number;
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
roleDisplayName: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
grantedAt: string;
|
||||
};
|
||||
|
||||
export type UserRolePermissionRecord = {
|
||||
permissions: string | null;
|
||||
};
|
||||
|
||||
export type UserRoleNameSwitchResult = {
|
||||
added: boolean;
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
export class RoleRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listRoles(): Promise<RoleRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
}
|
||||
|
||||
async findRoleById(id: number): Promise<RoleRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findRoleByName(name: string): Promise<RoleRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.name, name))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createRole(role: NewRoleRecord): Promise<number> {
|
||||
const result = await this.context.drizzle.insert(roles).values(role);
|
||||
await this.afterWrite();
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
async updateRole(id: number, update: RoleUpdate): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(roles)
|
||||
.set(update)
|
||||
.where(eq(roles.id, id))
|
||||
.returning({ id: roles.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteRole(id: number): Promise<{ deletedUserIds: string[] }> {
|
||||
const deletedUserRoles = await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(eq(userRoles.roleId, id))
|
||||
.returning({ userId: userRoles.userId });
|
||||
|
||||
await this.context.drizzle
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.roleId, id));
|
||||
|
||||
await this.context.drizzle.delete(roles).where(eq(roles.id, id));
|
||||
await this.afterWrite();
|
||||
|
||||
return {
|
||||
deletedUserIds: deletedUserRoles.map((row) => row.userId),
|
||||
};
|
||||
}
|
||||
|
||||
async findUserRole(
|
||||
userId: string,
|
||||
roleId: number,
|
||||
): Promise<typeof userRoles.$inferSelect | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userRoles)
|
||||
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async assignRoleToUser(input: {
|
||||
userId: string;
|
||||
roleId: number;
|
||||
grantedBy: string;
|
||||
}): Promise<void> {
|
||||
await this.context.drizzle.insert(userRoles).values(input);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async assignRoleNameToUser(input: {
|
||||
userId: string;
|
||||
roleName: string;
|
||||
grantedBy: string;
|
||||
}): Promise<boolean> {
|
||||
const role = await this.findRoleByName(input.roleName);
|
||||
if (!role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.context.drizzle.insert(userRoles).values({
|
||||
userId: input.userId,
|
||||
roleId: role.id,
|
||||
grantedBy: input.grantedBy,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async switchUserRoleName(input: {
|
||||
userId: string;
|
||||
addRoleName: string;
|
||||
removeRoleName: string;
|
||||
grantedBy: string;
|
||||
}): Promise<UserRoleNameSwitchResult> {
|
||||
const [addRole, removeRole] = await Promise.all([
|
||||
this.findRoleByName(input.addRoleName),
|
||||
this.findRoleByName(input.removeRoleName),
|
||||
]);
|
||||
|
||||
let added = false;
|
||||
let removed = false;
|
||||
|
||||
if (addRole) {
|
||||
await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userRoles.userId, input.userId),
|
||||
eq(userRoles.roleId, addRole.id),
|
||||
),
|
||||
);
|
||||
await this.context.drizzle.insert(userRoles).values({
|
||||
userId: input.userId,
|
||||
roleId: addRole.id,
|
||||
grantedBy: input.grantedBy,
|
||||
});
|
||||
added = true;
|
||||
}
|
||||
|
||||
if (removeRole) {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userRoles.userId, input.userId),
|
||||
eq(userRoles.roleId, removeRole.id),
|
||||
),
|
||||
)
|
||||
.returning({ id: userRoles.id });
|
||||
removed = rows.length > 0;
|
||||
}
|
||||
|
||||
if (added || removed) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
async removeRoleFromUser(userId: string, roleId: number): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async removeAllRolesFromUser(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(userRoles)
|
||||
.where(eq(userRoles.userId, userId))
|
||||
.returning({ id: userRoles.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async listUserRoleIds(userId: string): Promise<number[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
|
||||
return rows.map((row) => row.roleId);
|
||||
}
|
||||
|
||||
async listRoleUserIds(roleId: number): Promise<string[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userRoles.userId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
|
||||
return rows.map((row) => row.userId);
|
||||
}
|
||||
|
||||
async listUserRolePermissions(
|
||||
userId: string,
|
||||
): Promise<UserRolePermissionRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({ permissions: roles.permissions })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
}
|
||||
|
||||
async userHasAnyRoleName(
|
||||
userId: string,
|
||||
roleNames: string[],
|
||||
): Promise<boolean> {
|
||||
if (roleNames.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.select({ roleName: roles.name })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(and(eq(userRoles.userId, userId), inArray(roles.name, roleNames)))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async listUserRoles(userId: string): Promise<UserRoleWithRole[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: userRoles.id,
|
||||
roleId: roles.id,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
grantedAt: userRoles.grantedAt,
|
||||
})
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
|
||||
describe("SessionRecordingRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SessionRecordingRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1'), (2, 'user-1', 'two', '10.0.0.2'), (3, 'user-2', 'other', '10.0.0.3');
|
||||
`);
|
||||
|
||||
return new SessionRecordingRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists session recordings with host metadata", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const first = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
endedAt: "2026-06-27T00:01:00.000Z",
|
||||
duration: 60,
|
||||
recordingPath: "/tmp/one.log",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 2,
|
||||
startedAt: "2026-06-27T00:02:00.000Z",
|
||||
recordingPath: "/tmp/two.log",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
hostId: 3,
|
||||
startedAt: "2026-06-27T00:03:00.000Z",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
duration: 60,
|
||||
recordingPath: "/tmp/one.log",
|
||||
});
|
||||
|
||||
const rows = await repo.listByUserIdWithHost("user-1");
|
||||
expect(rows.map((row) => row.hostName)).toEqual(["two", "one"]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
hostIp: "10.0.0.2",
|
||||
recordingPath: "/tmp/two.log",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds paths and prunes old recordings", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const old = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
recordingPath: "/tmp/old.log",
|
||||
});
|
||||
const current = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
recordingPath: "/tmp/current.log",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.findPathByIdForUser("user-2", old.id)).toBeNull();
|
||||
expect(await repo.findPathByIdForUser("user-1", old.id)).toMatchObject({
|
||||
recordingPath: "/tmp/old.log",
|
||||
});
|
||||
expect(await repo.listPathsOlderThan("2026-02-01T00:00:00.000Z")).toEqual([
|
||||
{ id: old.id, recordingPath: "/tmp/old.log" },
|
||||
]);
|
||||
|
||||
expect(await repo.deleteById(old.id)).toBe(true);
|
||||
expect(await repo.deleteById(old.id)).toBe(false);
|
||||
expect(await repo.findByIdForUser("user-1", current.id)).toMatchObject({
|
||||
id: current.id,
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes recordings by user and host references", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const first = await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
startedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-1",
|
||||
hostId: 2,
|
||||
startedAt: "2026-06-27T00:01:00.000Z",
|
||||
});
|
||||
await repo.create({
|
||||
userId: "user-2",
|
||||
hostId: 3,
|
||||
startedAt: "2026-06-27T00:02:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", first.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", first.id)).toBe(true);
|
||||
expect(await repo.deleteByHostIds([])).toBe(0);
|
||||
expect(await repo.deleteByHostIds([2])).toBe(1);
|
||||
expect(writeCount).toBe(5);
|
||||
|
||||
expect(await repo.deleteByUserId("user-2")).toBe(1);
|
||||
expect(await repo.deleteByHostId(3)).toBe(0);
|
||||
expect(writeCount).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
||||
import { hosts, sessionRecordings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.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;
|
||||
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 this.context.drizzle
|
||||
.insert(sessionRecordings)
|
||||
.values(input)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateEnded(
|
||||
id: number,
|
||||
input: { endedAt: string; duration: number | null },
|
||||
): 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,
|
||||
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 rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.userId, userId))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostId(hostId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.hostId, hostId))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
||||
if (hostIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessionRecordings)
|
||||
.where(inArray(sessionRecordings.hostId, hostIds))
|
||||
.returning({ id: sessionRecordings.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { and, eq, lte, ne } from "drizzle-orm";
|
||||
import { sessions } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type SessionRecord = typeof sessions.$inferSelect;
|
||||
export type NewSessionRecord = typeof sessions.$inferInsert;
|
||||
|
||||
export class SessionRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async create(session: NewSessionRecord): Promise<SessionRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sessions)
|
||||
.values(session)
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async listAll(): Promise<SessionRecord[]> {
|
||||
return this.context.drizzle.select().from(sessions);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SessionRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByUserId(userId: string): Promise<SessionRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
}
|
||||
|
||||
async listExpired(now = new Date()): Promise<SessionRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(lte(sessions.expiresAt, now.toISOString()));
|
||||
}
|
||||
|
||||
async touch(
|
||||
id: string,
|
||||
lastActiveAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessions)
|
||||
.set({ lastActiveAt })
|
||||
.where(eq(sessions.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async updateToken(
|
||||
id: string,
|
||||
jwtToken: string,
|
||||
lastActiveAt = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(sessions)
|
||||
.set({ jwtToken, lastActiveAt })
|
||||
.where(eq(sessions.id, id));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async revoke(id: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.id, id))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async revokeAllForUser(
|
||||
userId: string,
|
||||
exceptSessionId?: string,
|
||||
): Promise<number> {
|
||||
const where = exceptSessionId
|
||||
? and(eq(sessions.userId, userId), ne(sessions.id, exceptSessionId))
|
||||
: eq(sessions.userId, userId);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessions)
|
||||
.where(where)
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteExpired(now = new Date()): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sessions)
|
||||
.where(lte(sessions.expiresAt, now.toISOString()))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
|
||||
describe("SettingsRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(): Promise<SettingsRepository> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
return new SettingsRepository(context);
|
||||
}
|
||||
|
||||
it("creates and updates settings", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.set("allow_registration", "true");
|
||||
expect(await repo.get("allow_registration")).toBe("true");
|
||||
|
||||
await repo.set("allow_registration", "false");
|
||||
expect(await repo.get("allow_registration")).toBe("false");
|
||||
});
|
||||
|
||||
it("lists and upserts settings", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.upsert("theme", "dark");
|
||||
await repo.upsert("allow_registration", "true");
|
||||
await repo.upsert("theme", "light");
|
||||
|
||||
expect(await repo.get("theme")).toBe("light");
|
||||
expect(await repo.listAll()).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: "theme", value: "light" },
|
||||
{ key: "allow_registration", value: "true" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null or fallback when setting is missing", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.get("missing")).toBeNull();
|
||||
expect(await repo.getBoolean("missing", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads boolean settings", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.set("enabled", "1");
|
||||
await repo.set("disabled", "false");
|
||||
|
||||
expect(await repo.getBoolean("enabled")).toBe(true);
|
||||
expect(await repo.getBoolean("disabled", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes settings", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.set("theme", "dark");
|
||||
await repo.delete("theme");
|
||||
|
||||
expect(await repo.get("theme")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes settings by SQL LIKE pattern", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.set("user_kek_salt_user-1", "salt");
|
||||
await repo.set("user_encrypted_dek_user-1", "dek");
|
||||
await repo.set("user_kek_salt_user-2", "other");
|
||||
|
||||
expect(await repo.deleteLike("user_%_user-1")).toBe(2);
|
||||
expect(await repo.get("user_kek_salt_user-1")).toBeNull();
|
||||
expect(await repo.get("user_encrypted_dek_user-1")).toBeNull();
|
||||
expect(await repo.get("user_kek_salt_user-2")).toBe("other");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq, like } from "drizzle-orm";
|
||||
import { settings } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export class SettingsRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ value: settings.value })
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key))
|
||||
.limit(1);
|
||||
|
||||
return rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
async listAll(): Promise<Array<{ key: string; value: string }>> {
|
||||
return this.context.drizzle
|
||||
.select({ key: settings.key, value: settings.value })
|
||||
.from(settings);
|
||||
}
|
||||
|
||||
async getBoolean(key: string, fallback = false): Promise<boolean> {
|
||||
const value = await this.get(key);
|
||||
if (value === null) return fallback;
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
const existing = await this.get(key);
|
||||
if (existing === null) {
|
||||
await this.context.drizzle.insert(settings).values({ key, value });
|
||||
await this.afterWrite();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async upsert(key: string, value: string): Promise<void> {
|
||||
await this.set(key, value);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.context.drizzle.delete(settings).where(eq(settings.key, key));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteLike(pattern: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(settings)
|
||||
.where(like(settings.key, pattern))
|
||||
.returning({ key: settings.key });
|
||||
await this.afterWrite();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SqliteDatabaseAdapter } from "../runtime/sqlite-adapter.js";
|
||||
import { SharedCredentialRepository } from "./shared-credential-repository.js";
|
||||
|
||||
describe("SharedCredentialRepository", () => {
|
||||
let adapter: SqliteDatabaseAdapter | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<{
|
||||
repository: SharedCredentialRepository;
|
||||
sqlite: NonNullable<
|
||||
Awaited<ReturnType<SqliteDatabaseAdapter["connect"]>>["sqlite"]
|
||||
>;
|
||||
}> {
|
||||
adapter = new SqliteDatabaseAdapter({
|
||||
dialect: "sqlite",
|
||||
url: ":memory:",
|
||||
sqlitePath: ":memory:",
|
||||
});
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
CREATE TABLE shared_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
needs_re_encryption INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
INSERT INTO shared_credentials (
|
||||
id,
|
||||
host_access_id,
|
||||
original_credential_id,
|
||||
target_user_id,
|
||||
encrypted_username,
|
||||
encrypted_auth_type,
|
||||
encrypted_password,
|
||||
needs_re_encryption
|
||||
)
|
||||
VALUES
|
||||
(1, 10, 100, 'user-1', 'u1', 'password', 'p1', 0),
|
||||
(2, 10, 100, 'user-2', 'u2', 'password', 'p2', 1),
|
||||
(3, 11, 101, 'user-2', 'u3', 'key', NULL, 1);
|
||||
`);
|
||||
|
||||
return {
|
||||
repository: new SharedCredentialRepository(context, onWrite),
|
||||
sqlite: context.sqlite!,
|
||||
};
|
||||
}
|
||||
|
||||
it("checks existence and creates shared credentials", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.existsForHostAccessAndTargetUser(10, "user-1"),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
repository.existsForHostAccessAndTargetUser(10, "missing"),
|
||||
).resolves.toBe(false);
|
||||
|
||||
const created = await repository.create({
|
||||
hostAccessId: 12,
|
||||
originalCredentialId: 102,
|
||||
targetUserId: "user-3",
|
||||
encryptedUsername: "u4",
|
||||
encryptedAuthType: "password",
|
||||
encryptedPassword: "p4",
|
||||
needsReEncryption: false,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
hostAccessId: 12,
|
||||
originalCredentialId: 102,
|
||||
targetUserId: "user-3",
|
||||
encryptedUsername: "u4",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("finds, lists, and updates shared credentials", async () => {
|
||||
let writes = 0;
|
||||
const { repository } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(repository.findById(1)).resolves.toMatchObject({
|
||||
targetUserId: "user-1",
|
||||
});
|
||||
await expect(repository.findById(999)).resolves.toBeNull();
|
||||
await expect(
|
||||
repository.listByOriginalCredentialId(100),
|
||||
).resolves.toHaveLength(2);
|
||||
await expect(
|
||||
repository.listPendingByTargetUserId("user-2"),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 2 }),
|
||||
expect.objectContaining({ id: 3 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
repository.updateById(2, {
|
||||
encryptedPassword: "updated",
|
||||
needsReEncryption: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
encryptedPassword: "updated",
|
||||
needsReEncryption: false,
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("marks and deletes shared credentials", async () => {
|
||||
let writes = 0;
|
||||
const { repository, sqlite } = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.markNeedsReEncryptionByOriginalCredentialId(100),
|
||||
).resolves.toBe(2);
|
||||
await expect(repository.deleteByTargetUserId("user-1")).resolves.toBe(1);
|
||||
await expect(repository.deleteByOriginalCredentialId(101)).resolves.toBe(1);
|
||||
|
||||
expect(
|
||||
sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(),
|
||||
).toEqual([{ id: 2 }]);
|
||||
expect(writes).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { sharedCredentials } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "../runtime/adapter.js";
|
||||
|
||||
export type SharedCredentialRecord = typeof sharedCredentials.$inferSelect;
|
||||
export type NewSharedCredentialRecord = typeof sharedCredentials.$inferInsert;
|
||||
export type SharedCredentialUpdate = Partial<
|
||||
Omit<NewSharedCredentialRecord, "id">
|
||||
>;
|
||||
|
||||
export class SharedCredentialRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async existsForHostAccessAndTargetUser(
|
||||
hostAccessId: number,
|
||||
targetUserId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sharedCredentials.id })
|
||||
.from(sharedCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedCredentials.hostAccessId, hostAccessId),
|
||||
eq(sharedCredentials.targetUserId, targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async create(
|
||||
sharedCredential: NewSharedCredentialRecord,
|
||||
): Promise<SharedCredentialRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sharedCredentials)
|
||||
.values(sharedCredential)
|
||||
.returning();
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SharedCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(eq(sharedCredentials.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async listByOriginalCredentialId(
|
||||
credentialId: number,
|
||||
): Promise<SharedCredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId));
|
||||
}
|
||||
|
||||
async listPendingByTargetUserId(
|
||||
userId: string,
|
||||
): Promise<SharedCredentialRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedCredentials.targetUserId, userId),
|
||||
eq(sharedCredentials.needsReEncryption, true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateById(
|
||||
id: number,
|
||||
update: SharedCredentialUpdate,
|
||||
): Promise<SharedCredentialRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sharedCredentials)
|
||||
.set(update)
|
||||
.where(eq(sharedCredentials.id, id))
|
||||
.returning();
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async markNeedsReEncryptionByOriginalCredentialId(
|
||||
credentialId: number,
|
||||
): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.update(sharedCredentials)
|
||||
.set({ needsReEncryption: true })
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId))
|
||||
.returning({ id: sharedCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId))
|
||||
.returning({ id: sharedCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async deleteByTargetUserId(userId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.targetUserId, userId))
|
||||
.returning({ id: sharedCredentials.id });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user