mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e7c9ea53c | |||
| ed6cda4ea4 | |||
| b2c5b9cde3 | |||
| b7a1cffb52 | |||
| ced78961a0 | |||
| 0a16adf237 | |||
| 71a63ae5e8 | |||
| 39d40db8c6 | |||
| 84d4e9522a | |||
| 7da5c24457 | |||
| b2328ce2f7 | |||
| 287b0fa656 | |||
| 22124e1bc4 | |||
| 401ec7e8fc | |||
| 1ee4cacbe9 | |||
| fafff94e5e | |||
| 9d336bf9df | |||
| e1d1a3e53d | |||
| 4b2a60a854 | |||
| 3d42a2c09c | |||
| 9ed77d42d4 | |||
| c7a9063a72 | |||
| a0ae397e63 | |||
| b0598294a6 | |||
| 867058bddd | |||
| 6e66a5a4ef | |||
| 07d5f5a133 | |||
| d44695eb79 | |||
| c6a2ac69dc | |||
| a3e615b59c | |||
| f2b7dae234 | |||
| d33c90dd06 | |||
| 553cba5a78 | |||
| c733673150 | |||
| f9459d6ede | |||
| ccce77ddcc | |||
| 6f6908bdf4 | |||
| 1c18620629 | |||
| 033dd38344 | |||
| 732255333f | |||
| cf743bbea5 | |||
| 4613954857 | |||
| 55867ffd7b | |||
| bc4e42f59f | |||
| 85130dc056 | |||
| 4f05bd4fbd | |||
| 6b98b86969 | |||
| 37560fa133 | |||
| aef34fd966 | |||
| 11ecb66b33 | |||
| 50a6572ac1 | |||
| 1b5baf2351 | |||
| 38c5932aa9 | |||
| 37f9402060 | |||
| b9a995c9cb | |||
| 017be33974 | |||
| 579c5c675c | |||
| d908d9dc57 | |||
| 57effd2405 | |||
| ed29b114b9 | |||
| 2f0b002212 | |||
| d8bfe5d2b4 | |||
| f586b0c3b6 | |||
| 183005de84 | |||
| 5beadffc58 | |||
| 36a0fcfd6c | |||
| acdb40bd2b | |||
| e0725848a9 |
@@ -14,6 +14,10 @@ on:
|
|||||||
options:
|
options:
|
||||||
- Development
|
- Development
|
||||||
- Production
|
- Production
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build (defaults to the workflow ref)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -29,6 +33,11 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -37,8 +46,12 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Resolve source revision
|
||||||
|
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
with:
|
with:
|
||||||
@@ -98,7 +111,7 @@ jobs:
|
|||||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||||
org.opencontainers.image.created=${{ github.run_id }}
|
org.opencontainers.image.created=${{ github.run_id }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ on:
|
|||||||
- file
|
- file
|
||||||
- release
|
- release
|
||||||
- submit
|
- submit
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build (defaults to the workflow ref)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
build_type:
|
build_type:
|
||||||
@@ -38,6 +42,11 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: ""
|
default: ""
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
outputs:
|
outputs:
|
||||||
macos_universal_dmg_sha256:
|
macos_universal_dmg_sha256:
|
||||||
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
|
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
|
||||||
@@ -54,6 +63,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -144,6 +154,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -354,6 +365,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -580,6 +592,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
@@ -686,6 +699,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
@@ -823,6 +837,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
@@ -933,6 +948,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ jobs:
|
|||||||
|
|
||||||
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
|
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
|
||||||
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||||
echo "build_ref=main" >> "$GITHUB_OUTPUT"
|
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: [prep, merge-to-main]
|
needs: [prep, merge-to-main]
|
||||||
@@ -287,6 +287,7 @@ jobs:
|
|||||||
version: ${{ needs.prep.outputs.version }}
|
version: ${{ needs.prep.outputs.version }}
|
||||||
build_type: Production
|
build_type: Production
|
||||||
dry_run: ${{ inputs.mode == 'Dry run' }}
|
dry_run: ${{ inputs.mode == 'Dry run' }}
|
||||||
|
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
@@ -351,15 +352,17 @@ jobs:
|
|||||||
build_type: all
|
build_type: all
|
||||||
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
|
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||||
|
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
electron-submit:
|
electron-submit:
|
||||||
needs: [prep, electron-release]
|
needs: [prep, merge-to-main, electron-release]
|
||||||
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
|
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
|
||||||
uses: ./.github/workflows/electron.yml
|
uses: ./.github/workflows/electron.yml
|
||||||
with:
|
with:
|
||||||
build_type: all
|
build_type: all
|
||||||
artifact_destination: submit
|
artifact_destination: submit
|
||||||
|
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
cask-commit-back:
|
cask-commit-back:
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Install dependencies
|
# Stage 1: Install dependencies
|
||||||
FROM node:24-slim AS deps
|
FROM node:26-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3
|
|||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Production dependencies only
|
# Stage 4: Production dependencies only
|
||||||
FROM node:24-slim AS production-deps
|
FROM node:26-slim AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 5: Final optimized image
|
# Stage 5: Final optimized image
|
||||||
FROM node:24-slim
|
FROM node:26-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
GUACD_HOST: "guacd-dev"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd-dev
|
- guacd-dev
|
||||||
networks:
|
networks:
|
||||||
@@ -21,6 +23,8 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd-dev
|
container_name: guacd-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- termix-dev-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-dev-net
|
- termix-dev-net
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
GUACD_HOST: "guacd"
|
GUACD_HOST: "guacd"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd
|
- guacd
|
||||||
networks:
|
networks:
|
||||||
@@ -19,6 +20,8 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd
|
container_name: guacd
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- termix-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -235,6 +235,18 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/proxmox(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -433,8 +445,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
@@ -676,8 +688,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
|
|||||||
+4
-4
@@ -434,8 +434,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
@@ -677,8 +677,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
|
|||||||
+1
-1
@@ -1397,7 +1397,7 @@ ipcMain.handle(
|
|||||||
|
|
||||||
server.once("error", fail);
|
server.once("error", fail);
|
||||||
|
|
||||||
server.listen(callbackPort, "127.0.0.1", async () => {
|
server.listen(callbackPort, "localhost", async () => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal(authUrl);
|
await shell.openExternal(authUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Generated
+603
-642
File diff suppressed because it is too large
Load Diff
+31
-30
@@ -14,7 +14,7 @@
|
|||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"biome:check": "biome check biome.json package.json",
|
"biome:check": "biome check biome.json package.json",
|
||||||
"biome:fix": "biome check --write biome.json package.json",
|
"biome:fix": "biome check --write biome.json package.json",
|
||||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs",
|
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix .",
|
||||||
@@ -45,8 +45,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
"@types/ldapjs": "^3.0.6",
|
"@types/ldapjs": "^3.0.6",
|
||||||
"axios": "^1.18.0",
|
"axios": "^1.18.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"body-parser": "^2.3.0",
|
"body-parser": "^2.3.0",
|
||||||
@@ -58,28 +59,28 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"guacamole-lite": "^1.2.0",
|
"guacamole-lite": "^1.2.0",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"js-yaml": "^5.0.0",
|
"js-yaml": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.42.2",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nanoid": "^5.1.15",
|
"nanoid": "^5.1.16",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^8.5.0",
|
"undici": "^8.7.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.1",
|
"@biomejs/biome": "2.5.2",
|
||||||
"@codemirror/autocomplete": "^6.20.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.4",
|
||||||
"@codemirror/search": "^6.7.1",
|
"@codemirror/search": "^6.7.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.43.1",
|
"@codemirror/view": "^6.43.5",
|
||||||
"@commitlint/cli": "^21.0.2",
|
"@commitlint/cli": "^21.0.2",
|
||||||
"@commitlint/config-conventional": "^21.0.2",
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
@@ -91,23 +92,23 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/source-code-pro": "^5.2.7",
|
"@fontsource/source-code-pro": "^5.2.7",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.13",
|
"@radix-ui/react-accordion": "^1.2.15",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@radix-ui/react-alert-dialog": "^1.1.18",
|
||||||
"@radix-ui/react-checkbox": "^1.3.4",
|
"@radix-ui/react-checkbox": "^1.3.6",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-dialog": "^1.1.18",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-label": "^2.1.11",
|
||||||
"@radix-ui/react-popover": "^1.1.16",
|
"@radix-ui/react-popover": "^1.1.18",
|
||||||
"@radix-ui/react-progress": "^1.1.9",
|
"@radix-ui/react-progress": "^1.1.11",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||||
"@radix-ui/react-select": "^2.3.1",
|
"@radix-ui/react-select": "^2.3.2",
|
||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-separator": "^1.1.11",
|
||||||
"@radix-ui/react-slider": "^1.4.1",
|
"@radix-ui/react-slider": "^1.4.2",
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.3.1",
|
"@radix-ui/react-switch": "^1.3.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.16",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.11",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -130,7 +131,7 @@
|
|||||||
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
||||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||||
"@uiw/react-codemirror": "^4.25.9",
|
"@uiw/react-codemirror": "^4.25.9",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"@vitest/ui": "^4.1.9",
|
"@vitest/ui": "^4.1.9",
|
||||||
"@xterm/addon-clipboard": "^0.2.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"cytoscape": "^3.34.0",
|
"cytoscape": "^3.34.0",
|
||||||
"electron": "^42.4.1",
|
"electron": "^43.0.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.5.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -152,13 +153,13 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"guacamole-common-js": "^1.5.0",
|
"guacamole-common-js": "^1.5.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"lint-staged": "^17.0.8",
|
"lint-staged": "^17.0.8",
|
||||||
"lucide-react": "^1.20.0",
|
"lucide-react": "^1.20.0",
|
||||||
"prettier": "3.8.4",
|
"prettier": "3.8.4",
|
||||||
"radix-ui": "^1.6.0",
|
"radix-ui": "^1.6.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-cytoscapejs": "^2.0.0",
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -172,7 +173,7 @@
|
|||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sharp": "^0.35.2",
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,548 @@
|
|||||||
|
# Database Layer Refactor Phase 0 Audit
|
||||||
|
|
||||||
|
Status: Draft
|
||||||
|
Branch: `feature/database-layer-refactor`
|
||||||
|
Purpose: Establish the current database access inventory, domain map, sensitive field map, and first implementation boundaries before changing runtime persistence.
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
Phase 0 does not change runtime behavior.
|
||||||
|
|
||||||
|
It produces the evidence needed for the next implementation phases:
|
||||||
|
|
||||||
|
- where database access currently happens
|
||||||
|
- which modules write directly to the database
|
||||||
|
- which tables belong to which product domains
|
||||||
|
- which fields are sensitive
|
||||||
|
- which direct writes are risky under the current in-memory snapshot model
|
||||||
|
- which domains should move first into repositories
|
||||||
|
|
||||||
|
## 2. Current Evidence
|
||||||
|
|
||||||
|
Commands used for the initial audit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "getDb\\(|getSqlite\\(|DatabaseSaveTrigger|SimpleDBOps|db\\.\\$client|\\.prepare\\(" src/backend
|
||||||
|
rg -n "getDb\\(\\)\\.(insert|update|delete)|await db\\.(insert|update|delete)|db\\.(insert|update|delete)|\\.\\$client\\.prepare\\(\\\"(INSERT|UPDATE|DELETE)|\\.prepare\\(\\\"(INSERT|UPDATE|DELETE)|DatabaseSaveTrigger\\.triggerSave|DatabaseSaveTrigger\\.forceSave" src/backend
|
||||||
|
rg -n "export const .* = sqliteTable\\(" src/backend/database/db/schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
High-level findings:
|
||||||
|
|
||||||
|
- Database infrastructure is concentrated in `src/backend/database/db/index.ts`.
|
||||||
|
- Business database access is spread across route modules, SSH modules, utilities, and auth helpers.
|
||||||
|
- `SimpleDBOps` is not the only write path.
|
||||||
|
- There are many direct Drizzle writes and raw SQLite writes.
|
||||||
|
- Some direct writes manually trigger `DatabaseSaveTrigger`; many write paths do not.
|
||||||
|
- Schema is SQLite-specific through `sqliteTable` and manual `CREATE TABLE IF NOT EXISTS` / `addColumnIfNotExists`.
|
||||||
|
|
||||||
|
## 3. Database Access Hotspots
|
||||||
|
|
||||||
|
The most database-heavy files by audit hits:
|
||||||
|
|
||||||
|
| File | Approx. hits | Notes |
|
||||||
|
| ----------------------------------------------------- | -----------: | --------------------------------------------------------------- |
|
||||||
|
| `src/backend/database/db/index.ts` | 75 | database init, schema creation, ad-hoc migration, snapshot save |
|
||||||
|
| `src/backend/database/routes/alert-rules-routes.ts` | 64 | alert rules, channels, firings, raw SQL deletes |
|
||||||
|
| `src/backend/database/routes/users.ts` | 54 | users, settings, OIDC/GitHub settings, admin flows |
|
||||||
|
| `src/backend/database/database.ts` | 27 | import/export, legacy SQLite handling |
|
||||||
|
| `src/backend/ssh/host-metrics.ts` | 26 | metrics connection and settings reads |
|
||||||
|
| `src/backend/database/routes/user-settings-routes.ts` | 16 | user settings |
|
||||||
|
| `src/backend/dashboard.ts` | 15 | dashboard aggregation reads |
|
||||||
|
| `src/backend/ssh/docker.ts` | 14 | host/credential reads for Docker SSH |
|
||||||
|
| `src/backend/ssh/managers/health.ts` | 13 | host health checks |
|
||||||
|
| `src/backend/ssh/alert-engine.ts` | 13 | alert evaluation and firing state |
|
||||||
|
| `src/backend/utils/user-crypto.ts` | 12 | settings writes for crypto metadata |
|
||||||
|
| `src/backend/ssh/host-metrics-settings-routes.ts` | 12 | metrics settings |
|
||||||
|
| `src/backend/ssh/tmux-monitor.ts` | 11 | tmux session tags |
|
||||||
|
| `src/backend/guacamole/routes.ts` | 11 | Guacamole config/routes |
|
||||||
|
| `src/backend/database/routes/host.ts` | 10 | host operations and related rows |
|
||||||
|
|
||||||
|
This confirms the refactor must be domain-by-domain. A mechanical replacement of `getDb()` would be noisy and unsafe.
|
||||||
|
|
||||||
|
## 4. Direct Write Risk Inventory
|
||||||
|
|
||||||
|
Under the current architecture, a direct write is risky when it bypasses `SimpleDBOps` and does not trigger `DatabaseSaveTrigger`.
|
||||||
|
|
||||||
|
### 4.1 Direct Writes That Need Repository Ownership
|
||||||
|
|
||||||
|
These areas perform direct writes and should move behind repositories/services:
|
||||||
|
|
||||||
|
| Area | Representative files | Examples |
|
||||||
|
| --------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------- |
|
||||||
|
| users/settings/auth | `routes/users.ts`, `utils/auth-manager.ts`, `utils/user-crypto.ts` | sessions, trusted devices, OIDC settings, registration settings |
|
||||||
|
| RBAC/sharing | `routes/rbac.ts`, `utils/shared-credential-manager.ts` | roles, host access, snippet access, shared credentials |
|
||||||
|
| hosts/credentials | `routes/host.ts`, `routes/credentials.ts`, `host-resolver.ts` | host access cleanup, credential usage |
|
||||||
|
| file manager metadata | `host-file-manager-bookmark-routes.ts` | recent, pinned, shortcuts |
|
||||||
|
| alerts | `alert-rules-routes.ts`, `ssh/alert-engine.ts` | channels, rules, firings |
|
||||||
|
| metrics | `host-metrics-preferences-routes.ts`, `managers/health.ts` | preferences, health checks, history |
|
||||||
|
| terminal logs | `terminal-session-manager.ts` | session recording metadata |
|
||||||
|
| tmux | `tmux-monitor.ts` | tmux session tags |
|
||||||
|
| import/export | `database/database.ts` | SQLite import and forced save |
|
||||||
|
| open tabs | `routes/open-tabs.ts` | tab persistence and cleanup |
|
||||||
|
| API keys | `user-api-key-routes.ts`, `utils/auth-manager.ts` | API key create/delete/last-used |
|
||||||
|
| SSO and identity | `sso-provider-routes.ts`, `termix-id.ts` | providers, identity keys/CA |
|
||||||
|
|
||||||
|
### 4.2 Immediate Compatibility Rule
|
||||||
|
|
||||||
|
Until a domain is migrated to repositories:
|
||||||
|
|
||||||
|
- Every direct write must either be moved into a repository or explicitly trigger persistence in the old runtime.
|
||||||
|
- New code should not add direct `getDb()` writes outside infrastructure or repositories.
|
||||||
|
- The draft branch should add an enforcement check before Phase 8, not immediately, because the current codebase still violates the target rule widely.
|
||||||
|
|
||||||
|
## 5. Table Domain Map
|
||||||
|
|
||||||
|
### 5.1 Identity and Authentication
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| ---------------------- | -------------------------------------------- |
|
||||||
|
| `users` | local/OIDC users, TOTP fields, password hash |
|
||||||
|
| `sessions` | JWT sessions |
|
||||||
|
| `trusted_devices` | remembered devices |
|
||||||
|
| `api_keys` | API token hashes/prefixes |
|
||||||
|
| `sso_providers` | configured SSO providers |
|
||||||
|
| `termix_identities` | Termix identity records |
|
||||||
|
| `termix_identity_keys` | public/private identity key metadata |
|
||||||
|
| `termix_identity_ca` | CA material, private key is sensitive |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `userRepository`
|
||||||
|
- `sessionRepository`
|
||||||
|
- `trustedDeviceRepository`
|
||||||
|
- `apiKeyRepository`
|
||||||
|
- `ssoProviderRepository`
|
||||||
|
- `termixIdentityRepository`
|
||||||
|
|
||||||
|
### 5.2 Hosts and Credentials
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| ---------------------- | ------------------------------------------------ |
|
||||||
|
| `ssh_data` | primary host table, many config JSON/text fields |
|
||||||
|
| `ssh_credentials` | reusable credentials |
|
||||||
|
| `ssh_credential_usage` | usage history |
|
||||||
|
| `ssh_folders` | folder metadata |
|
||||||
|
| `host_access` | sharing/RBAC access rows |
|
||||||
|
| `shared_credentials` | encrypted shared credential material |
|
||||||
|
| `network_topology` | topology graph/config |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `hostRepository`
|
||||||
|
- `credentialRepository`
|
||||||
|
- `credentialUsageRepository`
|
||||||
|
- `hostAccessRepository`
|
||||||
|
- `sharedCredentialRepository`
|
||||||
|
- `hostFolderRepository`
|
||||||
|
- `networkTopologyRepository`
|
||||||
|
|
||||||
|
### 5.3 RBAC
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| ---------------- | ------------------------ |
|
||||||
|
| `roles` | role definitions |
|
||||||
|
| `user_roles` | user to role assignments |
|
||||||
|
| `host_access` | host-level grants |
|
||||||
|
| `snippet_access` | snippet-level grants |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `roleRepository`
|
||||||
|
- `accessRepository`
|
||||||
|
|
||||||
|
### 5.4 File Manager
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| ------------------------ | ---------------------------------- |
|
||||||
|
| `file_manager_recent` | recently opened paths |
|
||||||
|
| `file_manager_pinned` | pinned paths |
|
||||||
|
| `file_manager_shortcuts` | saved shortcuts |
|
||||||
|
| `transfer_recent` | host-to-host transfer destinations |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `fileManagerRepository`
|
||||||
|
- `transferRecentRepository`
|
||||||
|
|
||||||
|
### 5.5 Snippets
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| ----------------- | ----------------------- |
|
||||||
|
| `snippets` | command snippets |
|
||||||
|
| `snippet_folders` | snippet folder metadata |
|
||||||
|
| `snippet_access` | snippet sharing |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `snippetRepository`
|
||||||
|
|
||||||
|
### 5.6 Runtime Metadata and Audit
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| -------------------- | ------------------------------------------------------ |
|
||||||
|
| `audit_logs` | append-only audit trail |
|
||||||
|
| `session_recordings` | terminal recording metadata, log content is file-based |
|
||||||
|
| `recent_activity` | host activity feed |
|
||||||
|
| `command_history` | terminal command history |
|
||||||
|
| `user_open_tabs` | UI restore state; currently cleared on startup |
|
||||||
|
| `user_preferences` | per-user UI/preferences |
|
||||||
|
| `settings` | global settings |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `auditRepository`
|
||||||
|
- `sessionRecordingRepository`
|
||||||
|
- `activityRepository`
|
||||||
|
- `commandHistoryRepository`
|
||||||
|
- `openTabsRepository`
|
||||||
|
- `userPreferencesRepository`
|
||||||
|
- `settingsRepository`
|
||||||
|
|
||||||
|
### 5.7 Metrics and Alerts
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| -------------------------- | -------------------------- |
|
||||||
|
| `host_metrics_preferences` | metrics layout/preferences |
|
||||||
|
| `host_health_checks` | health check definitions |
|
||||||
|
| `host_health_history` | health check results |
|
||||||
|
| `host_metrics_history` | metrics history |
|
||||||
|
| `alert_rules` | alert definitions |
|
||||||
|
| `notification_channels` | webhook/ntfy/etc config |
|
||||||
|
| `alert_rule_channels` | rule/channel joins |
|
||||||
|
| `alert_firings` | firing/ack state |
|
||||||
|
| `dismissed_alerts` | dismissed system alerts |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `metricsRepository`
|
||||||
|
- `healthCheckRepository`
|
||||||
|
- `alertRepository`
|
||||||
|
- `notificationRepository`
|
||||||
|
|
||||||
|
### 5.8 Integrations and Feature Config
|
||||||
|
|
||||||
|
| Tables | Notes |
|
||||||
|
| ------------------------- | --------------------------------------- |
|
||||||
|
| `c2s_tunnel_presets` | tunnel preset config |
|
||||||
|
| `opkssh_tokens` | OPKSSH cert/private key cache |
|
||||||
|
| `vault_profiles` | Vault profile config, mostly non-secret |
|
||||||
|
| `vault_tokens` | Vault cert/private key cache |
|
||||||
|
| `dashboard_service_links` | dashboard links |
|
||||||
|
| `homepage_items` | homepage widgets/items |
|
||||||
|
| `homepage_layouts` | homepage layouts |
|
||||||
|
| `tmux_session_tags` | tmux tag metadata |
|
||||||
|
|
||||||
|
Suggested repository:
|
||||||
|
|
||||||
|
- `tunnelPresetRepository`
|
||||||
|
- `opksshTokenRepository`
|
||||||
|
- `vaultRepository`
|
||||||
|
- `dashboardRepository`
|
||||||
|
- `homepageRepository`
|
||||||
|
- `tmuxRepository`
|
||||||
|
|
||||||
|
## 6. Sensitive Field Map
|
||||||
|
|
||||||
|
The current explicit `FieldCrypto` map encrypts:
|
||||||
|
|
||||||
|
| Table | Fields |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `users` | `passwordHash`, `clientSecret`, `totpSecret`, `totpBackupCodes`, `oidcIdentifier` |
|
||||||
|
| `ssh_data` | `password`, `key`, `keyPassword`, `sudoPassword`, `autostartPassword`, `autostartKey`, `autostartKeyPassword`, `socks5Password`, `rdpPassword`, `vncPassword`, `telnetPassword` |
|
||||||
|
| `ssh_credentials` | `password`, `privateKey`, `keyPassword`, `key`, `publicKey` |
|
||||||
|
| `opkssh_tokens` | `sshCert`, `privateKey` |
|
||||||
|
| `termix_identity_ca` | `privateKey` |
|
||||||
|
| `vault_tokens` | `sshCert`, `privateKey` |
|
||||||
|
|
||||||
|
Additional sensitive fields by table semantics:
|
||||||
|
|
||||||
|
| Table | Fields / reason |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ssh_credentials` | `systemPassword`, `systemKey`, `systemKeyPassword` are system-key encrypted credential copies |
|
||||||
|
| `shared_credentials` | `encryptedUsername`, `encryptedAuthType`, `encryptedPassword`, `encryptedKey`, `encryptedKeyPassword`, `encryptedKeyType` are already encrypted payload fields |
|
||||||
|
| `api_keys` | `tokenHash` is not plaintext but is authentication material; `tokenPrefix` may remain plaintext for display |
|
||||||
|
| `settings` | some keys may hold provider secrets or reset codes; repository must classify by key |
|
||||||
|
| `notification_channels` | config may include webhook URLs/tokens; treat config as sensitive unless split |
|
||||||
|
| `alert_rules` | rule definitions are usually not secret but can include host/resource metadata |
|
||||||
|
| `homepage_items` | widget config can include URLs/API config; classify per widget type |
|
||||||
|
| `c2s_tunnel_presets` | config can include connection details; review before plaintext external DB storage |
|
||||||
|
| `termix_identity_keys` | inspect key material fields before migration; public keys are not secret but private material must never be plaintext |
|
||||||
|
| `vault_profiles` | current comments say profile fields are non-secret; keep that invariant explicit |
|
||||||
|
|
||||||
|
Open privacy decision:
|
||||||
|
|
||||||
|
- `ssh_data.ip`, domain fields, usernames, folders, and tags are queryable today.
|
||||||
|
- Encrypting them improves confidentiality but breaks search/filter/sort unless blind indexes are added.
|
||||||
|
- Recommended initial migration: keep them plaintext and document privacy implications; add optional privacy mode later.
|
||||||
|
|
||||||
|
## 7. Repository Migration Order
|
||||||
|
|
||||||
|
Use a vertical slice instead of broad replacement.
|
||||||
|
|
||||||
|
### 7.1 First Slice
|
||||||
|
|
||||||
|
1. `settingsRepository`
|
||||||
|
2. `userRepository`
|
||||||
|
3. `sessionRepository`
|
||||||
|
4. `hostRepository`
|
||||||
|
5. `credentialRepository`
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- Covers the app's boot/login/core host management path.
|
||||||
|
- Exercises field encryption.
|
||||||
|
- Exercises per-user data unlock requirements.
|
||||||
|
- Exercises transaction and migration behavior.
|
||||||
|
- Builds reusable patterns for the rest of the backend.
|
||||||
|
|
||||||
|
### 7.2 Second Slice
|
||||||
|
|
||||||
|
1. `hostAccessRepository`
|
||||||
|
2. `roleRepository`
|
||||||
|
3. `sharedCredentialRepository`
|
||||||
|
4. `auditRepository`
|
||||||
|
5. `userPreferencesRepository`
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- Completes permission and sharing boundaries.
|
||||||
|
- Removes high-risk direct writes in admin/RBAC flows.
|
||||||
|
- Moves audit to an append-only repository.
|
||||||
|
|
||||||
|
### 7.3 Third Slice
|
||||||
|
|
||||||
|
1. `snippetRepository`
|
||||||
|
2. `fileManagerRepository`
|
||||||
|
3. `metricsRepository`
|
||||||
|
4. `alertRepository`
|
||||||
|
5. `homepageRepository`
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- These are broad feature domains with many routes.
|
||||||
|
- They should reuse patterns from the core slices.
|
||||||
|
|
||||||
|
### 7.4 Fourth Slice
|
||||||
|
|
||||||
|
1. `vaultRepository`
|
||||||
|
2. `opksshTokenRepository`
|
||||||
|
3. `termixIdentityRepository`
|
||||||
|
4. `tunnelPresetRepository`
|
||||||
|
5. `tmuxRepository`
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- Sensitive token/key cache domains need careful encryption tests.
|
||||||
|
- Some data is transient and should have retention cleanup.
|
||||||
|
|
||||||
|
## 8. Adapter Design Notes
|
||||||
|
|
||||||
|
The adapter boundary should expose:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface DatabaseAdapter {
|
||||||
|
dialect: "sqlite" | "postgres" | "mysql";
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
migrate(): Promise<void>;
|
||||||
|
transaction<T>(fn: (tx: DatabaseTransaction) => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The repository layer should not depend on `better-sqlite3`.
|
||||||
|
|
||||||
|
For Drizzle, likely options:
|
||||||
|
|
||||||
|
- keep dialect-specific Drizzle clients internally
|
||||||
|
- expose repositories instead of exposing the raw Drizzle client
|
||||||
|
- keep schema definitions close to migrations, not route handlers
|
||||||
|
|
||||||
|
Important: do not make route modules import dialect-specific schema objects after migration.
|
||||||
|
|
||||||
|
## 9. Compatibility Shims
|
||||||
|
|
||||||
|
During the migration, avoid a flag day.
|
||||||
|
|
||||||
|
Add a compatibility database module that lets old code continue to run while new repositories are introduced:
|
||||||
|
|
||||||
|
```text
|
||||||
|
legacy getDb() path
|
||||||
|
new adapter/repository path
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- New modules use repositories only.
|
||||||
|
- Migrated modules must not fall back to `getDb()`.
|
||||||
|
- Legacy path is removed only after all domains move.
|
||||||
|
|
||||||
|
## 10. Phase 1 Entry Criteria
|
||||||
|
|
||||||
|
Before implementing the adapter skeleton:
|
||||||
|
|
||||||
|
- This audit document exists.
|
||||||
|
- The sensitive field map is reviewed.
|
||||||
|
- The first vertical slice is accepted.
|
||||||
|
- The draft PR remains draft.
|
||||||
|
- No runtime behavior has changed.
|
||||||
|
|
||||||
|
## 11. Phase 1 Deliverables
|
||||||
|
|
||||||
|
Recommended first implementation PR on this branch:
|
||||||
|
|
||||||
|
- `src/backend/database/runtime/config.ts`
|
||||||
|
- `src/backend/database/runtime/adapter.ts`
|
||||||
|
- `src/backend/database/runtime/sqlite-adapter.ts`
|
||||||
|
- `src/backend/database/repositories/settings-repository.ts`
|
||||||
|
- `src/backend/database/repositories/user-repository.ts`
|
||||||
|
- `src/backend/database/repositories/session-repository.ts`
|
||||||
|
- `src/backend/database/repositories/host-repository.ts`
|
||||||
|
- `src/backend/database/repositories/credential-repository.ts`
|
||||||
|
- `src/backend/database/repositories/field-encryption-boundary.ts`
|
||||||
|
- `src/backend/database/repositories/current-settings-repository.ts`
|
||||||
|
- tests for config parsing and SQLite adapter boot
|
||||||
|
|
||||||
|
Started:
|
||||||
|
|
||||||
|
- runtime config parser
|
||||||
|
- SQLite adapter skeleton
|
||||||
|
- migration metadata table bootstrap
|
||||||
|
- `SettingsRepository` skeleton and tests
|
||||||
|
- `UserRepository` and `SessionRepository` skeletons and tests
|
||||||
|
- `HostRepository` and `CredentialRepository` skeletons and tests
|
||||||
|
- `FieldEncryptionBoundary` skeleton and tests
|
||||||
|
- first settings route slice wired through `SettingsRepository`
|
||||||
|
- user settings route direct `settings` table access moved behind
|
||||||
|
`SettingsRepository`
|
||||||
|
- host metrics settings route direct `settings` table access moved behind
|
||||||
|
`SettingsRepository`
|
||||||
|
- ACME SSL settings route direct `settings` table access moved behind
|
||||||
|
`SettingsRepository`
|
||||||
|
- terminal route direct `settings` table access moved behind
|
||||||
|
`SettingsRepository`
|
||||||
|
- tailscale route direct `settings` table access moved behind
|
||||||
|
`SettingsRepository`
|
||||||
|
- Guacamole route and WebSocket server direct `settings` table access moved
|
||||||
|
behind the current settings repository boundary
|
||||||
|
- auth token expiry and terminal session timeout settings reads moved behind the
|
||||||
|
current settings repository boundary
|
||||||
|
- open tabs, TOTP, and LDAP auth route settings reads moved behind the current
|
||||||
|
settings repository boundary
|
||||||
|
- host metrics polling settings reads moved behind the current settings
|
||||||
|
repository boundary
|
||||||
|
- backend startup settings reads moved behind the current settings repository
|
||||||
|
boundary
|
||||||
|
- user deletion cleanup now removes per-user settings through
|
||||||
|
`SettingsRepository.deleteLike`
|
||||||
|
- password reset route reset code and temporary token settings access moved
|
||||||
|
behind `SettingsRepository`
|
||||||
|
- OIDC utility legacy config fallback reads `oidc_config` through
|
||||||
|
`SettingsRepository`
|
||||||
|
- user route registration/password flags and OIDC config administration moved
|
||||||
|
behind the current settings repository boundary
|
||||||
|
- OIDC authorize/callback temporary state and auto-provision reads moved behind
|
||||||
|
the current settings repository boundary
|
||||||
|
- user login settings reads moved behind the current settings repository
|
||||||
|
boundary, completing direct `settings` access cleanup in `routes/users.ts`
|
||||||
|
- user encryption metadata in `utils/user-crypto.ts` moved behind the current
|
||||||
|
settings repository boundary
|
||||||
|
- database startup and schema migration defaults in `database/db/index.ts`
|
||||||
|
moved to local raw settings helpers
|
||||||
|
- database import/export settings handling in `database/database.ts` moved to
|
||||||
|
local helper boundaries
|
||||||
|
- core `auth-manager.ts` session create/read/update/revoke/list paths started
|
||||||
|
using the current session repository boundary
|
||||||
|
- remaining `auth-manager.ts` session cleanup/middleware/logout paths and
|
||||||
|
`user-session-routes.ts` single-session lookup moved behind the current
|
||||||
|
session repository boundary
|
||||||
|
- current user repository factory/write-save hook added, and
|
||||||
|
`user-admin-routes.ts` list/admin promotion/admin removal/admin-create user
|
||||||
|
paths moved behind the current user repository boundary
|
||||||
|
- low-risk `routes/users.ts` current-user lookup and admin gate checks moved
|
||||||
|
behind the current user repository boundary
|
||||||
|
- user registration, self-delete, password change hash updates, and admin
|
||||||
|
delete-user lookup paths in `routes/users.ts` moved behind the current user
|
||||||
|
repository boundary, with first-user admin creation kept transactional inside
|
||||||
|
`UserRepository`
|
||||||
|
- traditional login username lookup and `auth-manager.ts` admin user checks
|
||||||
|
moved behind the current user repository boundary
|
||||||
|
- GitHub and standard OIDC callback user lookup/create/rollback/profile/admin
|
||||||
|
sync writes moved behind the current user repository boundary, removing direct
|
||||||
|
Drizzle `users` table access from `routes/users.ts`, `user-admin-routes.ts`,
|
||||||
|
and `auth-manager.ts`
|
||||||
|
- API key create/list/delete and API key authentication last-used updates moved
|
||||||
|
behind the current API key repository boundary
|
||||||
|
- trusted device check/add/remove and TOTP trusted-device cleanup moved behind
|
||||||
|
the current trusted device repository boundary
|
||||||
|
- user session routes moved user/admin lookups and admin session username
|
||||||
|
enrichment behind the current user repository boundary
|
||||||
|
- vault admin checks, Termix ID audit username lookup, permission manager admin
|
||||||
|
checks, and user data export user lookup moved behind the current user
|
||||||
|
repository boundary
|
||||||
|
- SSH credential OIDC username expansion and tmux monitor audit actor username
|
||||||
|
lookup moved behind the current user repository boundary
|
||||||
|
- user settings route admin checks and audit actor username lookups moved behind
|
||||||
|
the current user repository boundary
|
||||||
|
- ACME SSL route admin checks and audit actor username lookups moved behind the
|
||||||
|
current user repository boundary
|
||||||
|
- audit log route admin checks moved behind the current user repository boundary
|
||||||
|
- OIDC account link/unlink route user lookups and OIDC field updates moved
|
||||||
|
behind the current user repository boundary
|
||||||
|
- password reset route user lookups, password hash updates, and TOTP reset
|
||||||
|
fields moved behind the current user repository boundary
|
||||||
|
- user deletion helper now removes sessions through the current session
|
||||||
|
repository and the final user record through the current user repository
|
||||||
|
- snippet create/update/delete audit actor username lookups moved behind the
|
||||||
|
current user repository boundary
|
||||||
|
- LDAP login existing-user lookup, encryption rollback delete, admin sync, and
|
||||||
|
display-name sync moved behind the current user repository boundary
|
||||||
|
- TOTP setup/enable/disable/backup-code/login verification user updates and
|
||||||
|
session revocation moved behind the current user/session repository
|
||||||
|
boundaries
|
||||||
|
- RBAC host sharing, role assignment, and snippet sharing target-user existence
|
||||||
|
checks moved behind the current user repository boundary, with existing
|
||||||
|
RBAC/snippet owner username joins retained
|
||||||
|
- RBAC role list/create/update/delete, user-role assignment/removal/listing,
|
||||||
|
and shared host/snippet role-id lookups moved behind the current role
|
||||||
|
repository boundary, with existing RBAC/share credential joins retained
|
||||||
|
- permission manager role permission aggregation, role-id lookups for shared
|
||||||
|
host access, and admin role checks moved behind the current role repository
|
||||||
|
boundary
|
||||||
|
- RBAC host/snippet access-list read models moved behind the current RBAC access
|
||||||
|
repository boundary, and snippet route shared-access role-id lookups moved
|
||||||
|
behind the current role repository boundary
|
||||||
|
- RBAC shared host/shared snippet read models and the main snippet
|
||||||
|
shared-snippet read model moved behind the current RBAC access repository
|
||||||
|
boundary
|
||||||
|
- RBAC host/snippet access grant, revoke, and direct host-access credential
|
||||||
|
override writes moved behind the current RBAC access repository boundary,
|
||||||
|
with shared credential material creation retained in the existing manager
|
||||||
|
- permission manager host-access expiration cleanup, shared host-access lookup,
|
||||||
|
and last-access timestamp updates moved behind the current RBAC access
|
||||||
|
repository boundary
|
||||||
|
- repository rollout guard added through `DATABASE_LAYER_REPOSITORY_ROLLOUT`
|
||||||
|
for the migrated settings/users/sessions/API-key/trusted-device/role/RBAC-access
|
||||||
|
slice
|
||||||
|
|
||||||
|
Keep it small. Do not wire host or credential routes into the new repositories in
|
||||||
|
the same first implementation commit.
|
||||||
|
|
||||||
|
## 12. Current Unknowns
|
||||||
|
|
||||||
|
- Exact Drizzle multi-dialect strategy needs a spike.
|
||||||
|
- The project may need a migration generator or a custom migration runner.
|
||||||
|
- Some raw SQL in `routes/users.ts`, `alert-rules-routes.ts`, and `db/index.ts` must be rewritten or isolated.
|
||||||
|
- `settings` contains mixed public and sensitive values; key-level classification is required.
|
||||||
|
- `homepage_items.config`, `notification_channels.config`, and tunnel configs may contain embedded secrets.
|
||||||
|
- Legacy encrypted snapshot fixtures need to be created before migration implementation.
|
||||||
|
|
||||||
|
## 13. Decision Log
|
||||||
|
|
||||||
|
- Default upgrade target should be persistent SQLite, not PostgreSQL/MySQL.
|
||||||
|
- PostgreSQL/MySQL migration should be explicit and initially manual/experimental.
|
||||||
|
- Runtime SSH/WebSocket/tunnel state remains memory-only.
|
||||||
|
- Field-level encryption is mandatory for every database backend.
|
||||||
|
- Field-level encryption must use a stable record id; temporary encryption
|
||||||
|
contexts are forbidden for newly written repository data.
|
||||||
|
- Repository migration should start with settings/users/sessions/hosts/credentials.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -26,10 +26,31 @@ if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
|||||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||||
|
|
||||||
// Patch 1: version acceptance list
|
// Patch 1: protocol version negotiation.
|
||||||
const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
|
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||||
const newVersionCheck =
|
// versions Termix can handle, and conservatively answer future 1.x versions as
|
||||||
"if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
|
// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
|
||||||
|
// claiming support for unknown instructions.
|
||||||
|
const oldVersionBlock =
|
||||||
|
" if (version === '1_0_0' || version === '1_1_0') {\n" +
|
||||||
|
" protocolVersion = version;\n" +
|
||||||
|
" } else {\n" +
|
||||||
|
" protocolVersion = '1_1_0';\n" +
|
||||||
|
" }";
|
||||||
|
const oldPatchedVersionBlock =
|
||||||
|
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
|
||||||
|
" protocolVersion = version;\n" +
|
||||||
|
" } else {\n" +
|
||||||
|
" protocolVersion = '1_1_0';\n" +
|
||||||
|
" }";
|
||||||
|
const newVersionBlock =
|
||||||
|
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
|
||||||
|
" protocolVersion = version;\n" +
|
||||||
|
" } else if (/^1_\\d+_0$/.test(version)) {\n" +
|
||||||
|
" protocolVersion = '1_5_0';\n" +
|
||||||
|
" } else {\n" +
|
||||||
|
" protocolVersion = '1_1_0';\n" +
|
||||||
|
" }";
|
||||||
|
|
||||||
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
|
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
|
||||||
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||||
@@ -105,17 +126,23 @@ const newReadyHandler =
|
|||||||
|
|
||||||
let patched = false;
|
let patched = false;
|
||||||
|
|
||||||
if (!guacdClientContent.includes(newVersionCheck)) {
|
if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
|
||||||
if (!guacdClientContent.includes(oldVersionCheck)) {
|
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
|
||||||
|
guacdClientContent = guacdClientContent.replace(
|
||||||
|
oldPatchedVersionBlock,
|
||||||
|
newVersionBlock,
|
||||||
|
);
|
||||||
|
} else if (guacdClientContent.includes(oldVersionBlock)) {
|
||||||
|
guacdClientContent = guacdClientContent.replace(
|
||||||
|
oldVersionBlock,
|
||||||
|
newVersionBlock,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
"[patch-guacamole-lite] Version check target not found, skipping",
|
"[patch-guacamole-lite] Version check target not found, skipping",
|
||||||
);
|
);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
guacdClientContent = guacdClientContent.replace(
|
|
||||||
oldVersionCheck,
|
|
||||||
newVersionCheck,
|
|
||||||
);
|
|
||||||
patched = true;
|
patched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
|
||||||
|
|
||||||
|
type PatchedGuacdClient = {
|
||||||
|
connectionSettings: Record<string, unknown>;
|
||||||
|
nextArgumentStreamIndex: number;
|
||||||
|
sendInstruction: ReturnType<typeof vi.fn>;
|
||||||
|
sendHandshakeReply: (serverHandshake: string[]) => void;
|
||||||
|
sendRequiredArguments: (params: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createPatchedClient(
|
||||||
|
connectionSettings: Record<string, unknown>,
|
||||||
|
): PatchedGuacdClient {
|
||||||
|
return Object.assign(Object.create(GuacdClient.prototype), {
|
||||||
|
connectionSettings,
|
||||||
|
logger: { log: vi.fn() },
|
||||||
|
nextArgumentStreamIndex: 0,
|
||||||
|
sendInstruction: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("patch-guacamole-lite", () => {
|
describe("patch-guacamole-lite", () => {
|
||||||
it("handles guacd dynamic argument requests", () => {
|
it("handles guacd dynamic argument requests", () => {
|
||||||
@@ -20,4 +43,49 @@ describe("patch-guacamole-lite", () => {
|
|||||||
expect(content).toContain("this.sendInstruction(['blob'");
|
expect(content).toContain("this.sendInstruction(['blob'");
|
||||||
expect(content).toContain("this.sendInstruction(['end'");
|
expect(content).toContain("this.sendInstruction(['end'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps required-argument support when guacd offers a future 1.x protocol", () => {
|
||||||
|
const client = createPatchedClient({
|
||||||
|
hostname: "192.0.2.10",
|
||||||
|
port: 5900,
|
||||||
|
password: "secret",
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
dpi: 96,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.sendHandshakeReply(["VERSION_1_6_0", "hostname", "port"]);
|
||||||
|
|
||||||
|
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
|
||||||
|
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||||
|
"name",
|
||||||
|
"guacamole-lite",
|
||||||
|
]);
|
||||||
|
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||||
|
"connect",
|
||||||
|
"VERSION_1_5_0",
|
||||||
|
"192.0.2.10",
|
||||||
|
5900,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers required credentials through argument value streams", () => {
|
||||||
|
const client = createPatchedClient({
|
||||||
|
username: "",
|
||||||
|
password: "secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
client.sendRequiredArguments(["username", "password"]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
client.sendInstruction.mock.calls.map(([instruction]) => instruction),
|
||||||
|
).toEqual([
|
||||||
|
["argv", 0, "text/plain", "username"],
|
||||||
|
["blob", 0, ""],
|
||||||
|
["end", 0],
|
||||||
|
["argv", 1, "text/plain", "password"],
|
||||||
|
["blob", 1, Buffer.from("secret", "utf8").toString("base64")],
|
||||||
|
["end", 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const xtermDir = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"node_modules",
|
||||||
|
"@xterm",
|
||||||
|
"xterm",
|
||||||
|
"lib",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
|
||||||
|
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
|
||||||
|
// composition on the previous word and replace it with a shorter value (for
|
||||||
|
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
|
||||||
|
const patches = [
|
||||||
|
{
|
||||||
|
file: "xterm.mjs",
|
||||||
|
replacements: [
|
||||||
|
[
|
||||||
|
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||||
|
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||||
|
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||||
|
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
|
||||||
|
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "xterm.js",
|
||||||
|
replacements: [
|
||||||
|
[
|
||||||
|
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||||
|
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||||
|
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||||
|
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
|
||||||
|
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { file, replacements } of patches) {
|
||||||
|
const filePath = path.join(xtermDir, file);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = fs.readFileSync(filePath, "utf8");
|
||||||
|
if (source.includes("_preCompositionValue")) {
|
||||||
|
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [original, patched] of replacements) {
|
||||||
|
if (!source.includes(original)) {
|
||||||
|
throw new Error(
|
||||||
|
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
source = source.replace(original, patched);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, source);
|
||||||
|
console.log(`[patch-xterm-android-ime] Patched ${file}`);
|
||||||
|
}
|
||||||
+38
-102
@@ -1,19 +1,15 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { createCorsMiddleware } from "./utils/cors-config.js";
|
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 { dashboardLogger } from "./utils/logger.js";
|
||||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
|
||||||
import { AuthManager } from "./utils/auth-manager.js";
|
import { AuthManager } from "./utils/auth-manager.js";
|
||||||
import type { AuthenticatedRequest } from "../types/index.js";
|
import type { AuthenticatedRequest } from "../types/index.js";
|
||||||
import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.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 app = express();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -23,6 +19,10 @@ const serverStartTime = Date.now();
|
|||||||
const activityRateLimiter = new Map<string, number>();
|
const activityRateLimiter = new Map<string, number>();
|
||||||
const RATE_LIMIT_MS = 1000;
|
const RATE_LIMIT_MS = 1000;
|
||||||
|
|
||||||
|
function isUserDataUnlocked(userId: string): boolean {
|
||||||
|
return DataCrypto.getUserDataKey(userId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
app.use(createCorsMiddleware());
|
app.use(createCorsMiddleware());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
@@ -103,7 +103,7 @@ app.get("/activity/recent", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!isUserDataUnlocked(userId)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED",
|
code: "SESSION_EXPIRED",
|
||||||
@@ -112,16 +112,8 @@ app.get("/activity/recent", async (req, res) => {
|
|||||||
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
const limit = Number(req.query.limit) || 20;
|
||||||
|
|
||||||
const activities = await SimpleDBOps.select(
|
const activities =
|
||||||
getDb()
|
await createCurrentRecentActivityRepository().listByUserId(userId, limit);
|
||||||
.select()
|
|
||||||
.from(recentActivity)
|
|
||||||
.where(eq(recentActivity.userId, userId))
|
|
||||||
.orderBy(desc(recentActivity.timestamp))
|
|
||||||
.limit(limit),
|
|
||||||
"recent_activity",
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(activities);
|
res.json(activities);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -168,7 +160,7 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!isUserDataUnlocked(userId)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED",
|
code: "SESSION_EXPIRED",
|
||||||
@@ -223,97 +215,45 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownedHosts = await SimpleDBOps.select(
|
const isOwnedHost =
|
||||||
getDb()
|
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
||||||
.select()
|
hostId,
|
||||||
.from(hosts)
|
userId,
|
||||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
);
|
||||||
"ssh_data",
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ownedHosts.length === 0) {
|
if (!isOwnedHost) {
|
||||||
const userRoleIds = await getDb()
|
const roleIds =
|
||||||
.select({ roleId: userRoles.roleId })
|
await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
.from(userRoles)
|
const sharedHosts =
|
||||||
.where(eq(userRoles.userId, userId));
|
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||||
const roleIds = userRoleIds.map((r) => r.roleId);
|
userId,
|
||||||
|
roleIds,
|
||||||
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)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
const hasSharedAccess = sharedHosts.some(
|
||||||
|
(access) => access.hostId === hostId,
|
||||||
|
);
|
||||||
|
|
||||||
if (sharedHosts.length === 0) {
|
if (!hasSharedAccess) {
|
||||||
return res
|
return res
|
||||||
.status(404)
|
.status(404)
|
||||||
.json({ error: "Host not found or access denied" });
|
.json({ error: "Host not found or access denied" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = (await SimpleDBOps.insert(
|
const result = await createCurrentRecentActivityRepository().create({
|
||||||
recentActivity,
|
|
||||||
"recent_activity",
|
|
||||||
{
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
hostId,
|
|
||||||
hostName,
|
|
||||||
},
|
|
||||||
userId,
|
userId,
|
||||||
)) as unknown as { id: number };
|
type,
|
||||||
|
hostId,
|
||||||
|
hostName,
|
||||||
|
});
|
||||||
|
|
||||||
// Best-effort trim of old activity entries; failures here should not
|
// Best-effort trim of old activity entries; failures here should not
|
||||||
// cause the primary /activity/log request to 500.
|
// cause the primary /activity/log request to 500.
|
||||||
try {
|
try {
|
||||||
const allActivities = await SimpleDBOps.select<{
|
await createCurrentRecentActivityRepository().trimUserActivity(
|
||||||
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",
|
|
||||||
userId,
|
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) {
|
} catch (trimErr) {
|
||||||
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
||||||
operation: "trim_recent_activity",
|
operation: "trim_recent_activity",
|
||||||
@@ -349,18 +289,14 @@ app.delete("/activity/reset", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!isUserDataUnlocked(userId)) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: "Session expired - please log in again",
|
error: "Session expired - please log in again",
|
||||||
code: "SESSION_EXPIRED",
|
code: "SESSION_EXPIRED",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await SimpleDBOps.delete(
|
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||||
recentActivity,
|
|
||||||
"recent_activity",
|
|
||||||
eq(recentActivity.userId, userId),
|
|
||||||
);
|
|
||||||
|
|
||||||
dashboardLogger.success("Recent activity cleared", {
|
dashboardLogger.success("Recent activity cleared", {
|
||||||
operation: "reset_recent_activity",
|
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 { DatabaseMigration } from "../utils/database-migration.js";
|
||||||
import { UserDataExport } from "../utils/user-data-export.js";
|
import { UserDataExport } from "../utils/user-data-export.js";
|
||||||
import { AutoSSLSetup } from "../utils/auto-ssl-setup.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 { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||||
import { getProxyAgent } from "../utils/proxy-agent.js";
|
import { getProxyAgent } from "../utils/proxy-agent.js";
|
||||||
import {
|
|
||||||
users,
|
|
||||||
hosts,
|
|
||||||
sshCredentials,
|
|
||||||
fileManagerRecent,
|
|
||||||
fileManagerPinned,
|
|
||||||
fileManagerShortcuts,
|
|
||||||
dismissedAlerts,
|
|
||||||
sshCredentialUsage,
|
|
||||||
settings,
|
|
||||||
} from "./db/schema.js";
|
|
||||||
import type {
|
import type {
|
||||||
CacheEntry,
|
CacheEntry,
|
||||||
GitHubRelease,
|
GitHubRelease,
|
||||||
GitHubAPIResponse,
|
GitHubAPIResponse,
|
||||||
AuthenticatedRequest,
|
AuthenticatedRequest,
|
||||||
} from "../../types/index.js";
|
} from "../../types/index.js";
|
||||||
import { getDb, DatabaseSaveTrigger } from "./db/index.js";
|
import { DatabaseSaveTrigger } from "./db/index.js";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
@@ -70,6 +67,45 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
|||||||
const requireAdmin = authManager.createAdminMiddleware();
|
const requireAdmin = authManager.createAdminMiddleware();
|
||||||
app.use(createCorsMiddleware());
|
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 uploadsDir = path.join(process.env.DATA_DIR || "./db/data", "uploads");
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -621,12 +657,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const deviceInfo = parseUserAgent(req);
|
const deviceInfo = parseUserAgent(req);
|
||||||
|
|
||||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
const userRepository = createCurrentUserRepository();
|
||||||
if (!user || user.length === 0) {
|
const user = await userRepository.findById(userId);
|
||||||
|
if (!user) {
|
||||||
return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOidcUser = !!user[0].isOidc;
|
const isOidcUser = !!user.isOidc;
|
||||||
|
|
||||||
if (!DataCrypto.getUserDataKey(userId)) {
|
if (!DataCrypto.getUserDataKey(userId)) {
|
||||||
if (isOidcUser) {
|
if (isOidcUser) {
|
||||||
@@ -867,22 +904,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
userRecord.totpBackupCodes || null,
|
userRecord.totpBackupCodes || null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const sshHosts = await getDb()
|
const sshHosts =
|
||||||
.select()
|
await createCurrentHostRepository().listDecryptedByUserId(userId);
|
||||||
.from(hosts)
|
|
||||||
.where(eq(hosts.userId, userId));
|
|
||||||
const insertHost = exportDb.prepare(`
|
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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const host of sshHosts) {
|
for (const decrypted of sshHosts) {
|
||||||
const decrypted = DataCrypto.decryptRecord(
|
|
||||||
"ssh_data",
|
|
||||||
host,
|
|
||||||
userId,
|
|
||||||
userDataKey,
|
|
||||||
);
|
|
||||||
insertHost.run(
|
insertHost.run(
|
||||||
decrypted.id,
|
decrypted.id,
|
||||||
decrypted.userId,
|
decrypted.userId,
|
||||||
@@ -940,22 +969,14 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = await getDb()
|
const credentials =
|
||||||
.select()
|
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
||||||
.from(sshCredentials)
|
|
||||||
.where(eq(sshCredentials.userId, userId));
|
|
||||||
const insertCred = exportDb.prepare(`
|
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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
for (const cred of credentials) {
|
for (const decrypted of credentials) {
|
||||||
const decrypted = DataCrypto.decryptRecord(
|
|
||||||
"ssh_credentials",
|
|
||||||
cred,
|
|
||||||
userId,
|
|
||||||
userDataKey,
|
|
||||||
);
|
|
||||||
insertCred.run(
|
insertCred.run(
|
||||||
decrypted.id,
|
decrypted.id,
|
||||||
decrypted.userId,
|
decrypted.userId,
|
||||||
@@ -979,19 +1000,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileManagerRepository =
|
||||||
|
createCurrentFileManagerBookmarkRepository();
|
||||||
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
|
||||||
getDb()
|
fileManagerRepository.listRecentByUserId(userId),
|
||||||
.select()
|
fileManagerRepository.listPinnedByUserId(userId),
|
||||||
.from(fileManagerRecent)
|
fileManagerRepository.listShortcutsByUserId(userId),
|
||||||
.where(eq(fileManagerRecent.userId, userId)),
|
|
||||||
getDb()
|
|
||||||
.select()
|
|
||||||
.from(fileManagerPinned)
|
|
||||||
.where(eq(fileManagerPinned.userId, userId)),
|
|
||||||
getDb()
|
|
||||||
.select()
|
|
||||||
.from(fileManagerShortcuts)
|
|
||||||
.where(eq(fileManagerShortcuts.userId, userId)),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const insertRecent = exportDb.prepare(`
|
const insertRecent = exportDb.prepare(`
|
||||||
@@ -1039,10 +1053,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const alerts = await getDb()
|
const dismissedAlertRepository = createCurrentDismissedAlertRepository();
|
||||||
.select()
|
const alerts = await dismissedAlertRepository.listByUserId(userId);
|
||||||
.from(dismissedAlerts)
|
|
||||||
.where(eq(dismissedAlerts.userId, userId));
|
|
||||||
const insertAlert = exportDb.prepare(`
|
const insertAlert = exportDb.prepare(`
|
||||||
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
|
INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
@@ -1056,10 +1068,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = await getDb()
|
const sshCredentialUsageRepository =
|
||||||
.select()
|
createCurrentSshCredentialUsageRepository();
|
||||||
.from(sshCredentialUsage)
|
const usage = await sshCredentialUsageRepository.listByUserId(userId);
|
||||||
.where(eq(sshCredentialUsage.userId, userId));
|
|
||||||
const insertUsage = exportDb.prepare(`
|
const insertUsage = exportDb.prepare(`
|
||||||
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
|
INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
@@ -1074,20 +1085,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsData = await getDb().select().from(settings);
|
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
|
||||||
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);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
exportDb.close();
|
exportDb.close();
|
||||||
}
|
}
|
||||||
@@ -1182,19 +1180,16 @@ app.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const mainDb = getDb();
|
|
||||||
const deviceInfo = parseUserAgent(req);
|
const deviceInfo = parseUserAgent(req);
|
||||||
|
|
||||||
const userRecords = await mainDb
|
const userRepository = createCurrentUserRepository();
|
||||||
.select()
|
const userRecord = await userRepository.findById(userId);
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, userId));
|
|
||||||
|
|
||||||
if (!userRecords || userRecords.length === 0) {
|
if (!userRecord) {
|
||||||
return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOidcUser = !!userRecords[0].isOidc;
|
const isOidcUser = !!userRecord.isOidc;
|
||||||
|
|
||||||
if (!DataCrypto.getUserDataKey(userId)) {
|
if (!DataCrypto.getUserDataKey(userId)) {
|
||||||
if (isOidcUser) {
|
if (isOidcUser) {
|
||||||
@@ -1276,332 +1271,287 @@ app.post(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mainDb.$client.exec("PRAGMA foreign_keys = OFF");
|
await withCurrentSqliteForeignKeysDisabled(async () => {
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
const importedItems = importDb
|
const importedHosts = importDb
|
||||||
.prepare(`SELECT * FROM ${table}`)
|
.prepare("SELECT * FROM ssh_data")
|
||||||
.all();
|
.all();
|
||||||
for (const item of importedItems) {
|
for (const host of importedHosts) {
|
||||||
try {
|
try {
|
||||||
const existing = await mainDb
|
const hostRepository = createCurrentHostRepository();
|
||||||
.select()
|
const exists = await hostRepository.existsForImportIdentity(
|
||||||
.from(schema)
|
userId,
|
||||||
.where(
|
host.ip,
|
||||||
and(
|
host.port,
|
||||||
eq(schema.userId, userId),
|
host.username,
|
||||||
eq(schema.path, item.path),
|
);
|
||||||
eq(schema.name, item.name),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (exists) {
|
||||||
result.summary.skippedItems++;
|
result.summary.skippedItems++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemData = {
|
const hostData = {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
hostId: item.host_id,
|
name: host.name,
|
||||||
name: item.name,
|
ip: host.ip,
|
||||||
path: item.path,
|
port: host.port,
|
||||||
...(table === "file_manager_recent" && {
|
username: host.username,
|
||||||
lastOpened: item.last_opened,
|
folder: host.folder,
|
||||||
}),
|
tags: host.tags,
|
||||||
...(table === "file_manager_pinned" && {
|
pin: Boolean(host.pin),
|
||||||
pinnedAt: item.pinned_at,
|
authType: host.auth_type,
|
||||||
}),
|
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||||
...(table === "file_manager_shortcuts" && {
|
password: host.password,
|
||||||
createdAt: item.created_at,
|
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);
|
await hostRepository.createEncryptedForUser(userId, hostData);
|
||||||
result.summary[key]++;
|
result.summary.sshHostsImported++;
|
||||||
} catch (itemError) {
|
} catch (hostError) {
|
||||||
result.summary.errors.push(
|
result.summary.errors.push(
|
||||||
`${table} import error: ${itemError.message}`,
|
`SSH host import error: ${hostError.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
apiLogger.info(`${table} table not found in import file, skipping`);
|
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const importedAlerts = importDb
|
const importedCreds = importDb
|
||||||
.prepare("SELECT * FROM dismissed_alerts")
|
.prepare("SELECT * FROM ssh_credentials")
|
||||||
.all();
|
.all();
|
||||||
for (const alert of importedAlerts) {
|
for (const cred of importedCreds) {
|
||||||
try {
|
try {
|
||||||
const existing = await mainDb
|
const credentialRepository =
|
||||||
.select()
|
createCurrentCredentialRepository();
|
||||||
.from(dismissedAlerts)
|
const exists =
|
||||||
.where(
|
await credentialRepository.existsForImportIdentity(
|
||||||
and(
|
userId,
|
||||||
eq(dismissedAlerts.userId, userId),
|
cred.name,
|
||||||
eq(dismissedAlerts.alertId, alert.alert_id),
|
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({
|
const fileManagerTables = [
|
||||||
userId: userId,
|
{
|
||||||
alertId: alert.alert_id,
|
table: "file_manager_recent",
|
||||||
dismissedAt: alert.dismissed_at || new Date().toISOString(),
|
key: "fileManagerItemsImported",
|
||||||
});
|
},
|
||||||
result.summary.dismissedAlertsImported++;
|
{
|
||||||
} catch (alertError) {
|
table: "file_manager_pinned",
|
||||||
result.summary.errors.push(
|
key: "fileManagerItemsImported",
|
||||||
`Dismissed alert import error: ${alertError.message}`,
|
},
|
||||||
|
{
|
||||||
|
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
|
const dismissedAlertRepository =
|
||||||
.select()
|
createCurrentDismissedAlertRepository();
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, userId));
|
|
||||||
if (targetUser.length > 0 && targetUser[0].isAdmin) {
|
|
||||||
try {
|
try {
|
||||||
const importedSettings = importDb
|
const importedAlerts = importDb
|
||||||
.prepare("SELECT * FROM settings")
|
.prepare("SELECT * FROM dismissed_alerts")
|
||||||
.all();
|
.all();
|
||||||
for (const setting of importedSettings) {
|
for (const alert of importedAlerts) {
|
||||||
try {
|
try {
|
||||||
const existing = await mainDb
|
const created = await dismissedAlertRepository.createForImport(
|
||||||
.select()
|
userId,
|
||||||
.from(settings)
|
alert.alert_id,
|
||||||
.where(eq(settings.key, setting.key));
|
alert.dismissed_at,
|
||||||
|
);
|
||||||
if (existing.length > 0) {
|
if (created) {
|
||||||
await mainDb
|
result.summary.dismissedAlertsImported++;
|
||||||
.update(settings)
|
|
||||||
.set({ value: setting.value })
|
|
||||||
.where(eq(settings.key, setting.key));
|
|
||||||
result.summary.settingsImported++;
|
|
||||||
} else {
|
} else {
|
||||||
await mainDb.insert(settings).values({
|
result.summary.skippedItems++;
|
||||||
key: setting.key,
|
|
||||||
value: setting.value,
|
|
||||||
});
|
|
||||||
result.summary.settingsImported++;
|
|
||||||
}
|
}
|
||||||
} catch (settingError) {
|
} catch (alertError) {
|
||||||
result.summary.errors.push(
|
result.summary.errors.push(
|
||||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
`Dismissed alert import error: ${alertError.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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");
|
const targetUser = await userRepository.findById(userId);
|
||||||
result.success = true;
|
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 {
|
result.success = true;
|
||||||
await DatabaseSaveTrigger.forceSave("database_import");
|
|
||||||
} catch (saveError) {
|
try {
|
||||||
apiLogger.error(
|
await DatabaseSaveTrigger.forceSave("database_import");
|
||||||
"Failed to persist imported data to disk",
|
} catch (saveError) {
|
||||||
saveError,
|
apiLogger.error(
|
||||||
{
|
"Failed to persist imported data to disk",
|
||||||
operation: "import_force_save_failed",
|
saveError,
|
||||||
userId,
|
{
|
||||||
},
|
operation: "import_force_save_failed",
|
||||||
);
|
userId,
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (importDb) {
|
if (importDb) {
|
||||||
importDb.close();
|
importDb.close();
|
||||||
@@ -1945,6 +1895,7 @@ app.get(
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
migrationStatus: status,
|
migrationStatus: status,
|
||||||
|
repositoryRollout: getRepositoryRolloutStatus(),
|
||||||
files: {
|
files: {
|
||||||
unencryptedDbSize: unencryptedSize,
|
unencryptedDbSize: unencryptedSize,
|
||||||
encryptedDbSize: encryptedSize,
|
encryptedDbSize: encryptedSize,
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ let memoryDatabase: Database.Database;
|
|||||||
let isNewDatabase = false;
|
let isNewDatabase = false;
|
||||||
let sqlite: Database.Database;
|
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> {
|
async function initializeDatabaseAsync(): Promise<void> {
|
||||||
const systemCrypto = SystemCrypto.getInstance();
|
const systemCrypto = SystemCrypto.getInstance();
|
||||||
|
|
||||||
@@ -460,6 +482,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
commands TEXT,
|
commands TEXT,
|
||||||
dangerous_actions TEXT,
|
dangerous_actions TEXT,
|
||||||
recording_path TEXT,
|
recording_path TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
format TEXT NOT NULL DEFAULT 'text',
|
||||||
terminated_by_owner INTEGER DEFAULT 0,
|
terminated_by_owner INTEGER DEFAULT 0,
|
||||||
termination_reason TEXT,
|
termination_reason TEXT,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
@@ -564,12 +588,30 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite.prepare("DELETE FROM user_open_tabs").run();
|
const timeoutRow = sqlite
|
||||||
databaseLogger.info("Open tabs cleared on startup", {
|
.prepare(
|
||||||
operation: "db_init_open_tabs_cleanup",
|
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||||
});
|
)
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
const timeoutMinutes = timeoutRow
|
||||||
|
? parseInt(timeoutRow.value, 10)
|
||||||
|
: 30;
|
||||||
|
const ttlMs =
|
||||||
|
!isNaN(timeoutMinutes) && timeoutMinutes > 0
|
||||||
|
? timeoutMinutes * 60_000
|
||||||
|
: 30 * 60_000;
|
||||||
|
const cutoff = new Date(Date.now() - ttlMs).toISOString();
|
||||||
|
const result = sqlite
|
||||||
|
.prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
|
||||||
|
.run(cutoff);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
databaseLogger.info("Expired open tabs cleared on startup", {
|
||||||
|
operation: "db_init_open_tabs_cleanup",
|
||||||
|
count: result.changes,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not clear open tabs on startup", {
|
databaseLogger.warn("Could not clear expired open tabs on startup", {
|
||||||
operation: "db_init_open_tabs_cleanup_failed",
|
operation: "db_init_open_tabs_cleanup_failed",
|
||||||
error: e,
|
error: e,
|
||||||
});
|
});
|
||||||
@@ -595,16 +637,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
migrateSchema();
|
migrateSchema();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const row = sqlite
|
ensureRawSettingDefault("allow_registration", "true");
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
|
||||||
.get();
|
|
||||||
if (!row) {
|
|
||||||
sqlite
|
|
||||||
.prepare(
|
|
||||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not initialize default settings", {
|
databaseLogger.warn("Could not initialize default settings", {
|
||||||
operation: "db_init",
|
operation: "db_init",
|
||||||
@@ -613,16 +646,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const row = sqlite
|
ensureRawSettingDefault("allow_password_login", "true");
|
||||||
.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();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not initialize allow_password_login setting", {
|
databaseLogger.warn("Could not initialize allow_password_login setting", {
|
||||||
operation: "db_init",
|
operation: "db_init",
|
||||||
@@ -631,16 +655,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const row = sqlite
|
ensureRawSettingDefault("guac_enabled", "true");
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
|
||||||
.get();
|
|
||||||
if (!row) {
|
|
||||||
sqlite
|
|
||||||
.prepare(
|
|
||||||
"INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
||||||
operation: "db_init",
|
operation: "db_init",
|
||||||
@@ -649,16 +664,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const row = sqlite
|
ensureRawSettingDefault("guac_url", getDefaultGuacdUrl());
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
|
||||||
.get();
|
|
||||||
if (!row) {
|
|
||||||
sqlite
|
|
||||||
.prepare(
|
|
||||||
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
|
|
||||||
)
|
|
||||||
.run(getDefaultGuacdUrl());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||||
operation: "db_init",
|
operation: "db_init",
|
||||||
@@ -695,6 +701,17 @@ const addColumnIfNotExists = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
const migrateSchema = () => {
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"session_recordings",
|
||||||
|
"protocol",
|
||||||
|
"TEXT NOT NULL DEFAULT 'ssh'",
|
||||||
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"session_recordings",
|
||||||
|
"format",
|
||||||
|
"TEXT NOT NULL DEFAULT 'text'",
|
||||||
|
);
|
||||||
|
|
||||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||||
@@ -747,6 +764,10 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||||
|
|
||||||
|
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -1553,6 +1574,8 @@ const migrateSchema = () => {
|
|||||||
commands TEXT,
|
commands TEXT,
|
||||||
dangerous_actions TEXT,
|
dangerous_actions TEXT,
|
||||||
recording_path TEXT,
|
recording_path TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
format TEXT NOT NULL DEFAULT 'text',
|
||||||
terminated_by_owner INTEGER DEFAULT 0,
|
terminated_by_owner INTEGER DEFAULT 0,
|
||||||
termination_reason TEXT,
|
termination_reason TEXT,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
@@ -1900,31 +1923,30 @@ const migrateSchema = () => {
|
|||||||
|
|
||||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||||
try {
|
try {
|
||||||
const migrationDone = sqlite
|
const migrationDone = getRawSettingValue("sso_migration_v1");
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'")
|
|
||||||
.get();
|
|
||||||
if (!migrationDone) {
|
if (!migrationDone) {
|
||||||
const providerCount = (
|
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;
|
).c;
|
||||||
if (providerCount === 0) {
|
if (providerCount === 0) {
|
||||||
const legacyRow = sqlite
|
const legacyConfig = getRawSettingValue("oidc_config");
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
if (legacyConfig) {
|
||||||
.get() as { value: string } | undefined;
|
|
||||||
if (legacyRow) {
|
|
||||||
sqlite
|
sqlite
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
|
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
|
||||||
)
|
)
|
||||||
.run("OIDC", legacyRow.value);
|
.run("OIDC", legacyConfig);
|
||||||
databaseLogger.info("Migrated legacy oidc_config into sso_providers table", {
|
databaseLogger.info(
|
||||||
operation: "sso_migration_v1",
|
"Migrated legacy oidc_config into sso_providers table",
|
||||||
});
|
{
|
||||||
|
operation: "sso_migration_v1",
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sqlite
|
setRawSettingValue("sso_migration_v1", "true");
|
||||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')")
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Failed to run SSO migration v1", {
|
databaseLogger.warn("Failed to run SSO migration v1", {
|
||||||
@@ -2063,19 +2085,15 @@ const migrateSchema = () => {
|
|||||||
|
|
||||||
// Seed default metrics history retention setting
|
// Seed default metrics history retention setting
|
||||||
try {
|
try {
|
||||||
const retentionRow = sqlite
|
ensureRawSettingDefault("metrics_history_retention_days", "7");
|
||||||
.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();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not initialize metrics_history_retention_days setting", {
|
databaseLogger.warn(
|
||||||
operation: "schema_migration",
|
"Could not initialize metrics_history_retention_days setting",
|
||||||
error: e,
|
{
|
||||||
});
|
operation: "schema_migration",
|
||||||
|
error: e,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- homepage begin ---
|
// --- homepage begin ---
|
||||||
@@ -2166,21 +2184,23 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePostInitFileEncryption() {
|
async function handlePostInitFileEncryption() {
|
||||||
if (!enableFileEncryption) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (memoryDatabase) {
|
if (memoryDatabase) {
|
||||||
await saveMemoryDatabaseToFile();
|
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||||
|
|
||||||
|
if (enableFileEncryption) {
|
||||||
|
await saveMemoryDatabaseToFile();
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (DatabaseSaveTrigger.isDirty) {
|
if (DatabaseSaveTrigger.isDirty) {
|
||||||
saveMemoryDatabaseToFile();
|
saveMemoryDatabaseToFile();
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!enableFileEncryption) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const migration = new DatabaseMigration(dataDir);
|
const migration = new DatabaseMigration(dataDir);
|
||||||
migration.cleanupOldBackups();
|
migration.cleanupOldBackups();
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
jwtToken: text("jwt_token").notNull(),
|
jwtToken: text("jwt_token").notNull(),
|
||||||
deviceType: text("device_type").notNull(),
|
deviceType: text("device_type").notNull(),
|
||||||
deviceInfo: text("device_info").notNull(),
|
deviceInfo: text("device_info").notNull(),
|
||||||
|
oidcSub: text("oidc_sub"),
|
||||||
|
oidcSid: text("oidc_sid"),
|
||||||
|
ssoProviderId: integer("sso_provider_id"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -657,6 +660,8 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
|||||||
dangerousActions: text("dangerous_actions"),
|
dangerousActions: text("dangerous_actions"),
|
||||||
|
|
||||||
recordingPath: text("recording_path"),
|
recordingPath: text("recording_path"),
|
||||||
|
protocol: text("protocol").notNull().default("ssh"),
|
||||||
|
format: text("format").notNull().default("text"),
|
||||||
|
|
||||||
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user