Compare commits

..
Author SHA1 Message Date
LukeGus 8f7501203b chore: sync Crowdin translations 2026-08-26 06:10:56 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 46d4626f03 chore(deps-dev): bump the dev-minor-updates group across 1 directory with 10 updates (#1323)
Bumps the dev-minor-updates group with 10 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.10.4` | `6.11.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.2` | `26.2.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.5` | `6.1.0` |
| [electron](https://github.com/electron/electron) | `43.2.0` | `43.4.1` |
| [globals](https://github.com/sindresorhus/globals) | `17.9.0` | `17.11.0` |
| [i18next](https://github.com/i18next/i18next) | `26.3.6` | `26.4.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.28.0` | `1.33.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.84.0` | `7.85.0` |
| [react-pdf](https://github.com/wojtekmaj/react-pdf/tree/HEAD/packages/react-pdf) | `10.4.1` | `10.5.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.66.0` | `8.67.0` |



Updates `@codemirror/commands` from 6.10.4 to 6.11.0
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

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

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

Updates `electron` from 43.2.0 to 43.4.1
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v43.2.0...v43.4.1)

Updates `globals` from 17.9.0 to 17.11.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.9.0...v17.11.0)

Updates `i18next` from 26.3.6 to 26.4.0
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.6...v26.4.0)

Updates `lucide-react` from 1.28.0 to 1.33.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.33.0/packages/lucide-react)

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

Updates `react-pdf` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/wojtekmaj/react-pdf/releases)
- [Commits](https://github.com/wojtekmaj/react-pdf/commits/v10.5.0/packages/react-pdf)

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

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: electron
  dependency-version: 43.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: globals
  dependency-version: 17.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: i18next
  dependency-version: 26.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: lucide-react
  dependency-version: 1.33.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.85.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-pdf
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 19:47:48 +08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 80191409e8 chore(deps): bump the prod-minor-updates group across 1 directory with 3 updates (#1325)
Bumps the prod-minor-updates group with 3 updates in the / directory: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript), [js-yaml](https://github.com/nodeca/js-yaml) and [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg).


Updates `@anthropic-ai/sdk` from 0.116.0 to 0.120.0
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.116.0...sdk-v0.120.0)

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

Updates `pg` from 8.22.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

---
updated-dependencies:
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.120.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: js-yaml
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 19:36:20 +08:00
LukeGus a6874cea95 chore: sync Crowdin translations 2026-08-24 06:14:10 +00:00
355 changed files with 1983 additions and 155665 deletions
-17
View File
@@ -427,7 +427,6 @@ jobs:
npm ci
npm install --force @rollup/rollup-darwin-arm64
npm install dmg-license
node scripts/install-macos-sharp.cjs
- name: Check for Code Signing Certificates
id: check_certs
@@ -539,14 +538,6 @@ jobs:
fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
- name: Verify macOS sharp packaging
run: |
artifacts=(release/termix_macos_*_dmg.dmg)
if [ -f release/termix_macos_universal_mas.pkg ]; then
artifacts+=(release/termix_macos_universal_mas.pkg)
fi
node scripts/verify-macos-sharp.cjs "${artifacts[@]}"
- name: Upload macOS MAS PKG
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v7
@@ -932,9 +923,6 @@ jobs:
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
- name: Verify macOS sharp packaging
run: node scripts/verify-macos-sharp.cjs release_asset/termix_macos_universal_dmg.dmg
- name: Prepare Homebrew submission files
run: |
VERSION="${{ steps.package-version.outputs.version }}"
@@ -1025,7 +1013,6 @@ jobs:
npm ci
npm install --force @rollup/rollup-darwin-arm64
npm install dmg-license
node scripts/install-macos-sharp.cjs
- name: Check for Code Signing Certificates
id: check_certs
@@ -1167,10 +1154,6 @@ jobs:
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
- name: Verify macOS sharp packaging
if: steps.check_certs.outputs.has_certs == 'true'
run: node scripts/verify-macos-sharp.cjs release/termix_macos_universal_mas.pkg
- name: Generate App Store release notes
id: asc_notes
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
+2 -18
View File
@@ -331,11 +331,6 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# guacd, not the Termix container, reads and writes redirected-drive files.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
@@ -345,10 +340,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
# The official guacd image runs as a non-root user. Keep the drive path
# in this writable shared volume instead of bind-mounting /drive.
- termix-data:/termix-data
ports:
- "4822:4822"
networks:
- termix-net
@@ -361,15 +354,6 @@ networks:
driver: bridge
```
For multiple Termix backend instances, set the same `REDIS_URL` and optional
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
presence, control requests, controller state, and events. It also routes Step CA
OAuth callbacks back to the instance holding the user's terminal; the optional
`TERMIX_STEP_CA_REDIS_PREFIX` isolates those short-lived encrypted messages.
Keep WebSocket session affinity enabled because live SSH and remote desktop
transports remain attached to the backend instance that opened them. A single
instance needs no Redis.
### Command Line Interface
Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts.
-18
View File
@@ -3,21 +3,3 @@
## Reporting a Vulnerability
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
## External secret storage
By default, a single-container installation generates its keys in the Termix
data directory for ease of recovery. Production deployments that keep backups
or database files outside a trusted encrypted volume should set
`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a
secret manager:
- `JWT_SECRET` (at least 64 characters)
- `DATABASE_KEY` (64 hexadecimal characters)
- `ENCRYPTION_KEY` (64 hexadecimal characters)
- `INTERNAL_AUTH_TOKEN` (at least 32 characters)
Each value can instead be mounted as a Docker or Kubernetes secret and supplied
with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`.
Hardened mode fails closed instead of writing a replacement key beside the
encrypted database.
-4
View File
@@ -75,7 +75,6 @@ env:
PORT: "8080"
DATA_DIR: /app/data
NODE_ENV: production
TERMIX_REQUIRE_EXTERNAL_SECRETS: "false"
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
@@ -87,9 +86,6 @@ secrets:
name: ""
data:
JWT_SECRET: ""
DATABASE_KEY: ""
ENCRYPTION_KEY: ""
INTERNAL_AUTH_TOKEN: ""
DATABASE_URL: ""
GUACAMOLE_ENCRYPTION_KEY: ""
+7 -10
View File
@@ -1,5 +1,5 @@
# Stage 1: Install dependencies
FROM node:26-slim AS deps
FROM node:24-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -38,28 +38,25 @@ RUN rm -rf node_modules/better-sqlite3/prebuilds && \
RUN npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:26-slim AS opkssh-downloader
FROM node:24-slim AS opkssh-downloader
ARG TARGETARCH
ARG OPKSSH_VERSION=v0.16.0
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
ARG OPKSSH_SHA256_ARM64=9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44
WORKDIR /opkssh
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN case "$TARGETARCH" in \
amd64) OPKSSH_ARCH=amd64; OPKSSH_SHA256="$OPKSSH_SHA256_AMD64" ;; \
arm64) OPKSSH_ARCH=arm64; OPKSSH_SHA256="$OPKSSH_SHA256_ARM64" ;; \
amd64) OPKSSH_ARCH=amd64 ;; \
arm64) OPKSSH_ARCH=arm64 ;; \
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
esac && \
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
"https://github.com/openpubkey/opkssh/releases/download/${OPKSSH_VERSION}/opkssh-linux-${OPKSSH_ARCH}" && \
echo "$OPKSSH_SHA256 opkssh-linux-${OPKSSH_ARCH}" | sha256sum -c - && \
chmod 755 "opkssh-linux-${OPKSSH_ARCH}" && \
echo -n "$OPKSSH_VERSION" > version.txt
# Stage 5: Production dependencies only
FROM node:26-slim AS production-deps
FROM node:24-slim AS production-deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -79,7 +76,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force
# Stage 6: Final optimized image
FROM node:26-slim
FROM node:24-slim
WORKDIR /app
ENV DATA_DIR=/app/data \
@@ -109,7 +106,7 @@ COPY --chown=node:node drizzle ./drizzle
VOLUME ["/app/data"]
EXPOSE ${PORT}
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
-4
View File
@@ -15,10 +15,6 @@ services:
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
-13
View File
@@ -12,19 +12,6 @@ services:
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
# Hardened deployments can require keys from environment variables or
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
# TERMIX_REQUIRE_EXTERNAL_SECRETS: "true"
# Trusted reverse-proxy authentication is disabled by default. When
# enabled, do not expose this container directly to untrusted clients.
# TRUSTED_PROXY_AUTH_ENABLED: "true"
+1 -8
View File
@@ -172,14 +172,7 @@ if [ -n "$BASE_PATH" ]; then
echo "Injecting BASE_PATH: $BASE_PATH"
# Strip trailing slash for use as a path prefix
CLEAN_BASE_PATH="${BASE_PATH%/}"
case "$CLEAN_BASE_PATH" in
/*) ;;
*) echo "BASE_PATH must start with /" >&2; exit 1 ;;
esac
case "$CLEAN_BASE_PATH" in
*[!A-Za-z0-9_./~-]*) echo "BASE_PATH contains unsupported characters" >&2; exit 1 ;;
esac
find /app/html -name "index.html" -exec sed -i "s|name=\"termix-base-path\" content=\"\"|name=\"termix-base-path\" content=\"$CLEAN_BASE_PATH\"|g" {} \;
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
# Patch sw.js static asset paths with the base path prefix
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
else
-4
View File
@@ -161,10 +161,6 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
-4
View File
@@ -142,10 +142,6 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Content-Type-Options nosniff always;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri $uri/ /index.html;
-7
View File
@@ -350,13 +350,6 @@ networks:
driver: bridge
```
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件,
也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过
`TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍
依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。
单实例部署无需 Redis。
### 命令行工具
Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。
-34
View File
@@ -1,34 +0,0 @@
CREATE TABLE `collab_room_members` (
`id` int AUTO_INCREMENT NOT NULL,
`room_id` varchar(255) NOT NULL,
`user_id` varchar(255) NOT NULL,
`room_role` text NOT NULL DEFAULT ('member'),
`added_by` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `collab_room_members_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_collab_room_members_room_user` UNIQUE(`room_id`,`user_id`)
);
--> statement-breakpoint
CREATE TABLE `collab_rooms` (
`id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`owner_user_id` varchar(255) NOT NULL,
`persistent` boolean NOT NULL DEFAULT false,
`presenter_user_id` varchar(255),
`stage_protocol` text,
`stage_host_id` int,
`stage_share_id` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`ended_at` text,
CONSTRAINT `collab_rooms_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_room_id_collab_rooms_id_fk` FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_added_by_users_id_fk` FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_presenter_user_id_users_id_fk` FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_host_id_ssh_data_id_fk` FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_share_id_session_shares_id_fk` FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `collab_rooms` ADD `guest_link_token` varchar(255);--> statement-breakpoint
ALTER TABLE `collab_rooms` ADD CONSTRAINT `idx_collab_rooms_guest_token` UNIQUE(`guest_link_token`);
-15
View File
@@ -1,15 +0,0 @@
CREATE TABLE `secret_sources` (
`id` varchar(255) NOT NULL,
`user_id` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`kind` text NOT NULL DEFAULT ('onepassword-connect'),
`base_url` text NOT NULL,
`token` text NOT NULL,
`shared` boolean NOT NULL DEFAULT false,
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `secret_sources_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `secret_sources` ADD CONSTRAINT `secret_sources_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
-42
View File
@@ -1,42 +0,0 @@
CREATE TABLE `credential_access` (
`id` int AUTO_INCREMENT NOT NULL,
`credential_id` int NOT NULL,
`user_id` varchar(255),
`role_id` int,
`granted_by` varchar(255) NOT NULL,
`permission_level` text NOT NULL DEFAULT ('use'),
`expires_at` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `credential_access_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `shared_credential_secrets` (
`id` int AUTO_INCREMENT NOT NULL,
`credential_access_id` int NOT NULL,
`target_user_id` varchar(255) NOT NULL,
`credential_id` int NOT NULL,
`encrypted_username` text,
`auth_type` text NOT NULL DEFAULT ('password'),
`encrypted_password` text,
`encrypted_key` text,
`encrypted_key_password` text,
`key_type` text,
`public_key` text,
`cert_public_key` text,
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `shared_credential_secrets_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_shared_credential_secrets_scope` UNIQUE(`credential_access_id`,`target_user_id`)
);
--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_cred_secrets_access_id_fk` FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
-22
View File
@@ -1,22 +0,0 @@
CREATE TABLE `folder_access` (
`id` int AUTO_INCREMENT NOT NULL,
`owner_user_id` varchar(255) NOT NULL,
`folder` varchar(255) NOT NULL,
`user_id` varchar(255),
`role_id` int,
`granted_by` varchar(255) NOT NULL,
`permission_level` text NOT NULL DEFAULT ('connect'),
`expires_at` varchar(255),
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `folder_access_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `ssh_data` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `snippets` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `vault_profiles` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-35
View File
@@ -106,41 +106,6 @@
"when": 1786757023790,
"tag": "0014_bitter_nextwave",
"breakpoints": true
},
{
"idx": 15,
"version": "5",
"when": 1787576997080,
"tag": "0015_red_cobalt_man",
"breakpoints": true
},
{
"idx": 16,
"version": "5",
"when": 1787581983770,
"tag": "0016_unusual_tyrannus",
"breakpoints": true
},
{
"idx": 17,
"version": "5",
"when": 1787596414390,
"tag": "0017_spicy_proteus",
"breakpoints": true
},
{
"idx": 18,
"version": "5",
"when": 1787600445146,
"tag": "0018_fancy_barracuda",
"breakpoints": true
},
{
"idx": 19,
"version": "5",
"when": 1787602480861,
"tag": "0019_certain_archangel",
"breakpoints": true
}
]
}
-32
View File
@@ -1,32 +0,0 @@
CREATE TABLE "collab_room_members" (
"id" serial PRIMARY KEY NOT NULL,
"room_id" varchar(255) NOT NULL,
"user_id" varchar(255) NOT NULL,
"room_role" text DEFAULT 'member' NOT NULL,
"added_by" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "collab_rooms" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"owner_user_id" varchar(255) NOT NULL,
"persistent" boolean DEFAULT false NOT NULL,
"presenter_user_id" varchar(255),
"stage_protocol" text,
"stage_host_id" integer,
"stage_share_id" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"ended_at" text
);
--> statement-breakpoint
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_room_id_collab_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."collab_rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_presenter_user_id_users_id_fk" FOREIGN KEY ("presenter_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_host_id_ssh_data_id_fk" FOREIGN KEY ("stage_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_share_id_session_shares_id_fk" FOREIGN KEY ("stage_share_id") REFERENCES "public"."session_shares"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_collab_room_members_room_user" ON "collab_room_members" USING btree ("room_id","user_id");--> statement-breakpoint
CREATE INDEX "idx_collab_room_members_user" ON "collab_room_members" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_collab_rooms_owner" ON "collab_rooms" USING btree ("owner_user_id");
@@ -1,2 +0,0 @@
ALTER TABLE "collab_rooms" ADD COLUMN "guest_link_token" varchar(255);--> statement-breakpoint
CREATE UNIQUE INDEX "idx_collab_rooms_guest_token" ON "collab_rooms" USING btree ("guest_link_token");
@@ -1,14 +0,0 @@
CREATE TABLE "secret_sources" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL,
"kind" text DEFAULT 'onepassword-connect' NOT NULL,
"base_url" text NOT NULL,
"token" text NOT NULL,
"shared" boolean DEFAULT false NOT NULL,
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "secret_sources" ADD CONSTRAINT "secret_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_secret_sources_user" ON "secret_sources" USING btree ("user_id");
@@ -1,40 +0,0 @@
CREATE TABLE "credential_access" (
"id" serial PRIMARY KEY NOT NULL,
"credential_id" integer NOT NULL,
"user_id" varchar(255),
"role_id" integer,
"granted_by" varchar(255) NOT NULL,
"permission_level" text DEFAULT 'use' NOT NULL,
"expires_at" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shared_credential_secrets" (
"id" serial PRIMARY KEY NOT NULL,
"credential_access_id" integer NOT NULL,
"target_user_id" varchar(255) NOT NULL,
"credential_id" integer NOT NULL,
"encrypted_username" text,
"auth_type" text DEFAULT 'password' NOT NULL,
"encrypted_password" text,
"encrypted_key" text,
"encrypted_key_password" text,
"key_type" text,
"public_key" text,
"cert_public_key" text,
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_credential_access_id_credential_access_id_fk" FOREIGN KEY ("credential_access_id") REFERENCES "public"."credential_access"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_credential_access_user_id" ON "credential_access" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_credential_access_role_id" ON "credential_access" USING btree ("role_id");--> statement-breakpoint
CREATE INDEX "idx_credential_access_credential_id" ON "credential_access" USING btree ("credential_id");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_shared_credential_secrets_scope" ON "shared_credential_secrets" USING btree ("credential_access_id","target_user_id");--> statement-breakpoint
CREATE INDEX "idx_shared_credential_secrets_target" ON "shared_credential_secrets" USING btree ("target_user_id","credential_id");
-21
View File
@@ -1,21 +0,0 @@
CREATE TABLE "folder_access" (
"id" serial PRIMARY KEY NOT NULL,
"owner_user_id" varchar(255) NOT NULL,
"folder" varchar(255) NOT NULL,
"user_id" varchar(255),
"role_id" integer,
"granted_by" varchar(255) NOT NULL,
"permission_level" text DEFAULT 'connect' NOT NULL,
"expires_at" varchar(255),
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "snippets" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "vault_profiles" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_folder_access_owner_folder" ON "folder_access" USING btree ("owner_user_id","folder");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-35
View File
@@ -106,41 +106,6 @@
"when": 1786757021444,
"tag": "0014_unusual_maelstrom",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787576994553,
"tag": "0015_early_spitfire",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1787581981251,
"tag": "0016_slippery_anita_blake",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787596411353,
"tag": "0017_curved_pet_avengers",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1787600442827,
"tag": "0018_puzzling_night_nurse",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1787602478567,
"tag": "0019_oval_puff_adder",
"breakpoints": true
}
]
}
@@ -1,32 +0,0 @@
CREATE TABLE `collab_room_members` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`room_role` text DEFAULT 'member' NOT NULL,
`added_by` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_collab_room_members_room_user` ON `collab_room_members` (`room_id`,`user_id`);--> statement-breakpoint
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
CREATE TABLE `collab_rooms` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`owner_user_id` text NOT NULL,
`persistent` integer DEFAULT false NOT NULL,
`presenter_user_id` text,
`stage_protocol` text,
`stage_host_id` integer,
`stage_share_id` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`ended_at` text,
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `collab_rooms` ADD `guest_link_token` text;--> statement-breakpoint
CREATE UNIQUE INDEX `idx_collab_rooms_guest_token` ON `collab_rooms` (`guest_link_token`);
@@ -1,14 +0,0 @@
CREATE TABLE `secret_sources` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`kind` text DEFAULT 'onepassword-connect' NOT NULL,
`base_url` text NOT NULL,
`token` text NOT NULL,
`shared` integer DEFAULT false NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
@@ -1,40 +0,0 @@
CREATE TABLE `credential_access` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`credential_id` integer NOT NULL,
`user_id` text,
`role_id` integer,
`granted_by` text NOT NULL,
`permission_level` text DEFAULT 'use' NOT NULL,
`expires_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
CREATE TABLE `shared_credential_secrets` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`credential_access_id` integer NOT NULL,
`target_user_id` text NOT NULL,
`credential_id` integer NOT NULL,
`encrypted_username` text,
`auth_type` text DEFAULT 'password' NOT NULL,
`encrypted_password` text,
`encrypted_key` text(16384),
`encrypted_key_password` text,
`key_type` text,
`public_key` text(4096),
`cert_public_key` text(8192),
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_shared_credential_secrets_scope` ON `shared_credential_secrets` (`credential_access_id`,`target_user_id`);--> statement-breakpoint
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
-17
View File
@@ -1,17 +0,0 @@
CREATE TABLE `folder_access` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`owner_user_id` text NOT NULL,
`folder` text NOT NULL,
`user_id` text,
`role_id` integer,
`granted_by` text NOT NULL,
`permission_level` text DEFAULT 'connect' NOT NULL,
`expires_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-35
View File
@@ -78,41 +78,6 @@
"when": 1786757019248,
"tag": "0010_mean_queen_noir",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1787576992255,
"tag": "0011_wakeful_titanium_man",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1787581978969,
"tag": "0012_yellow_firedrake",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1787596405780,
"tag": "0013_tranquil_master_mold",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1787600440707,
"tag": "0014_lean_doctor_octopus",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1787602476319,
"tag": "0015_glossy_shotgun",
"breakpoints": true
}
]
}
+8 -36
View File
@@ -21,7 +21,7 @@ const net = require("net");
const tls = require("tls");
const zlib = require("zlib");
const crypto = require("crypto");
const { URL, pathToFileURL } = require("url");
const { URL } = require("url");
const { fork, spawn } = require("child_process");
const pty = require("node-pty");
const WebSocket = require("ws");
@@ -536,16 +536,6 @@ function httpFetch(url, options = {}) {
// Node's http/https modules never auto-decompress, so an unhandled
// content-encoding here silently turns the body into garbage bytes.
let stream = res;
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
let responseBytes = 0;
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
stream.destroy();
req.destroy();
reject(error);
};
const encoding = (res.headers["content-encoding"] || "")
.toLowerCase()
.trim();
@@ -562,17 +552,8 @@ function httpFetch(url, options = {}) {
return;
}
stream.on("data", (chunk) => {
responseBytes += chunk.length;
if (responseBytes > maxResponseBytes) {
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
return;
}
chunks.push(chunk);
});
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => {
if (settled) return;
settled = true;
const data = Buffer.concat(chunks).toString("utf8");
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
@@ -581,7 +562,7 @@ function httpFetch(url, options = {}) {
json: () => Promise.resolve(JSON.parse(data)),
});
});
stream.on("error", fail);
stream.on("error", reject);
});
req.on("error", reject);
@@ -1212,12 +1193,11 @@ function createWindow() {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
webSecurity: false,
preload: path.join(__dirname, "preload.js"),
partition: termixSessionPartition,
allowRunningInsecureContent: false,
webviewTag: false,
allowRunningInsecureContent: true,
webviewTag: true,
offscreen: false,
},
show: true,
@@ -1397,13 +1377,6 @@ function createWindow() {
}
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const allowedUrl = isDev
? url.startsWith("http://localhost:5173/")
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
if (!allowedUrl) event.preventDefault();
});
}
ipcMain.handle("get-app-version", () => {
@@ -1673,7 +1646,7 @@ ipcMain.handle("clear-remote-sync-config", async () => {
return result;
});
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
@@ -1681,8 +1654,7 @@ ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
needsReauth: false,
lastError: null,
});
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
return { ...result, status: status || null };
remoteSync.getRemoteSyncEngine()?.syncNow();
}
return result;
});
+1 -24
View File
@@ -1,28 +1,5 @@
const { contextBridge, ipcRenderer } = require("electron");
const ALLOWED_INVOKE_CHANNELS = new Set([
"check-electron-update",
"clear-remote-sync-config",
"get-desktop-settings",
"get-legacy-server-config",
"get-remote-sync-config",
"get-remote-sync-jwt",
"get-remote-sync-status",
"get-remote-sync-user-info",
"remote-sync-now",
"save-desktop-settings",
"save-remote-sync-config",
"save-remote-sync-jwt",
"test-server-connection",
]);
function invokeAllowed(channel, ...args) {
if (!ALLOWED_INVOKE_CHANNELS.has(channel)) {
return Promise.reject(new Error(`IPC channel is not allowed: ${channel}`));
}
return ipcRenderer.invoke(channel, ...args);
}
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke("get-platform"),
@@ -126,7 +103,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
return () => ipcRenderer.removeListener(channel, listener);
},
invoke: invokeAllowed,
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
});
contextBridge.exposeInMainWorld("electronClipboard", {
+2 -24
View File
@@ -37,20 +37,7 @@ function writeJson(filePath, value) {
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
mode: 0o600,
});
fs.renameSync(temporaryPath, filePath);
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch {
// already absent
}
throw error;
}
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
}
function getDesktopSettingsPath() {
@@ -204,7 +191,6 @@ class RemoteSyncEngine {
this.getMainWindow = getMainWindow;
this.localJwt = null;
this.timer = null;
this.startupTimer = null;
this.syncing = false;
this.status = {
connected: false,
@@ -234,15 +220,11 @@ class RemoteSyncEngine {
const config = getRemoteSyncConfig();
this.status.connected = !!config?.serverUrl;
if (this.timer) clearInterval(this.timer);
if (this.startupTimer) clearTimeout(this.startupTimer);
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
if (config?.serverUrl) {
// Fire an initial sync shortly after startup rather than waiting a
// full interval, but don't block app boot on it.
this.startupTimer = setTimeout(() => {
this.startupTimer = null;
this.syncNow();
}, 5000);
setTimeout(() => this.syncNow(), 5000);
}
}
@@ -251,10 +233,6 @@ class RemoteSyncEngine {
clearInterval(this.timer);
this.timer = null;
}
if (this.startupTimer) {
clearTimeout(this.startupTimer);
this.startupTimer = null;
}
}
async syncNow() {
+3 -1
View File
@@ -9,7 +9,6 @@
/>
<meta name="theme-color" content="#09090b" />
<meta name="termix-base-path" content="" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
@@ -69,6 +68,9 @@
</style>
</head>
<body>
<script>
window.__TERMIX_BASE_PATH__ = "";
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+399 -505
View File
File diff suppressed because it is too large Load Diff
+34 -35
View File
@@ -46,15 +46,15 @@
"schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.116.0",
"@anthropic-ai/sdk": "^0.120.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.10",
"@tanstack/react-virtual": "^3.14.9",
"@types/compression": "^1.8.1",
"@types/ldapjs": "^3.0.6",
"axios": "^1.19.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3",
"better-sqlite3": "^13.0.2",
"body-parser": "^2.3.0",
"chalk": "^6.0.0",
"compression": "^1.8.1",
@@ -64,34 +64,33 @@
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jose": "^6.2.9",
"js-yaml": "^5.2.3",
"jose": "^6.2.8",
"js-yaml": "^5.3.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ldapjs": "^3.0.7",
"motion": "^13.1.1",
"motion": "^12.43.0",
"multer": "^2.2.0",
"mysql2": "^3.23.4",
"mysql2": "^3.23.2",
"nanoid": "^6.0.1",
"node-pty": "^1.1.0",
"pg": "^8.22.0",
"pg": "^8.23.0",
"qrcode": "^1.5.4",
"redis": "^6.2.1",
"serialport": "^13.0.0",
"sharp": "^0.35.3",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.10.0",
"ws": "^8.21.3"
"ws": "^8.21.1"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/commands": "^6.11.0",
"@codemirror/search": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.9",
"@commitlint/cli": "^21.2.2",
"@codemirror/view": "^6.43.7",
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@deadendjs/swagger-jsdoc": "^8.1.2",
"@electron/notarize": "^3.1.1",
@@ -120,9 +119,9 @@
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^9.6.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
@@ -131,8 +130,8 @@
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/node": "^26.1.2",
"@types/pg": "^8.20.3",
"@types/node": "^26.2.0",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
@@ -142,9 +141,9 @@
"@uiw/codemirror-extensions-langs": "^4.25.11",
"@uiw/codemirror-theme-github": "^4.25.11",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"@vitejs/plugin-react": "^6.1.0",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
@@ -154,47 +153,47 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^10.0.5",
"cytoscape": "^3.34.1",
"concurrently": "^10.0.4",
"cytoscape": "^3.34.0",
"drizzle-kit": "^0.31.10",
"electron": "^43.2.0",
"electron": "^43.4.1",
"electron-builder": "^26.15.3",
"eslint": "^10.8.1",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.8.0",
"globals": "^17.11.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
"i18next": "^26.3.6",
"i18next": "^26.4.0",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^30.0.1",
"lint-staged": "^17.2.0",
"lucide-react": "^1.28.0",
"lucide-react": "^1.33.0",
"prettier": "3.9.6",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.8",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.84.0",
"react-i18next": "^17.0.12",
"react-hook-form": "^7.85.0",
"react-i18next": "^17.0.11",
"react-icons": "^5.7.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
"react-pdf": "^10.5.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.66.0",
"vite": "^8.2.2",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.11"
"vitest": "^4.1.10"
},
"lint-staged": {
"*.{ts,tsx}": [
@@ -1,21 +0,0 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const main = readFileSync("electron/main.cjs", "utf8");
const preload = readFileSync("electron/preload.js", "utf8");
describe("Electron security boundary", () => {
it("keeps the renderer sandbox and browser security enabled", () => {
expect(main).toContain("sandbox: true");
expect(main).toContain("webSecurity: true");
expect(main).toContain("allowRunningInsecureContent: false");
expect(main).toContain("webviewTag: false");
});
it("does not expose an unrestricted IPC invoke primitive", () => {
expect(preload).toContain("invoke: invokeAllowed");
expect(preload).not.toContain(
"invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)",
);
});
});
+2 -2
View File
@@ -171,8 +171,8 @@ function transform(source, dialect) {
);
const imports = isPg
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n foreignKey,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n foreignKey,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
out = out.replace(
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
-27
View File
@@ -1,27 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
function findPackage(start) {
let directory = path.dirname(start);
while (directory !== path.dirname(directory)) {
const manifest = path.join(directory, "package.json");
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
directory = path.dirname(directory);
}
throw new Error("Could not locate the installed sharp package manifest");
}
const sharpPackage = findPackage(require.resolve("sharp"));
const packages = [
"@img/sharp-darwin-arm64",
"@img/sharp-darwin-x64",
"@img/sharp-libvips-darwin-arm64",
"@img/sharp-libvips-darwin-x64",
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
execFileSync(
process.platform === "win32" ? "npm.cmd" : "npm",
["install", "--force", "--no-save", ...packages],
{ stdio: "inherit" },
);
+2 -34
View File
@@ -25,20 +25,11 @@ const clientConnectionPath = path.join(
"lib",
"ClientConnection.js",
);
const serverPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Server.js",
);
if (
!fs.existsSync(guacdClientPath) ||
!fs.existsSync(cryptPath) ||
!fs.existsSync(clientConnectionPath) ||
!fs.existsSync(serverPath)
!fs.existsSync(clientConnectionPath)
) {
console.log("[patch-guacamole-lite] File not found, skipping");
process.exit(0);
@@ -61,7 +52,6 @@ function missingAnchor(patch) {
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8");
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
let serverContent = fs.readFileSync(serverPath, "utf8");
// Patch 1: protocol version negotiation.
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
@@ -368,27 +358,6 @@ if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
patched = true;
}
// Patch 9: ClientConnection closes malformed-token WebSockets in its
// constructor, but Server.newConnection still called connect() afterwards.
// That dereferenced the absent connection settings and turned one bad token
// into an unhandled rejection that could terminate the backend process.
const oldConnectionSetup =
" newConnection.on('ready', async (clientConnection) => {";
const newConnectionSetup =
" if (!newConnection.connectionSettings || !newConnection.connectionSettings.connection) {\n" +
" return;\n" +
" }\n" +
"\n" +
oldConnectionSetup;
if (!serverContent.includes("!newConnection.connectionSettings.connection")) {
if (!serverContent.includes(oldConnectionSetup)) {
missingAnchor("invalid-token connection guard");
}
serverContent = serverContent.replace(oldConnectionSetup, newConnectionSetup);
patched = true;
}
if (!patched) {
console.log("[patch-guacamole-lite] Already patched");
process.exit(0);
@@ -397,7 +366,6 @@ if (!patched) {
fs.writeFileSync(guacdClientPath, guacdClientContent);
fs.writeFileSync(cryptPath, cryptContent);
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
fs.writeFileSync(serverPath, serverContent);
console.log(
"[patch-guacamole-lite] Patched protocol negotiation, name handshake, required arguments, UTF-8 token decrypt, read-only joins, and malformed-token handling",
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
);
-45
View File
@@ -5,7 +5,6 @@ import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
const GuacamoleLite = require("../node_modules/guacamole-lite/lib/Server.js");
type PatchedGuacdClient = {
connectionSettings: Record<string, unknown>;
@@ -27,50 +26,6 @@ function createPatchedClient(
}
describe("patch-guacamole-lite", () => {
it("rejects a malformed token without starting or retaining a connection", async () => {
const server = Object.assign(Object.create(GuacamoleLite.prototype), {
connectionsCount: 0,
clientOptions: {
crypt: {
cypher: "AES-256-CBC",
key: Buffer.alloc(32, 7),
},
log: {
level: 0,
stdLog: vi.fn(),
errorLog: vi.fn(),
},
},
callbacks: {
processConnectionSettings: vi.fn(),
},
extractGuacdOptions: vi.fn(async () => ({
guacdOptions: { host: "127.0.0.1", port: 4822 },
connectionInfo: null,
isJoin: false,
targetSessionId: null,
})),
activeConnections: new Map(),
emit: vi.fn(),
});
const webSocket = {
OPEN: 1,
readyState: 1,
send: vi.fn(),
close: vi.fn(),
on: vi.fn(),
removeAllListeners: vi.fn(),
};
await server.newConnection(webSocket, {
url: "/guacamole/websocket/?token=not-an-encrypted-token",
});
expect(webSocket.close).toHaveBeenCalledOnce();
expect(server.activeConnections.size).toBe(0);
expect(server.emit).not.toHaveBeenCalledWith("open", expect.anything());
});
it("handles guacd dynamic argument requests", () => {
const guacdClientPath = path.join(
process.cwd(),
-150
View File
@@ -1,150 +0,0 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync, spawnSync } = require("node:child_process");
const architectures = {
x64: ["x64"],
arm64: ["arm64"],
universal: ["x64", "arm64"],
};
function expectedArchitecture(artifact) {
const name = path.basename(artifact);
if (name.includes("_x64_")) return "x64";
if (name.includes("_arm64_")) return "arm64";
if (name.includes("_universal_")) return "universal";
throw new Error(`Cannot determine architecture from artifact name: ${name}`);
}
function findApp(root) {
const pending = [root];
while (pending.length) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isDirectory() && entry.name.endsWith(".app")) return candidate;
if (entry.isDirectory()) pending.push(candidate);
}
}
throw new Error(`No .app bundle found below ${root}`);
}
function containsFile(root, suffix) {
const pending = [root];
while (pending.length) {
const current = pending.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const candidate = path.join(current, entry.name);
if (entry.isDirectory()) pending.push(candidate);
if (entry.isFile() && entry.name.endsWith(suffix)) return true;
}
}
return false;
}
function verifyApp(app, architecture, runtimeCheck) {
const modules = path.join(
app,
"Contents/Resources/app.asar.unpacked/node_modules",
);
for (const arch of architectures[architecture]) {
for (const [packageName, nativeSuffix] of [
[`sharp-darwin-${arch}`, ".node"],
[`sharp-libvips-darwin-${arch}`, ".dylib"],
]) {
const packagePath = path.join(modules, "@img", packageName);
if (
!fs.existsSync(packagePath) ||
!containsFile(packagePath, nativeSuffix)
) {
throw new Error(
`${path.basename(app)} is missing the native binary from @img/${packageName}`,
);
}
}
}
if (!runtimeCheck) return;
const executable = path.join(app, "Contents/MacOS/Termix");
const sharpPath = path.join(modules, "sharp");
const smoke = [
"const sharp = require(process.argv[1]);",
"sharp({create:{width:1,height:1,channels:4,background:'#000'}})",
".png().toBuffer().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });",
].join("");
for (const arch of architectures[architecture]) {
const result = spawnSync(
"arch",
[
arch === "x64" ? "-x86_64" : "-arm64",
executable,
"-e",
smoke,
sharpPath,
],
{
encoding: "utf8",
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
},
);
if (result.status !== 0) {
throw new Error(
`${path.basename(app)} failed the ${arch} sharp runtime smoke test:\n${result.stderr || result.stdout}`,
);
}
}
}
function verifyArtifact(artifact) {
const architecture = expectedArchitecture(artifact);
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-"));
let mountedAt;
try {
if (artifact.endsWith(".dmg")) {
mountedAt = path.join(temporaryRoot, "mounted");
fs.mkdirSync(mountedAt);
execFileSync("hdiutil", [
"attach",
artifact,
"-readonly",
"-nobrowse",
"-mountpoint",
mountedAt,
]);
verifyApp(findApp(mountedAt), architecture, true);
} else if (artifact.endsWith(".pkg")) {
const expanded = path.join(temporaryRoot, "expanded");
execFileSync("pkgutil", ["--expand-full", artifact, expanded]);
verifyApp(findApp(expanded), architecture, false);
} else {
throw new Error(`Unsupported macOS artifact: ${artifact}`);
}
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
} finally {
if (mountedAt) {
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
}
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
module.exports = { expectedArchitecture, verifyApp };
if (require.main === module) {
if (process.platform !== "darwin") {
throw new Error("macOS sharp artifact verification must run on macOS");
}
if (process.argv.length < 3) {
throw new Error(
"Usage: node scripts/verify-macos-sharp.cjs <artifact> [...]",
);
}
for (const artifact of process.argv.slice(2))
verifyArtifact(path.resolve(artifact));
}
-62
View File
@@ -1,62 +0,0 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { expectedArchitecture, verifyApp } = require("./verify-macos-sharp.cjs");
const temporaryDirectories: string[] = [];
function createApp(architectures: string[]) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-test-"));
temporaryDirectories.push(root);
const app = path.join(root, "Termix.app");
const modules = path.join(
app,
"Contents/Resources/app.asar.unpacked/node_modules/@img",
);
for (const architecture of architectures) {
const sharp = path.join(modules, `sharp-darwin-${architecture}/lib`);
const libvips = path.join(
modules,
`sharp-libvips-darwin-${architecture}/lib`,
);
fs.mkdirSync(sharp, { recursive: true });
fs.mkdirSync(libvips, { recursive: true });
fs.writeFileSync(path.join(sharp, `sharp-darwin-${architecture}.node`), "");
fs.writeFileSync(path.join(libvips, "libvips.dylib"), "");
}
return app;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe("macOS sharp artifact verification", () => {
it("derives the expected architecture from artifact names", () => {
expect(expectedArchitecture("termix_macos_x64_dmg.dmg")).toBe("x64");
expect(expectedArchitecture("termix_macos_arm64_dmg.dmg")).toBe("arm64");
expect(expectedArchitecture("termix_macos_universal_mas.pkg")).toBe(
"universal",
);
});
it("accepts a universal app with both sharp architectures", () => {
expect(() =>
verifyApp(createApp(["x64", "arm64"]), "universal", false),
).not.toThrow();
});
it("rejects an x64 app containing only arm64 sharp binaries", () => {
expect(() => verifyApp(createApp(["arm64"]), "x64", false)).toThrow(
/sharp-darwin-x64/,
);
});
});
+5 -10
View File
@@ -51,7 +51,8 @@ function normalizeHost(hostname: string): string {
/**
* True when the URL names a destination the SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else needs DNS resolution.
* private only if it is "localhost" -- anything else resolves through DNS and
* is caught at connect time by the guard instead.
*/
export function isPrivateDestination(rawUrl: string): boolean {
let url: URL;
@@ -97,18 +98,12 @@ export function evaluateEgress(
const host = normalizeHost(url.hostname);
const isPrivate = isPrivateDestination(rawUrl);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An explicitly allowlisted hostname may resolve to a private address. It
// must use the private fetch path; sending it through safeOutboundFetch
// would reject it after DNS resolution and make hostname allowlist entries
// ineffective. Only administrators can write this list.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivate) return { allowed: true, isPrivate: false };
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
return {
allowed: false,
isPrivate: true,
-14
View File
@@ -1,7 +1,6 @@
import { getErrorMessage } from "../utils/error-message.js";
import express from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { AuthManager } from "../utils/auth-manager.js";
import { databaseLogger } from "../utils/logger.js";
import {
@@ -37,7 +36,6 @@ import { applyProposal } from "./tools/executor.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const aiGate = createAiGate();
@@ -99,7 +97,6 @@ router.get("/status", authenticateJWT, async (req, res) => {
router.get(
"/providers",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -150,7 +147,6 @@ router.get(
router.post(
"/providers",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -228,7 +224,6 @@ router.post(
router.patch(
"/providers/:id",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -291,7 +286,6 @@ router.patch(
router.delete(
"/providers/:id",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -364,7 +358,6 @@ router.delete(
router.post(
"/probe-models",
authenticateJWT,
permissionManager.requirePermission("ai.manage_providers"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -430,7 +423,6 @@ router.post(
router.get(
"/providers/:id/models",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -478,7 +470,6 @@ router.get(
router.get(
"/conversations",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -519,7 +510,6 @@ router.get(
router.get(
"/conversations/:id",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -570,7 +560,6 @@ router.get(
router.delete(
"/conversations/:id",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -633,7 +622,6 @@ router.delete(
router.post(
"/chat/stream",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -852,7 +840,6 @@ router.post(
router.post(
"/proposals/:id/apply",
authenticateJWT,
permissionManager.requirePermission("ai.apply_proposals"),
requireDataAccess,
aiGate,
async (req, res) => {
@@ -944,7 +931,6 @@ router.post(
router.post(
"/proposals/:id/reject",
authenticateJWT,
permissionManager.requirePermission("ai.use"),
requireDataAccess,
aiGate,
async (req, res) => {
+7 -4
View File
@@ -1,4 +1,4 @@
import { fetchWithProxy } from "../../utils/proxy-agent.js";
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
import { AiProviderError } from "./types.js";
@@ -9,8 +9,8 @@ import { AiProviderError } from "./types.js";
*
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
* connect time. Allowlisted private hosts cannot use it (its whole job is to
* refuse them), so they use the installed Undici fetch implementation with
* its matching proxy dispatcher -- still respecting proxy configuration.
* refuse them), so they fall back to plain fetch with the proxy dispatcher --
* still respecting corporate proxy configuration.
*/
export async function providerFetch(
url: string,
@@ -24,7 +24,10 @@ export async function providerFetch(
}
if (decision.isPrivate) {
return fetchWithProxy(url, init);
return fetch(url, {
...init,
dispatcher: getFetchDispatcher(url),
} as RequestInit);
}
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
+6 -6
View File
@@ -5,10 +5,12 @@ import type { AiTool, ToolDefinitionShape } from "./types.js";
/**
* The allowlist, and the security boundary for the whole feature.
*
* A model can only ever invoke what appears here. Route-level RBAC gates the
* HTTP API, but tools run in-process with the calling user's identity and never
* pass through a router, so "the assistant cannot reach credentials or user
* administration" is a property of this list, not of the permission system.
* A model can only ever invoke what appears here. This matters more than usual
* in this codebase: PermissionManager.requirePermission exists but is currently
* mounted on zero routes, so RBAC strings are a vocabulary for the admin role
* editor rather than route enforcement. "The assistant cannot reach credentials
* or user administration" is therefore a property of this list, not of the
* permission system.
*
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
* SSO or instance settings is deliberately absent and must stay absent.
@@ -49,8 +51,6 @@ export const FORBIDDEN_DOMAINS = [
"identity",
"certificate",
"opkssh",
"stepca",
"step_ca",
"acme",
"ssl",
"audit",
+6 -8
View File
@@ -1,14 +1,13 @@
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
/**
* Outbound HTTP for automation steps and notification channels.
*
* safeOutboundFetch refuses private and loopback addresses, which is the right
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
* on a LAN that many installs actually use. Private delivery therefore needs
* both a channel opt-in and an exact host in the administrator allowlist;
* scheme validation, DNS pinning and redirect refusal remain in force.
* on a LAN that many installs actually use. Rather than weaken the guard
* globally, a destination can opt in explicitly; everything else about the
* guard (scheme, embedded credentials, no redirects) still applies.
*/
export interface AutomationFetchOptions {
method?: string;
@@ -53,8 +52,8 @@ export async function automationFetch(
}
/**
* The opt-in path still goes through the guarded resolver. Only an exact host
* authorized by an administrator may resolve to a private address.
* The opt-in path. Keeps the parts of the guard that are always right and
* drops only the address blocklist.
*/
async function privateNetworkFetch(
rawUrl: string,
@@ -74,6 +73,5 @@ async function privateNetworkFetch(
throw new Error("URLs with embedded credentials are not allowed");
}
const allowlist = await readNotificationPrivateAllowlist();
return safeOutboundFetch(rawUrl, init, allowlist);
return fetch(rawUrl, { ...init, redirect: "error" });
}
+6 -9
View File
@@ -17,7 +17,6 @@ import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import guacamoleRoutes from "../hosts/guacamole/routes.js";
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
import collabRoutes from "../hosts/collab/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
@@ -30,7 +29,6 @@ import termixIdRoutes from "./routes/termix-id.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js";
import secretSourceRoutes from "./routes/secret-sources.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js";
import aiRoutes from "../ai/index.js";
import automationsRoutes from "./routes/automations.js";
@@ -75,7 +73,7 @@ const __dirname = path.dirname(__filename);
const app = express();
app.set("trust proxy", "loopback");
app.set("trust proxy", true);
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
@@ -261,8 +259,9 @@ async function fetchGitHubAPI<T>(
}
}
app.use(bodyParser.json({ limit: "2mb" }));
app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
app.use(bodyParser.json({ limit: "1gb" }));
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
app.use(cookieParser());
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
@@ -1754,7 +1753,6 @@ app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/session-sharing", sessionSharingRoutes);
app.use("/collab", collabRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
@@ -1767,7 +1765,6 @@ app.use("/termix-id", termixIdRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
app.use("/secret-sources", secretSourceRoutes);
// Before the alert routes, which are mounted at the root and would otherwise
// have first claim on the path.
app.use("/automations", automationsRoutes);
@@ -2038,7 +2035,7 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => {
});
export const serverReady = new Promise<void>((resolve) => {
httpServer.listen(HTTP_PORT, "127.0.0.1", async () => {
httpServer.listen(HTTP_PORT, async () => {
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
@@ -2087,7 +2084,7 @@ if (
});
});
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
httpsServer.listen(sslConfig.port, () => {
databaseLogger.success(
`Backend is now also listening for HTTPS directly`,
{
+14 -130
View File
@@ -21,7 +21,6 @@ import {
} from "../../utils/data-dir-guard.js";
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
import { connectRemoteDatabase } from "./connect.js";
import { runRemoteMigrations } from "./migrate.js";
import type { PortableDatabase } from "../repositories/database-context.js";
@@ -578,107 +577,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS collab_rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
persistent INTEGER NOT NULL DEFAULT 0,
presenter_user_id TEXT,
stage_protocol TEXT,
stage_host_id INTEGER,
stage_share_id TEXT,
guest_link_token TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS collab_room_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
room_role TEXT NOT NULL DEFAULT 'member',
added_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (room_id, user_id),
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS secret_sources (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'onepassword-connect',
base_url TEXT NOT NULL,
token TEXT NOT NULL,
shared INTEGER NOT NULL 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
);
CREATE TABLE IF NOT EXISTS credential_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'use',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_credential_access_user_id ON credential_access (user_id);
CREATE INDEX IF NOT EXISTS idx_credential_access_role_id ON credential_access (role_id);
CREATE INDEX IF NOT EXISTS idx_credential_access_credential_id ON credential_access (credential_id);
CREATE TABLE IF NOT EXISTS shared_credential_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_access_id INTEGER NOT NULL,
target_user_id TEXT NOT NULL,
credential_id INTEGER NOT NULL,
encrypted_username TEXT,
auth_type TEXT NOT NULL DEFAULT 'password',
encrypted_password TEXT,
encrypted_key TEXT,
encrypted_key_password TEXT,
key_type TEXT,
public_key TEXT,
cert_public_key TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (credential_access_id, target_user_id),
FOREIGN KEY (credential_access_id) REFERENCES credential_access (id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
CREATE TABLE IF NOT EXISTS folder_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id TEXT NOT NULL,
folder TEXT NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'connect',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -2055,24 +1953,20 @@ const migrateSchema = () => {
});
}
const systemRoles = Object.entries(SYSTEM_ROLE_DEFAULTS).map(
([name, defaults]) => ({
name,
displayName: `rbac.roles.${name}`,
description: defaults.description,
permissions: JSON.stringify(defaults.permissions),
}),
);
// Route-level RBAC needs the permission lists to exist; roles seeded by
// earlier versions carried NULL there. Backfill only NULL so an admin's
// edits to these roles are never overwritten.
const backfillPermissions = sqlite.prepare(
"UPDATE roles SET permissions = ? WHERE name = ? AND is_system = 1 AND permissions IS NULL",
);
for (const role of systemRoles) {
backfillPermissions.run(role.permissions, role.name);
}
const systemRoles = [
{
name: "admin",
displayName: "rbac.roles.admin",
description: "Administrator with full access",
permissions: null,
},
{
name: "user",
displayName: "rbac.roles.user",
description: "Regular user",
permissions: null,
},
];
for (const role of systemRoles) {
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
@@ -2141,10 +2035,6 @@ const migrateSchema = () => {
}
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
addColumnIfNotExists("collab_rooms", "guest_link_token", "TEXT");
sqlite.exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_rooms_guest_token ON collab_rooms (guest_link_token)",
);
try {
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
@@ -3096,12 +2986,6 @@ async function initializeRemoteDatabase(
await primeCurrentSettingsCache();
startSettingsCacheRefresh();
// The SQLite bootstrap seeds system roles inline below; migrations for the
// remote dialects never did, and route-level RBAC denies a user with no
// usable role, so they are seeded (and backfilled) here.
const { ensureSystemRoles } = await import("../../utils/system-roles.js");
await ensureSystemRoles();
databaseLogger.info(`${dialect} database ready`, {
operation: "db_init_complete",
dialect,
+4 -241
View File
@@ -16,7 +16,6 @@ import {
double,
index,
uniqueIndex,
foreignKey,
type AnyMySqlColumn,
} from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm";
@@ -151,7 +150,7 @@ export const hosts = mysqlTable(
ip: text("ip").notNull(),
port: int("port").notNull(),
username: text("username").notNull(),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: int("parent_host_id").references(
@@ -440,7 +439,7 @@ export const sshCredentials = mysqlTable(
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -506,7 +505,7 @@ export const snippets = mysqlTable(
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
order: int("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
@@ -1023,7 +1022,7 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
@@ -1940,239 +1939,3 @@ export const aiProposals = mysqlTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = mysqlTable(
"collab_rooms",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: boolean("persistent")
.notNull()
.default(false),
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: int("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: varchar("guest_link_token", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = mysqlTable(
"collab_room_members",
{
id: int("id").autoincrement().primaryKey(),
roomId: varchar("room_id", { length: 255 })
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = mysqlTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = mysqlTable(
"credential_access",
{
id: int("id").autoincrement().primaryKey(),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = mysqlTable(
"shared_credential_secrets",
{
id: int("id").autoincrement().primaryKey(),
credentialAccessId: int("credential_access_id").notNull(),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key"),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = mysqlTable(
"folder_access",
{
id: int("id").autoincrement().primaryKey(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: varchar("folder", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
+4 -241
View File
@@ -17,7 +17,6 @@ import {
doublePrecision,
index,
uniqueIndex,
foreignKey,
type AnyPgColumn,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
@@ -152,7 +151,7 @@ export const hosts = pgTable(
ip: text("ip").notNull(),
port: integer("port").notNull(),
username: text("username").notNull(),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
// Sub-host nesting: a host acting as an organizational parent for other
// hosts, mutually exclusive with folder (see host route validation).
parentHostId: integer("parent_host_id").references(
@@ -441,7 +440,7 @@ export const sshCredentials = pgTable(
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
tags: text("tags"),
pin: boolean("pin").notNull().default(false),
// Manual drag-to-reorder position within a folder. Null means the
@@ -507,7 +506,7 @@ export const snippets = pgTable(
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
order: integer("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
@@ -1024,7 +1023,7 @@ export const vaultProfiles = pgTable("vault_profiles", {
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
folder: text("folder"),
tags: text("tags"),
// Vault server connection (non-secret)
vaultAddr: text("vault_addr").notNull(),
@@ -1941,239 +1940,3 @@ export const aiProposals = pgTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = pgTable(
"collab_rooms",
{
id: varchar("id", { length: 255 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: boolean("persistent")
.notNull()
.default(false),
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: integer("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: varchar("guest_link_token", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = pgTable(
"collab_room_members",
{
id: serial("id").primaryKey(),
roomId: varchar("room_id", { length: 255 })
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
onDelete: "set null",
}),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = pgTable(
"secret_sources",
{
id: varchar("id", { length: 255 }).primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = pgTable(
"credential_access",
{
id: serial("id").primaryKey(),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = pgTable(
"shared_credential_secrets",
{
id: serial("id").primaryKey(),
credentialAccessId: integer("credential_access_id").notNull(),
targetUserId: varchar("target_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key"),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = pgTable(
"folder_access",
{
id: serial("id").primaryKey(),
ownerUserId: varchar("owner_user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: varchar("folder", { length: 255 }).notNull(),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
-237
View File
@@ -5,7 +5,6 @@ import {
real,
index,
uniqueIndex,
foreignKey,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
@@ -1937,239 +1936,3 @@ export const aiProposals = sqliteTable(
],
);
// --- ai end ---
// --- collab rooms ---
/**
* A collaboration room: a group of users watching one "stage" - the live
* session the current presenter is showing. The stage points at a
* shareType="room" row in session_shares, so transport, gating, recording and
* expiry all reuse the session-sharing machinery.
*/
export const collabRooms = sqliteTable(
"collab_rooms",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
ownerUserId: text("owner_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Persistent rooms survive being emptied and can be re-used; one-off
// rooms are ended explicitly and never listed again.
persistent: integer("persistent", { mode: "boolean" })
.notNull()
.default(false),
presenterUserId: text("presenter_user_id").references(() => users.id, {
onDelete: "set null",
}),
stageProtocol: text("stage_protocol"),
stageHostId: integer("stage_host_id").references(() => hosts.id, {
onDelete: "set null",
}),
stageShareId: text("stage_share_id").references(() => sessionShares.id, {
onDelete: "set null",
}),
// Set = anonymous guests may watch the stage through this token.
guestLinkToken: text("guest_link_token"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
endedAt: text("ended_at"),
},
(table) => [
index("idx_collab_rooms_owner").on(table.ownerUserId),
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
],
);
export const collabRoomMembers = sqliteTable(
"collab_room_members",
{
id: integer("id").primaryKey({ autoIncrement: true }),
roomId: text("room_id")
.notNull()
.references(() => collabRooms.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// "host" runs the room: invites, force-switches the presenter, ends it.
roomRole: text("room_role").notNull().default("member"),
addedBy: text("added_by").references(() => users.id, {
onDelete: "set null",
}),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_collab_room_members_room_user").on(
table.roomId,
table.userId,
),
index("idx_collab_room_members_user").on(table.userId),
],
);
// --- secret sources ---
/**
* An external password manager Termix pulls secrets from at connect time,
* instead of storing them. Only the access token is secret; it is encrypted
* with the owner's data key under the row id. Hosts and credentials refer to
* entries by reference ("op://vault/item/field") in their secret fields.
*/
export const secretSources = sqliteTable(
"secret_sources",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
// "onepassword-connect" for now; the reference syntax is per kind.
kind: text("kind").notNull().default("onepassword-connect"),
baseUrl: text("base_url").notNull(),
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_secret_sources_user").on(table.userId)],
);
// --- credential sharing ---
/**
* Who may use or manage someone else's credential. Same shape as
* snippet_access; "use" attaches it to hosts and connects, "manage" also
* edits and re-shares it.
*/
export const credentialAccess = sqliteTable(
"credential_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("use"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_credential_access_user_id").on(table.userId),
index("idx_credential_access_role_id").on(table.roleId),
index("idx_credential_access_credential_id").on(table.credentialId),
],
);
/**
* A recipient's copy of a shared credential's secrets, re-encrypted under
* the recipient's data key (the owner's key cannot be used by anyone else).
* Rebuilt whenever the owner edits the credential; one row per grant and
* recipient, like shared_host_secrets.
*/
export const sharedCredentialSecrets = sqliteTable(
"shared_credential_secrets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
credentialAccessId: integer("credential_access_id").notNull(),
targetUserId: text("target_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
encryptedUsername: text("encrypted_username"),
authType: text("auth_type").notNull().default("password"),
encryptedPassword: text("encrypted_password"),
encryptedKey: text("encrypted_key", { length: 16384 }),
encryptedKeyPassword: text("encrypted_key_password"),
keyType: text("key_type"),
publicKey: text("public_key", { length: 4096 }),
certPublicKey: text("cert_public_key", { length: 8192 }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
foreignKey({
columns: [table.credentialAccessId],
foreignColumns: [credentialAccess.id],
name: "shared_cred_secrets_access_id_fk",
}).onDelete("cascade"),
uniqueIndex("idx_shared_credential_secrets_scope").on(
table.credentialAccessId,
table.targetUserId,
),
index("idx_shared_credential_secrets_target").on(
table.targetUserId,
table.credentialId,
),
],
);
// --- folder access rules ---
/**
* A standing share on a host folder. Sharing a folder fans out host_access
* grants to the hosts in it today; this row is what makes hosts created in
* or moved into the folder later inherit the same access.
*/
export const folderAccess = sqliteTable(
"folder_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
ownerUserId: text("owner_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
folder: text("folder").notNull(),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
],
);
@@ -1,222 +0,0 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
import { rowsAffected } from "./mutation-result.js";
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
export type CollabRoomRole = "host" | "member";
export interface CollabRoomMemberWithUser {
userId: string;
username: string;
roomRole: string;
createdAt: string;
}
export interface CollabRoomStage {
presenterUserId: string | null;
stageProtocol: string | null;
stageHostId: number | null;
stageShareId: string | null;
}
export class CollabRoomRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async createRoom(input: {
id: string;
name: string;
ownerUserId: string;
persistent: boolean;
}): Promise<CollabRoomRecord> {
const [created] = await insertReturning(this.context, collabRooms, {
id: input.id,
name: input.name,
ownerUserId: input.ownerUserId,
persistent: input.persistent,
});
await this.afterWrite();
return created;
}
async findById(id: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(eq(collabRooms.id, id))
.limit(1);
return rows[0] ?? null;
}
/** The live room whose stage points at this share, if any. */
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
)
.limit(1);
return rows[0] ?? null;
}
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRooms)
.where(
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
)
.limit(1);
return rows[0] ?? null;
}
async setGuestToken(roomId: string, token: string | null): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set({ guestLinkToken: token })
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
const rows = await this.context.drizzle
.select({ room: collabRooms })
.from(collabRoomMembers)
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
.where(
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
)
.orderBy(desc(collabRooms.createdAt));
return rows.map((row) => row.room);
}
async findMember(
roomId: string,
userId: string,
): Promise<CollabRoomMemberRecord | null> {
const rows = await this.context.drizzle
.select()
.from(collabRoomMembers)
.where(
and(
eq(collabRoomMembers.roomId, roomId),
eq(collabRoomMembers.userId, userId),
),
)
.limit(1);
return rows[0] ?? null;
}
async addMember(input: {
roomId: string;
userId: string;
roomRole: CollabRoomRole;
addedBy: string | null;
}): Promise<boolean> {
if (await this.findMember(input.roomId, input.userId)) return false;
await this.context.drizzle.insert(collabRoomMembers).values({
roomId: input.roomId,
userId: input.userId,
roomRole: input.roomRole,
addedBy: input.addedBy,
});
await this.afterWrite();
return true;
}
async removeMember(roomId: string, userId: string): Promise<void> {
await this.context.drizzle
.delete(collabRoomMembers)
.where(
and(
eq(collabRoomMembers.roomId, roomId),
eq(collabRoomMembers.userId, userId),
),
);
await this.afterWrite();
}
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
return this.context.drizzle
.select({
userId: collabRoomMembers.userId,
username: users.username,
roomRole: collabRoomMembers.roomRole,
createdAt: collabRoomMembers.createdAt,
})
.from(collabRoomMembers)
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
.where(eq(collabRoomMembers.roomId, roomId))
.orderBy(users.username);
}
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set(stage)
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async replaceStage(
roomId: string,
expectedShareId: string | null,
stage: CollabRoomStage,
): Promise<boolean> {
const result = await this.context.drizzle
.update(collabRooms)
.set(stage)
.where(
and(
eq(collabRooms.id, roomId),
expectedShareId
? eq(collabRooms.stageShareId, expectedShareId)
: isNull(collabRooms.stageShareId),
),
);
const changed = rowsAffected(result) > 0;
if (changed) await this.afterWrite();
return changed;
}
async clearStage(roomId: string): Promise<void> {
return this.updateStage(roomId, {
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
});
}
async endRoom(roomId: string): Promise<void> {
await this.context.drizzle
.update(collabRooms)
.set({
endedAt: new Date().toISOString(),
presenterUserId: null,
stageProtocol: null,
stageHostId: null,
stageShareId: null,
})
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
async deleteRoom(roomId: string): Promise<void> {
await this.context.drizzle
.delete(collabRooms)
.where(eq(collabRooms.id, roomId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,276 +0,0 @@
import { and, eq, gte, inArray, isNull, or, type SQL } from "drizzle-orm";
import { alias } from "drizzle-orm/sqlite-core";
import {
credentialAccess,
roles,
sshCredentials,
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
export type CredentialAccessRecord = typeof credentialAccess.$inferSelect;
export type CredentialPermissionLevel = "use" | "manage";
export interface CredentialAccessListItem extends CredentialAccessRecord {
targetType: "user" | "role";
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
grantedByUsername: string | null;
}
export type CredentialAccessTarget =
| { targetType: "user"; targetUserId: string }
| { targetType: "role"; targetRoleId: number };
export interface UpsertCredentialAccessInput {
credentialId: number;
grantedBy: string;
permissionLevel: CredentialPermissionLevel;
expiresAt: string | null;
target: CredentialAccessTarget;
}
/** A credential shared with the caller, with the grant that admits them. */
export interface SharedCredentialGrant {
accessId: number;
credentialId: number;
ownerId: string;
permissionLevel: string;
expiresAt: string | null;
}
function activeFilter(now: string): SQL {
return or(
isNull(credentialAccess.expiresAt),
gte(credentialAccess.expiresAt, now),
)!;
}
function granteeFilter(userId: string, roleIds: number[]): SQL {
return roleIds.length === 0
? eq(credentialAccess.userId, userId)
: or(
eq(credentialAccess.userId, userId),
inArray(credentialAccess.roleId, roleIds),
)!;
}
export class CredentialAccessRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listForCredential(
credentialId: number,
): Promise<CredentialAccessListItem[]> {
const granter = alias(users, "granter");
const rows = await this.context.drizzle
.select({
access: credentialAccess,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
grantedByUsername: granter.username,
})
.from(credentialAccess)
.leftJoin(users, eq(credentialAccess.userId, users.id))
.leftJoin(roles, eq(credentialAccess.roleId, roles.id))
.leftJoin(granter, eq(credentialAccess.grantedBy, granter.id))
.where(eq(credentialAccess.credentialId, credentialId));
return rows.map((row) => ({
...row.access,
targetType: row.access.roleId ? ("role" as const) : ("user" as const),
username: row.username,
roleName: row.roleName,
roleDisplayName: row.roleDisplayName,
grantedByUsername: row.grantedByUsername,
}));
}
async upsert(
input: UpsertCredentialAccessInput,
): Promise<{ id: number; created: boolean }> {
const targetFilter =
input.target.targetType === "user"
? eq(credentialAccess.userId, input.target.targetUserId)
: eq(credentialAccess.roleId, input.target.targetRoleId);
const existing = await this.context.drizzle
.select({ id: credentialAccess.id })
.from(credentialAccess)
.where(
and(
eq(credentialAccess.credentialId, input.credentialId),
targetFilter,
),
)
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(credentialAccess)
.set({
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
grantedBy: input.grantedBy,
})
.where(eq(credentialAccess.id, existing[0].id));
await this.afterWrite();
return { id: existing[0].id, created: false };
}
const [created] = await insertReturning(this.context, credentialAccess, {
credentialId: input.credentialId,
userId:
input.target.targetType === "user" ? input.target.targetUserId : null,
roleId:
input.target.targetType === "role" ? input.target.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
});
await this.afterWrite();
return { id: created.id, created: true };
}
async findById(
accessId: number,
credentialId: number,
): Promise<CredentialAccessRecord | null> {
const rows = await this.context.drizzle
.select()
.from(credentialAccess)
.where(
and(
eq(credentialAccess.id, accessId),
eq(credentialAccess.credentialId, credentialId),
),
)
.limit(1);
return rows[0] ?? null;
}
async revoke(accessId: number, credentialId: number): Promise<void> {
await this.context.drizzle
.delete(credentialAccess)
.where(
and(
eq(credentialAccess.id, accessId),
eq(credentialAccess.credentialId, credentialId),
),
);
await this.afterWrite();
}
/** The strongest unexpired grant admitting this user to the credential. */
async findActiveGrant(
credentialId: number,
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<CredentialAccessRecord | null> {
const rows = await this.context.drizzle
.select()
.from(credentialAccess)
.where(
and(
eq(credentialAccess.credentialId, credentialId),
granteeFilter(userId, roleIds),
activeFilter(now),
),
);
return (
rows.find((row) => row.permissionLevel === "manage") ?? rows[0] ?? null
);
}
/** Every credential shared with this user (directly or via a role). */
async listSharedWithUser(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<SharedCredentialGrant[]> {
const rows = await this.context.drizzle
.select({
accessId: credentialAccess.id,
credentialId: credentialAccess.credentialId,
ownerId: sshCredentials.userId,
permissionLevel: credentialAccess.permissionLevel,
expiresAt: credentialAccess.expiresAt,
})
.from(credentialAccess)
.innerJoin(
sshCredentials,
eq(credentialAccess.credentialId, sshCredentials.id),
)
.where(and(granteeFilter(userId, roleIds), activeFilter(now)));
// Direct and role grants can overlap; keep the strongest per credential.
const best = new Map<number, SharedCredentialGrant>();
for (const row of rows) {
if (row.ownerId === userId) continue;
const current = best.get(row.credentialId);
if (!current || row.permissionLevel === "manage")
best.set(row.credentialId, row);
}
return Array.from(best.values());
}
/** Active grants on one credential, expanded to user and role targets. */
async listActiveGrants(
credentialId: number,
now = new Date().toISOString(),
): Promise<CredentialAccessRecord[]> {
return this.context.drizzle
.select()
.from(credentialAccess)
.where(
and(eq(credentialAccess.credentialId, credentialId), activeFilter(now)),
);
}
/** Grants a role holds, with the credential owner - for new-member snapshots. */
async listRoleGrants(
roleId: number,
): Promise<
Array<{ accessId: number; credentialId: number; ownerId: string }>
> {
return this.context.drizzle
.select({
accessId: credentialAccess.id,
credentialId: credentialAccess.credentialId,
ownerId: sshCredentials.userId,
})
.from(credentialAccess)
.innerJoin(
sshCredentials,
eq(credentialAccess.credentialId, sshCredentials.id),
)
.where(eq(credentialAccess.roleId, roleId));
}
async deleteForUserReferences(userId: string): Promise<void> {
await this.context.drizzle
.delete(credentialAccess)
.where(
or(
eq(credentialAccess.userId, userId),
eq(credentialAccess.grantedBy, userId),
),
);
await this.afterWrite();
}
async reassignGrantedBy(fromUserId: string, toUserId: string): Promise<void> {
await this.context.drizzle
.update(credentialAccess)
.set({ grantedBy: toUserId })
.where(eq(credentialAccess.grantedBy, fromUserId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -348,54 +348,6 @@ export class CredentialRepository {
);
}
/**
* Re-keys every credential of one user to another: decrypt under the old
* owner's key, encrypt under the new one, change the owner. Used when an
* account is deleted and its data is inherited rather than dropped.
*/
async transferAllToUser(
fromUserId: string,
toUserId: string,
): Promise<number[]> {
const fromKey = DataCrypto.validateUserAccess(fromUserId);
const toKey = DataCrypto.validateUserAccess(toUserId);
const rows = await this.context.drizzle
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, fromUserId));
const moved: number[] = [];
for (const row of rows) {
const plain = DataCrypto.decryptRecord(
"ssh_credentials",
row,
fromUserId,
fromKey,
);
const {
id: _id,
userId: _userId,
...fields
} = plain as Record<string, unknown>;
const encrypted = DataCrypto.encryptRecord(
"ssh_credentials",
{ ...fields, id: row.id },
toUserId,
toKey,
) as Record<string, unknown>;
delete encrypted.id;
await this.context.drizzle
.update(sshCredentials)
.set({
...(encrypted as Partial<NewCredentialRecord>),
userId: toUserId,
})
.where(eq(sshCredentials.id, row.id));
moved.push(row.id);
}
if (moved.length) await this.afterWrite();
return moved;
}
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
record: T,
userId: string,
@@ -38,11 +38,6 @@ import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js";
import { SessionShareRepository } from "./session-share-repository.js";
import { CollabRoomRepository } from "./collab-room-repository.js";
import { SecretSourceRepository } from "./secret-source-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
@@ -400,13 +395,6 @@ export function createCurrentSessionRepository(): SessionRepository {
);
}
export function createCurrentCollabRoomRepository(): CollabRoomRepository {
return new CollabRoomRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("collab_room_repository_write"),
);
}
export function createCurrentSessionShareRepository(): SessionShareRepository {
return new SessionShareRepository(
createCurrentRepositoryContext(),
@@ -511,36 +499,6 @@ export function createCurrentUserRepository(): UserRepository {
);
}
export function createCurrentFolderAccessRepository(): FolderAccessRepository {
return new FolderAccessRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("folder_access_repository_write"),
);
}
export function createCurrentCredentialAccessRepository(): CredentialAccessRepository {
return new CredentialAccessRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("credential_access_repository_write"),
);
}
export function createCurrentSharedCredentialSecretsRepository(): SharedCredentialSecretsRepository {
return new SharedCredentialSecretsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_credential_secrets_repository_write",
),
);
}
export function createCurrentSecretSourceRepository(): SecretSourceRepository {
return new SecretSourceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("secret_source_repository_write"),
);
}
export function createCurrentVaultProfileRepository(): VaultProfileRepository {
return new VaultProfileRepository(
createCurrentRepositoryContext(),
@@ -1,190 +0,0 @@
import { and, eq, inArray, like, or } from "drizzle-orm";
import { folderAccess, roles, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
export type FolderAccessRecord = typeof folderAccess.$inferSelect;
export interface FolderAccessListItem extends FolderAccessRecord {
targetType: "user" | "role";
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
}
export type FolderAccessTarget =
| { targetType: "user"; targetUserId: string }
| { targetType: "role"; targetRoleId: number };
/** "A / B / C" → ["A", "A / B", "A / B / C"]: a host inherits rules on every ancestor. */
export function folderAncestors(folder: string): string[] {
const parts = folder.split(" / ");
return parts.map((_, i) => parts.slice(0, i + 1).join(" / "));
}
export class FolderAccessRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(input: {
ownerUserId: string;
folder: string;
grantedBy: string;
permissionLevel: string;
expiresAt: string | null;
target: FolderAccessTarget;
}): Promise<FolderAccessRecord> {
const targetFilter =
input.target.targetType === "user"
? eq(folderAccess.userId, input.target.targetUserId)
: eq(folderAccess.roleId, input.target.targetRoleId);
const existing = await this.context.drizzle
.select()
.from(folderAccess)
.where(
and(
eq(folderAccess.ownerUserId, input.ownerUserId),
eq(folderAccess.folder, input.folder),
targetFilter,
),
)
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(folderAccess)
.set({
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
grantedBy: input.grantedBy,
})
.where(eq(folderAccess.id, existing[0].id));
await this.afterWrite();
return {
...existing[0],
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
};
}
const [created] = await insertReturning(this.context, folderAccess, {
ownerUserId: input.ownerUserId,
folder: input.folder,
userId:
input.target.targetType === "user" ? input.target.targetUserId : null,
roleId:
input.target.targetType === "role" ? input.target.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: input.permissionLevel,
expiresAt: input.expiresAt,
});
await this.afterWrite();
return created;
}
/** Rules that apply to a host in this folder: the folder's own and its ancestors'. */
async listApplicable(
ownerUserId: string,
folder: string,
now = new Date().toISOString(),
): Promise<FolderAccessRecord[]> {
const rows = await this.context.drizzle
.select()
.from(folderAccess)
.where(
and(
eq(folderAccess.ownerUserId, ownerUserId),
inArray(folderAccess.folder, folderAncestors(folder)),
),
);
return rows.filter((row) => !row.expiresAt || row.expiresAt >= now);
}
async listForFolder(
ownerUserId: string,
folder: string,
): Promise<FolderAccessListItem[]> {
const rows = await this.context.drizzle
.select({
rule: folderAccess,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
})
.from(folderAccess)
.leftJoin(users, eq(folderAccess.userId, users.id))
.leftJoin(roles, eq(folderAccess.roleId, roles.id))
.where(
and(
eq(folderAccess.ownerUserId, ownerUserId),
eq(folderAccess.folder, folder),
),
);
return rows.map((row) => ({
...row.rule,
targetType: row.rule.roleId ? ("role" as const) : ("user" as const),
username: row.username,
roleName: row.roleName,
roleDisplayName: row.roleDisplayName,
}));
}
async findById(
id: number,
ownerUserId: string,
): Promise<FolderAccessRecord | null> {
const rows = await this.context.drizzle
.select()
.from(folderAccess)
.where(
and(eq(folderAccess.id, id), eq(folderAccess.ownerUserId, ownerUserId)),
)
.limit(1);
return rows[0] ?? null;
}
async deleteById(id: number, ownerUserId: string): Promise<void> {
await this.context.drizzle
.delete(folderAccess)
.where(
and(eq(folderAccess.id, id), eq(folderAccess.ownerUserId, ownerUserId)),
);
await this.afterWrite();
}
/** Follows a folder rename, including subfolders. */
async renameFolder(
ownerUserId: string,
oldName: string,
newName: string,
): Promise<void> {
const prefix = `${oldName} / `;
const rows = await this.context.drizzle
.select({ id: folderAccess.id, folder: folderAccess.folder })
.from(folderAccess)
.where(
and(
eq(folderAccess.ownerUserId, ownerUserId),
or(
eq(folderAccess.folder, oldName),
like(folderAccess.folder, `${prefix}%`),
),
),
);
for (const row of rows) {
const renamed =
row.folder === oldName
? newName
: `${newName} / ${row.folder.slice(prefix.length)}`;
await this.context.drizzle
.update(folderAccess)
.set({ folder: renamed })
.where(eq(folderAccess.id, row.id));
}
if (rows.length) await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -168,47 +168,6 @@ export class HostRepository {
return rows[0] ?? null;
}
/** Re-keys every host of one user to another; see CredentialRepository.transferAllToUser. */
async transferAllToUser(
fromUserId: string,
toUserId: string,
): Promise<number[]> {
const fromKey = DataCrypto.validateUserAccess(fromUserId);
const toKey = DataCrypto.validateUserAccess(toUserId);
const rows = await this.context.drizzle
.select()
.from(hosts)
.where(eq(hosts.userId, fromUserId));
const moved: number[] = [];
for (const row of rows) {
const plain = DataCrypto.decryptRecord(
"ssh_data",
row,
fromUserId,
fromKey,
);
const {
id: _id,
userId: _userId,
...fields
} = plain as Record<string, unknown>;
const encrypted = DataCrypto.encryptRecord(
"ssh_data",
{ ...fields, id: row.id },
toUserId,
toKey,
) as Record<string, unknown>;
delete encrypted.id;
await this.context.drizzle
.update(hosts)
.set({ ...(encrypted as Partial<HostUpdate>), userId: toUserId })
.where(eq(hosts.id, row.id));
moved.push(row.id);
}
if (moved.length) await this.afterWrite();
return moved;
}
async updateEncryptedForUser(
userId: string,
hostId: number,
@@ -207,18 +207,6 @@ export class RbacAccessRepository {
return rowsAffected(result);
}
/** Grants the departing user handed out now count as the successor's. */
async reassignHostAccessGrantedBy(
fromUserId: string,
toUserId: string,
): Promise<void> {
await this.context.drizzle
.update(hostAccess)
.set({ grantedBy: toUserId })
.where(eq(hostAccess.grantedBy, fromUserId));
await this.afterWrite();
}
async deleteHostAccessForUserReferences(userId: string): Promise<number> {
const directResult = await this.context.drizzle
.delete(hostAccess)
@@ -1,5 +1,5 @@
import { and, eq, inArray } from "drizzle-orm";
import { hostAccess, roles, userRoles, users } from "../db/schema.js";
import { hostAccess, roles, userRoles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { deleteReturning, insertReturning } from "./returning.js";
@@ -8,7 +8,7 @@ export type RoleRecord = typeof roles.$inferSelect;
export type NewRoleRecord = typeof roles.$inferInsert;
export type RoleUpdate = Pick<
Partial<NewRoleRecord>,
"displayName" | "description" | "permissions" | "updatedAt"
"displayName" | "description" | "updatedAt"
>;
export type UserRoleWithRole = {
@@ -21,13 +21,6 @@ export type UserRoleWithRole = {
grantedAt: string;
};
export type RoleMemberRecord = {
userId: string;
username: string;
grantedAt: string;
grantedBy: string | null;
};
export type UserRolePermissionRecord = {
permissions: string | null;
};
@@ -233,20 +226,6 @@ export class RoleRepository {
return rows.map((row) => row.userId);
}
async listRoleMembers(roleId: number): Promise<RoleMemberRecord[]> {
return this.context.drizzle
.select({
userId: userRoles.userId,
username: users.username,
grantedAt: userRoles.grantedAt,
grantedBy: userRoles.grantedBy,
})
.from(userRoles)
.innerJoin(users, eq(userRoles.userId, users.id))
.where(eq(userRoles.roleId, roleId))
.orderBy(users.username);
}
async listUserRolePermissions(
userId: string,
): Promise<UserRolePermissionRecord[]> {
@@ -1,135 +0,0 @@
import { desc, eq, or } from "drizzle-orm";
import { secretSources } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { insertReturning } from "./returning.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
export type SecretSourceRecord = typeof secretSources.$inferSelect;
export type SecretSourceKind = "onepassword-connect";
/** A row with the token still encrypted - safe to hand to the API. */
export type SecretSourcePublic = Omit<SecretSourceRecord, "token"> & {
hasToken: boolean;
};
export interface SecretSourceCreateInput {
id: string;
userId: string;
name: string;
kind: SecretSourceKind;
baseUrl: string;
token: string;
shared: boolean;
}
export interface SecretSourceUpdateInput {
name?: string;
baseUrl?: string;
token?: string;
shared?: boolean;
}
export function toPublicSecretSource(
row: SecretSourceRecord,
): SecretSourcePublic {
const { token, ...rest } = row;
return { ...rest, hasToken: token.length > 0 };
}
/**
* The token is encrypted with the owner's data key under the row id, like
* vault_tokens - so a shared source only decrypts while its owner's key is
* loaded, which the resolver reports as a clear error.
*/
export class SecretSourceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
private encryptToken(id: string, ownerId: string, token: string): string {
const key = DataCrypto.validateUserAccess(ownerId);
return FieldCrypto.encryptField(token, key, id, "token");
}
decryptToken(row: SecretSourceRecord): string {
const key = DataCrypto.getUserDataKey(row.userId);
if (!key) {
throw new Error(
"The secret source owner's data is locked; they need to sign in first",
);
}
return FieldCrypto.decryptField(row.token, key, row.id, "token");
}
async listVisibleToUser(userId: string): Promise<SecretSourceRecord[]> {
return this.context.drizzle
.select()
.from(secretSources)
.where(
or(eq(secretSources.userId, userId), eq(secretSources.shared, true)),
)
.orderBy(desc(secretSources.updatedAt));
}
async findById(id: string): Promise<SecretSourceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(secretSources)
.where(eq(secretSources.id, id))
.limit(1);
return rows[0] ?? null;
}
async create(input: SecretSourceCreateInput): Promise<SecretSourceRecord> {
const [created] = await insertReturning(this.context, secretSources, {
id: input.id,
userId: input.userId,
name: input.name,
kind: input.kind,
baseUrl: input.baseUrl,
token: this.encryptToken(input.id, input.userId, input.token),
shared: input.shared,
});
await this.afterWrite();
return created;
}
async update(
row: SecretSourceRecord,
input: SecretSourceUpdateInput,
): Promise<void> {
await this.context.drizzle
.update(secretSources)
.set({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
...(input.shared !== undefined ? { shared: input.shared } : {}),
...(input.token !== undefined
? { token: this.encryptToken(row.id, row.userId, input.token) }
: {}),
updatedAt: new Date().toISOString(),
})
.where(eq(secretSources.id, row.id));
await this.afterWrite();
}
async deleteById(id: string): Promise<void> {
await this.context.drizzle
.delete(secretSources)
.where(eq(secretSources.id, id));
await this.afterWrite();
}
async deleteByUserId(userId: string): Promise<void> {
await this.context.drizzle
.delete(secretSources)
.where(eq(secretSources.userId, userId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -13,9 +13,7 @@ export type SessionShareRecord = typeof sessionShares.$inferSelect;
export type SessionShareParticipantRecord =
typeof sessionShareParticipants.$inferSelect;
// "room" shares are minted internally by the collab-room routes and are
// joinable by any member of the room whose stage references them.
export type SessionShareType = "link" | "user" | "room";
export type SessionShareType = "link" | "user";
export type SessionSharePermissionLevel = "read-only" | "read-write";
export interface SessionShareCreateInput {
@@ -1,90 +0,0 @@
import { and, eq, inArray } from "drizzle-orm";
import { credentialAccess, sharedCredentialSecrets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
export type SharedCredentialSecretRecord =
typeof sharedCredentialSecrets.$inferSelect;
export type NewSharedCredentialSecretRecord =
typeof sharedCredentialSecrets.$inferInsert;
export class SharedCredentialSecretsRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async upsert(record: NewSharedCredentialSecretRecord): Promise<void> {
const existing = await this.context.drizzle
.select({ id: sharedCredentialSecrets.id })
.from(sharedCredentialSecrets)
.where(
and(
eq(
sharedCredentialSecrets.credentialAccessId,
record.credentialAccessId,
),
eq(sharedCredentialSecrets.targetUserId, record.targetUserId),
),
)
.limit(1);
if (existing[0]) {
await this.context.drizzle
.update(sharedCredentialSecrets)
.set({ ...record, updatedAt: new Date().toISOString() })
.where(eq(sharedCredentialSecrets.id, existing[0].id));
} else {
await this.context.drizzle.insert(sharedCredentialSecrets).values(record);
}
await this.afterWrite();
}
/** The recipient's snapshot for a credential, whichever grant produced it. */
async findForCredentialUser(
credentialId: number,
targetUserId: string,
): Promise<SharedCredentialSecretRecord | null> {
const rows = await this.context.drizzle
.select()
.from(sharedCredentialSecrets)
.where(
and(
eq(sharedCredentialSecrets.credentialId, credentialId),
eq(sharedCredentialSecrets.targetUserId, targetUserId),
),
)
.limit(1);
return rows[0] ?? null;
}
async deleteForRoleMember(
roleId: number,
targetUserId: string,
): Promise<void> {
const grants = await this.context.drizzle
.select({ id: credentialAccess.id })
.from(credentialAccess)
.where(eq(credentialAccess.roleId, roleId));
if (grants.length === 0) return;
await this.context.drizzle.delete(sharedCredentialSecrets).where(
and(
inArray(
sharedCredentialSecrets.credentialAccessId,
grants.map((g) => g.id),
),
eq(sharedCredentialSecrets.targetUserId, targetUserId),
),
);
await this.afterWrite();
}
async deleteByTargetUserId(userId: string): Promise<void> {
await this.context.drizzle
.delete(sharedCredentialSecrets)
.where(eq(sharedCredentialSecrets.targetUserId, userId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -33,33 +33,6 @@ export class SharedHostAuthOverrideRepository {
return rows[0] ?? null;
}
/** Recipient overrides for a host keyed by protocol, in one query. */
async listCredentialIds(
hostId: number,
userId: string,
): Promise<Partial<Record<AuthOverrideProtocol, number>>> {
const rows = await this.context.drizzle
.select({
protocol: sharedHostAuthOverrides.protocol,
credentialId: sharedHostAuthOverrides.credentialId,
})
.from(sharedHostAuthOverrides)
.where(
and(
eq(sharedHostAuthOverrides.hostId, hostId),
eq(sharedHostAuthOverrides.userId, userId),
),
);
const result: Partial<Record<AuthOverrideProtocol, number>> = {};
for (const row of rows) {
if (row.credentialId) {
result[row.protocol as AuthOverrideProtocol] = row.credentialId;
}
}
return result;
}
async findCredentialId(
hostId: number,
userId: string,
@@ -1,4 +1,4 @@
import { and, asc, eq, like, or, sql } from "drizzle-orm";
import { and, asc, eq, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -108,25 +108,6 @@ export class SnippetRepository {
return this.listFolders(userId);
}
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
async listOwnedSnippetsInFolder(
userId: string,
folder: string,
): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
or(
eq(snippets.folder, folder),
like(snippets.folder, `${folder} / %`),
),
),
);
}
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
@@ -7,7 +7,6 @@ import type {
Trigger,
} from "../../../types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import {
@@ -27,7 +26,6 @@ import {
const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
@@ -246,7 +244,6 @@ async function syncSchedule(
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -285,7 +282,6 @@ router.get(
router.get(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -337,7 +333,6 @@ router.get(
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -428,7 +423,6 @@ router.post(
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -537,7 +531,6 @@ router.put(
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -607,7 +600,6 @@ router.delete(
router.post(
"/:id/run",
authenticateJWT,
permissionManager.requirePermission("automations.run"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -681,7 +673,6 @@ router.post(
router.get(
"/runs/history",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -724,7 +715,6 @@ router.get(
router.get(
"/runs/:runId/steps",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -6,8 +6,6 @@ import { createCurrentCredentialRepository } from "../repositories/factory.js";
export function registerCredentialBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -44,8 +42,6 @@ export function registerCredentialBulkRoutes(
router.put(
"/reorder",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
@@ -359,9 +359,6 @@ async function deploySSHKeyToHost(
export function registerCredentialDeployRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireHostEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -401,9 +398,6 @@ export function registerCredentialDeployRoutes(
router.post(
"/:id/deploy-to-host",
authenticateJWT,
requireCredentialViewPermission,
requireHostEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const id = Array.isArray(req.params.id)
? req.params.id[0]
@@ -63,8 +63,6 @@ function generateSSHKeyPair(
export function registerCredentialKeyRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCredentialViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -96,8 +94,6 @@ export function registerCredentialKeyRoutes(
router.post(
"/detect-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
@@ -154,8 +150,6 @@ export function registerCredentialKeyRoutes(
router.post(
"/detect-public-key-type",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { publicKey } = req.body;
@@ -215,8 +209,6 @@ export function registerCredentialKeyRoutes(
router.post(
"/validate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, publicKey, keyPassword } = req.body;
@@ -282,8 +274,6 @@ export function registerCredentialKeyRoutes(
router.post(
"/generate-key-pair",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
@@ -349,8 +339,6 @@ export function registerCredentialKeyRoutes(
router.post(
"/generate-public-key",
authenticateJWT,
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
+13 -136
View File
@@ -2,7 +2,6 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
@@ -15,9 +14,6 @@ import {
} from "../../utils/audit-logger.js";
import {
createCurrentCredentialRepository,
createCurrentUserRepository,
createCurrentCredentialAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentSyncTombstoneRepository,
@@ -30,7 +26,6 @@ function isNonEmptyString(val: unknown): val is string {
}
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
@@ -83,7 +78,6 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -256,7 +250,6 @@ router.post(
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -269,11 +262,8 @@ router.get(
try {
const credentials =
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
const own = credentials.map((cred) => formatCredentialOutput(cred));
// Credentials shared with this user, read from their own snapshots.
const shared = await listSharedCredentialsForUser(userId);
res.json([...own, ...shared]);
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
} catch (err) {
authLogger.error("Failed to fetch credentials", err);
res.status(500).json({ error: "Failed to fetch credentials" });
@@ -300,7 +290,6 @@ router.get(
router.get(
"/folders",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -322,12 +311,7 @@ router.get(
// Registered here (before the PUT /:id route below) so the literal
// "/reorder" path segment is matched before Express falls through to the
// PUT /:id param route and treats "reorder" as an id.
registerCredentialBulkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
);
registerCredentialBulkRoutes(router, authenticateJWT);
/**
* @openapi
@@ -356,7 +340,6 @@ registerCredentialBulkRoutes(
router.get(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -368,14 +351,11 @@ router.get(
}
try {
const credentialRepository = createCurrentCredentialRepository();
const ownCredential = await credentialRepository.findDecryptedByIdForUser(
const credential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
parseInt(id),
);
const credential =
ownCredential ??
(await findSharedCredentialForUser(parseInt(id), userId));
if (!credential) {
return res.status(404).json({ error: "Credential not found" });
@@ -452,7 +432,6 @@ router.get(
router.post(
"/:id/duplicate",
authenticateJWT,
permissionManager.requirePermission("credentials.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -606,7 +585,6 @@ router.post(
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -626,21 +604,13 @@ router.put(
});
try {
// A recipient holding "manage" edits the owner's row on the owner's
// behalf; the row stays encrypted under the owner's key and every
// recipient's snapshot is rebuilt below.
const editableOwnerId = await resolveEditableCredentialOwner(
credentialId,
const existingCredential =
await createCurrentCredentialRepository().findDecryptedByIdForUser(
userId,
);
const existingCredential = editableOwnerId
? await createCurrentCredentialRepository().findDecryptedByIdForUser(
editableOwnerId,
credentialId,
)
: null;
);
if (!existingCredential || !editableOwnerId) {
if (!existingCredential) {
return res.status(404).json({ error: "Credential not found" });
}
@@ -703,14 +673,14 @@ router.put(
const credentialRepository = createCurrentCredentialRepository();
const updated = await credentialRepository.updateEncryptedForUser(
editableOwnerId,
userId,
credentialId,
updateFields,
);
const updatedCredential =
updated ??
(await credentialRepository.findDecryptedByIdForUser(
editableOwnerId,
userId,
credentialId,
));
@@ -718,13 +688,7 @@ router.put(
await import("../../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHostsForCredential(
credentialId,
editableOwnerId,
);
const { SharedCredentialSecretsManager } =
await import("../../utils/shared-credential-secrets-manager.js");
await SharedCredentialSecretsManager.getInstance().resyncCredential(
credentialId,
editableOwnerId,
userId,
);
authLogger.success("SSH credential updated", {
@@ -783,7 +747,6 @@ router.put(
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("credentials.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -915,7 +878,6 @@ router.delete(
router.post(
"/:id/apply-to-host/:hostId",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const credentialId = Array.isArray(req.params.id)
@@ -996,7 +958,6 @@ router.post(
router.get(
"/:id/hosts",
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const credentialId = Array.isArray(req.params.id)
@@ -1025,78 +986,6 @@ router.get(
},
);
/** The owner id if the caller may edit this credential (owner or "manage"), else null. */
async function resolveEditableCredentialOwner(
credentialId: number,
userId: string,
): Promise<string | null> {
const row = await createCurrentCredentialRepository().findById(credentialId);
if (!row) return null;
if (row.userId === userId) return userId;
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grant = await createCurrentCredentialAccessRepository().findActiveGrant(
credentialId,
userId,
roleIds,
);
return grant?.permissionLevel === "manage" ? row.userId : null;
}
async function findSharedCredentialForUser(
credentialId: number,
userId: string,
): Promise<Record<string, unknown> | null> {
const shared = (await listSharedCredentialsForUser(userId)).find(
(cred) => cred.id === credentialId,
);
return shared ?? null;
}
/** Shared credentials shaped like the caller's own, plus who shared them. */
async function listSharedCredentialsForUser(
userId: string,
): Promise<Record<string, unknown>[]> {
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const grants =
await createCurrentCredentialAccessRepository().listSharedWithUser(
userId,
roleIds,
);
if (grants.length === 0) return [];
const credentialRepository = createCurrentCredentialRepository();
const userRepository = createCurrentUserRepository();
const { findUsableCredential } =
await import("../../hosts/usable-credential.js");
const results: Record<string, unknown>[] = [];
for (const grant of grants) {
const row = await credentialRepository.findById(grant.credentialId);
if (!row) continue;
let secrets: Record<string, unknown> | null = null;
try {
secrets = (await findUsableCredential(
grant.credentialId,
userId,
)) as Record<string, unknown> | null;
} catch {
secrets = null;
}
const owner = await userRepository.findById(grant.ownerId);
results.push({
...formatCredentialOutput({
...row,
username: secrets?.username ?? row.username,
publicKey: secrets?.publicKey ?? null,
certPublicKey: secrets?.certPublicKey ?? null,
}),
isShared: true,
ownerUsername: owner?.username ?? null,
permissionLevel: grant.permissionLevel,
sharedExpiresAt: grant.expiresAt,
});
}
return results;
}
function formatCredentialOutput(
credential: Record<string, unknown>,
): Record<string, unknown> {
@@ -1187,7 +1076,6 @@ function formatSSHHostOutput(
router.put(
"/folders/rename",
authenticateJWT,
permissionManager.requirePermission("credentials.edit"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
@@ -1219,19 +1107,8 @@ router.put(
},
);
registerCredentialKeyRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
requireDataAccess,
);
registerCredentialKeyRoutes(router, authenticateJWT);
registerCredentialDeployRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("credentials.view"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
registerCredentialDeployRoutes(router, authenticateJWT);
export default router;
@@ -39,34 +39,14 @@ import {
createCurrentUserRepository,
createCurrentTransferRecentRepository,
createCurrentVaultProfileRepository,
createCurrentSecretSourceRepository,
createCurrentSharedCredentialSecretsRepository,
createCurrentCredentialAccessRepository,
createCurrentVaultTokenRepository,
} from "../repositories/factory.js";
export async function deleteUserAndRelatedData(
userId: string,
options: { successorUserId?: string } = {},
): Promise<void> {
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
try {
// With a successor, hosts and credentials (and the shares on them)
// change owner instead of disappearing with the account.
if (options.successorUserId) {
const { transferOwnership } =
await import("../../utils/transfer-ownership.js");
await transferOwnership(userId, options.successorUserId);
}
await createCurrentSharedHostSecretsRepository().deleteByTargetUserId(
userId,
);
await createCurrentSharedCredentialSecretsRepository().deleteByTargetUserId(
userId,
);
await createCurrentCredentialAccessRepository().deleteForUserReferences(
userId,
);
// Retained rather than deleted: these outlive the account by design.
// See anonymizeByUserId on each repository.
@@ -118,7 +98,6 @@ export async function deleteUserAndRelatedData(
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
await createCurrentVaultTokenRepository().deleteByUserId(userId);
await createCurrentVaultProfileRepository().deleteByUserId(userId);
await createCurrentSecretSourceRepository().deleteByUserId(userId);
await createCurrentTermixIdentityCaRepository().deleteByUserId(userId);
await createCurrentTermixIdentityRepository().deleteByUserId(userId);
await createCurrentTmuxSessionTagRepository().deleteByUserId(userId);
@@ -33,11 +33,6 @@ export function extractBearerOrCookieToken(req: Request): string | undefined {
return undefined;
}
export function isNativeTokenExportRequest(req: Request): boolean {
const userAgent = req.get("user-agent") || "";
return /^(Termix-Mobile|Termix-Desktop)\//.test(userAgent);
}
/**
* Decides who the desktop auto-session endpoint should silently log in as.
*
@@ -12,7 +12,6 @@ const faviconCache = new Map<
>();
const CACHE_SIZE = 100;
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
const MAX_FAVICON_BYTES = 512 * 1024;
function evictIfNeeded() {
if (faviconCache.size >= CACHE_SIZE) {
@@ -26,31 +25,14 @@ function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, { timeout: 5000 }, (res) => {
const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
res.destroy();
reject(error);
};
res.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_FAVICON_BYTES) {
fail(new Error("Favicon response too large"));
return;
}
chunks.push(chunk);
});
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
if (settled) return;
settled = true;
resolve({
data: Buffer.concat(chunks),
contentType: res.headers["content-type"] || "image/x-icon",
});
});
res.on("error", fail);
res.on("error", reject);
});
req.on("error", reject);
req.on("timeout", () => {
@@ -113,7 +95,7 @@ homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.send(data);
} catch {
} catch (err) {
homepageLogger.warn("Failed to fetch favicon", { domain });
res.status(500).json({ error: "Failed to fetch favicon" });
}
@@ -1,10 +1,11 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Request, type Response } from "express";
import https from "https";
import http from "http";
import { lookup } from "dns/promises";
import { isIP } from "net";
import { homepageLogger } from "../../utils/logger.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js";
export const homepageProxyRouter = express.Router();
@@ -16,20 +17,68 @@ interface ProxyCacheEntry {
const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
async function resolvePublicUrl(rawUrl: string): Promise<{
url: URL;
address: string;
}> {
const url = new URL(rawUrl);
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password
) {
throw new Error("Invalid URL");
}
const hostname = url.hostname.replace(/^\[|\]$/g, "");
const addresses = isIP(hostname)
? [{ address: hostname }]
: await lookup(hostname, { all: true, verbatim: true });
if (
addresses.length === 0 ||
addresses.some(({ address }) => isBlockedAddress(address))
) {
throw new Error("Private destinations are not allowed");
}
return { url, address: addresses[0].address };
}
async function fetchJson(rawUrl: string): Promise<unknown> {
const response = await safeOutboundFetch(rawUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Proxy fetch failed: ${response.status}`);
const text = await readResponseTextLimited(response, MAX_RESPONSE_BYTES);
const { url, address } = await resolvePublicUrl(rawUrl);
return new Promise((resolve, reject) => {
const mod = url.protocol === "https:" ? https : http;
const req = mod.get(
{
protocol: url.protocol,
hostname: address,
port: url.port || undefined,
path: `${url.pathname}${url.search}`,
headers: { Host: url.host },
servername: url.protocol === "https:" ? url.hostname : undefined,
timeout: FETCH_TIMEOUT_MS,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
try {
return JSON.parse(text);
const text = Buffer.concat(chunks).toString("utf-8");
resolve(JSON.parse(text));
} catch {
throw new Error("Response is not valid JSON");
reject(new Error("Response is not valid JSON"));
}
});
res.on("error", reject);
},
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Fetch timeout"));
});
});
}
/**
@@ -60,9 +109,7 @@ async function fetchJson(rawUrl: string): Promise<unknown> {
*/
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
const targetUrl = req.query.url as string;
const ttl =
Math.min(MAX_CACHE_TTL_SECONDS, Math.max(10, Number(req.query.ttl) || 60)) *
1000;
const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000;
if (!targetUrl) return res.status(400).json({ error: "url is required" });
try {
@@ -1,9 +1,6 @@
import express, { type Request, type Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
import {
readResponseTextLimited,
safeOutboundFetch,
} from "../../utils/safe-outbound-fetch.js";
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
export const homepageRssRouter = express.Router();
@@ -11,7 +8,6 @@ const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
interface RssItem {
title: string;
@@ -25,7 +21,7 @@ function fetchXml(url: string): Promise<string> {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}).then(async (res) => {
if (!res.ok) throw new Error(`RSS fetch failed: ${res.status}`);
return readResponseTextLimited(res, MAX_RESPONSE_BYTES);
return res.text();
});
}
@@ -135,7 +131,7 @@ homepageRssRouter.get("/", async (req: Request, res: Response) => {
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
res.json(items.slice(0, max));
} catch {
} catch (err) {
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
res.status(500).json({ error: "Failed to fetch feed" });
}
@@ -7,19 +7,12 @@ import { createCurrentHostRepository } from "../repositories/factory.js";
type HostAutostartRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
};
export function registerHostAutostartRoutes(
router: Router,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDataAccess,
}: HostAutostartRoutesDeps,
{ authenticateJWT, requireDataAccess }: HostAutostartRoutesDeps,
): void {
/**
* @openapi
@@ -51,7 +44,6 @@ export function registerHostAutostartRoutes(
router.post(
"/autostart/enable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -212,8 +204,6 @@ export function registerHostAutostartRoutes(
router.delete(
"/autostart/disable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
@@ -269,8 +259,6 @@ export function registerHostAutostartRoutes(
router.get(
"/autostart/status",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -105,22 +105,9 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
return results;
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
username: unknown,
): string | null {
if (isNonEmptyString(username)) return username;
if (connectionType !== "ssh" || authType === "credential") return "";
return null;
}
export function registerHostBulkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireCreatePermission: RequestHandler,
requireEditPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -185,8 +172,6 @@ export function registerHostBulkRoutes(
router.patch(
"/bulk-update",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostIds, updates } = req.body;
@@ -391,8 +376,6 @@ export function registerHostBulkRoutes(
router.put(
"/reorder",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
@@ -439,9 +422,6 @@ export function registerHostBulkRoutes(
router.post(
"/bulk-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
@@ -578,12 +558,10 @@ export function registerHostBulkRoutes(
continue;
}
const username = importedHostUsername(
effectiveConnectionType,
hostData.authType,
hostData.username,
);
if (username === null) {
if (
effectiveConnectionType === "ssh" &&
!isNonEmptyString(hostData.username)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Username required for SSH connections`,
@@ -600,14 +578,13 @@ export function registerHostBulkRoutes(
"credential",
"none",
"opkssh",
"stepca",
"tailscale",
"vault",
].includes(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`,
);
continue;
}
@@ -683,12 +660,12 @@ export function registerHostBulkRoutes(
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
name: hostData.name || `${username}@${hostData.ip}`,
name: hostData.name || `${hostData.username || ""}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
username,
username: hostData.username || null,
pin: hostData.pin || false,
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
@@ -855,9 +832,6 @@ export function registerHostBulkRoutes(
router.post(
"/ssh-config-import",
authenticateJWT,
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { content, overwrite } = req.body;
@@ -7,8 +7,6 @@ import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostCommandHistoryRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -35,8 +33,6 @@ export function registerHostCommandHistoryRoutes(
router.get(
"/command-history/:hostId",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdParam = Array.isArray(req.params.hostId)
@@ -102,8 +98,6 @@ export function registerHostCommandHistoryRoutes(
router.delete(
"/command-history",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, command } = req.body;
@@ -7,8 +7,6 @@ import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostFileManagerBookmarkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
@@ -35,8 +33,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.get(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
@@ -102,8 +98,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.post(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
@@ -161,8 +155,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.delete(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
@@ -211,8 +203,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.get(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
@@ -279,8 +269,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.post(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
@@ -339,8 +327,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.delete(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
@@ -389,8 +375,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.get(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
@@ -457,8 +441,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.post(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
@@ -517,8 +499,6 @@ export function registerHostFileManagerBookmarkRoutes(
router.delete(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
@@ -6,7 +6,6 @@ import {
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
@@ -18,25 +17,12 @@ import { isNonEmptyString } from "./host-normalizers.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDeletePermission: RequestHandler;
requireCredentialEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
statsServerUrl: string;
};
export function registerHostFolderRoutes(
router: Router,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDeletePermission,
requireCredentialEditPermission,
requireDataAccess,
statsServerUrl,
}: HostFolderRoutesDeps,
{ authenticateJWT, statsServerUrl }: HostFolderRoutesDeps,
): void {
/**
* @openapi
@@ -68,9 +54,6 @@ export function registerHostFolderRoutes(
router.put(
"/folders/rename",
authenticateJWT,
requireEditPermission,
requireCredentialEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldName, newName } = req.body;
@@ -93,11 +76,6 @@ export function registerHostFolderRoutes(
oldName,
newName,
);
await createCurrentFolderAccessRepository().renameFolder(
userId,
oldName,
newName,
);
res.json({
message: "Folder renamed successfully",
@@ -135,8 +113,6 @@ export function registerHostFolderRoutes(
router.get(
"/folders",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -194,8 +170,6 @@ export function registerHostFolderRoutes(
router.put(
"/folders/metadata",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, color, icon, credentialId } = req.body;
@@ -301,8 +275,6 @@ export function registerHostFolderRoutes(
router.put(
"/folders/reorder",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { positions } = req.body as {
@@ -374,8 +346,6 @@ export function registerHostFolderRoutes(
router.delete(
"/folders/:name/hosts",
authenticateJWT,
requireDeletePermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = Array.isArray(req.params.name)
@@ -7,17 +7,12 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j
interface HostNetworkRoutesDeps {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireDataAccess: RequestHandler;
}
export function registerHostNetworkRoutes(
router: Router,
{
authenticateJWT,
requireViewPermission,
requireDataAccess,
}: HostNetworkRoutesDeps,
{ authenticateJWT, requireDataAccess }: HostNetworkRoutesDeps,
): void {
/**
* @openapi
@@ -67,7 +62,6 @@ export function registerHostNetworkRoutes(
router.post(
"/db/proxy/test",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
try {
@@ -99,7 +93,6 @@ export function registerHostNetworkRoutes(
router.post(
"/db/host/:id/wake",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Number.parseInt(String(req.params.id), 10);
@@ -14,25 +14,6 @@ export function isOptionalBoolean(
return value === undefined || typeof value === "boolean";
}
const PROTOCOL_ENABLE_FIELDS = [
"enableSsh",
"enableRdp",
"enableVnc",
"enableTelnet",
] as const;
export function normalizeProtocolEnableFields(
values: Record<string, unknown>,
): Partial<Record<(typeof PROTOCOL_ENABLE_FIELDS)[number], 0 | 1>> {
return Object.fromEntries(
PROTOCOL_ENABLE_FIELDS.flatMap((field) =>
typeof values[field] === "boolean"
? [[field, values[field] ? 1 : 0]]
: [],
),
);
}
export const OWNER_PRIVATE_AUTH_FIELDS = {
ssh: [
"authType",
@@ -365,7 +346,6 @@ const CONNECT_LEVEL_FIELDS = new Set([
"enableTelnet",
"sshPort",
"rdpPort",
"rdpAuthType",
"vncPort",
"telnetPort",
"defaultPath",
@@ -1,34 +0,0 @@
import type { Request, Response, Router } from "express";
import { escapeHtml } from "./opkssh-html.js";
function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Signed in" : "Sign-in failed"}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
}
/**
* The OIDC redirect target for Step CA sign-ins. Unauthenticated on purpose:
* the browser that finishes the sign-in may not be the one running Termix,
* so the request is matched to its session by the OAuth state.
*/
export function registerHostStepCaRoutes(router: Router): void {
router.get("/step-ca-callback", async (req: Request, res: Response) => {
const { completeStepCaAuth } = await import("../../hosts/step-ca-auth.js");
const stringQuery = (name: string): string | undefined => {
const value = req.query[name];
return typeof value === "string" ? value : undefined;
};
const result = await completeStepCaAuth({
state: stringQuery("state"),
code: stringQuery("code"),
error: stringQuery("error"),
error_description: stringQuery("error_description"),
});
res
.status(result.ok ? 200 : 400)
.type("html")
.send(resultPage(result.ok, result.message));
});
}
+24 -265
View File
@@ -1,6 +1,4 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import axios from "axios";
@@ -30,14 +28,12 @@ import {
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../repositories/factory.js";
import {
containsOwnerPrivateAuthUpdate,
isNonEmptyString,
isOptionalBoolean,
isValidPort,
normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
sanitizeHostForRecipient,
@@ -46,7 +42,6 @@ import {
} from "./host-normalizers.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
@@ -67,7 +62,6 @@ import type {
HostResolutionCredentialRecord,
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
@@ -131,7 +125,6 @@ registerHostInternalRoutes(router);
router.post(
["/db/host", "/enroll"],
authenticateJWT,
permissionManager.requirePermission("hosts.create"),
requireDataAccess,
requireHostEnrollmentAccessForPath,
upload.single("key"),
@@ -268,8 +261,7 @@ router.post(
!isNonEmptyString(userId) ||
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
![enableSsh, enableRdp, enableVnc, enableTelnet].every(isOptionalBoolean)
!isOptionalBoolean(shareSshAuth)
) {
sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create",
@@ -400,7 +392,10 @@ router.post(
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
enableSsh: enableSsh ? 1 : 0,
enableRdp: enableRdp ? 1 : 0,
enableVnc: enableVnc ? 1 : 0,
enableTelnet: enableTelnet ? 1 : 0,
sshPort: sshPort || port || 22,
rdpPort: rdpPort || 3389,
vncPort: vncPort || 5900,
@@ -503,20 +498,6 @@ router.post(
}
const createdHost = result;
// Standing folder shares apply to the newcomer.
try {
await applyFolderAccessRules(
createdHost.id,
userId!,
createdHost.folder,
);
} catch (folderAccessError) {
sshLogger.warn("Failed to inherit folder access on host create", {
operation: "host_create_folder_access",
hostId: createdHost.id,
error: getErrorMessage(folderAccessError),
});
}
const baseHost = transformHostResponse(createdHost);
const resolvedHost =
@@ -731,7 +712,11 @@ router.post(
let resolvedUsername = username;
if (authType === "credential" && credentialId) {
const cred = await findUsableCredential(Number(credentialId), userId);
const cred =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
Number(credentialId),
userId,
);
if (!cred) {
return res.status(404).json({ error: "Credential not found" });
@@ -832,7 +817,6 @@ router.post(
router.put(
"/db/host/:id",
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
upload.single("key"),
async (req: Request, res: Response) => {
@@ -970,9 +954,6 @@ router.put(
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
![enableSsh, enableRdp, enableVnc, enableTelnet].every(
isOptionalBoolean,
) ||
!hostId
) {
sshLogger.warn("Invalid SSH data input validation failed for update", {
@@ -1104,7 +1085,10 @@ router.put(
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
enableSsh: enableSsh ? 1 : 0,
enableRdp: enableRdp ? 1 : 0,
enableVnc: enableVnc ? 1 : 0,
enableTelnet: enableTelnet ? 1 : 0,
sshPort: sshPort || port || 22,
rdpPort: rdpPort || 3389,
vncPort: vncPort || 5900,
@@ -1386,21 +1370,6 @@ router.put(
sshDataObj,
);
// A host that moved into a folder inherits that folder's standing shares.
try {
await applyFolderAccessRules(
Number(hostId),
ownerId,
sshDataObj.folder as string | null | undefined,
);
} catch (folderAccessError) {
sshLogger.warn("Failed to inherit folder access on host update", {
operation: "host_update_folder_access",
hostId: parseInt(hostId),
error: getErrorMessage(folderAccessError),
});
}
// Keep every recipient's re-encrypted secret snapshots in sync with
// the updated host record.
try {
@@ -1486,94 +1455,9 @@ router.put(
* 500:
* description: Failed to fetch SSH data.
*/
/**
* @openapi
* /host/db/host/{id}/terminal-config:
* patch:
* summary: Update a host's terminal behaviour flags
* description: Merges the given flags into the host's terminalConfig. Owner or a recipient with edit access. Currently supports autoTmux; used by the "enable Auto-Tmux" action shown when a persisted session expires.
* tags:
* - Hosts
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* autoTmux:
* type: boolean
* responses:
* 200:
* description: Updated.
* 403:
* description: No edit access.
* 404:
* description: Host not found.
*/
router.patch(
"/db/host/:id/terminal-config",
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = parseInt(String(req.params.id), 10);
const { autoTmux } = req.body ?? {};
if (isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
if (typeof autoTmux !== "boolean") {
return res.status(400).json({ error: "autoTmux must be a boolean" });
}
try {
const access = await permissionManager.canAccessHost(
userId,
hostId,
"edit",
);
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied to host" });
}
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
const host = ownerId
? await createCurrentHostRepository().findByIdForUser(ownerId, hostId)
: null;
if (!host || !ownerId) {
return res.status(404).json({ error: "Host not found" });
}
let terminalConfig: Record<string, unknown> = {};
if (host.terminalConfig) {
try {
terminalConfig = JSON.parse(host.terminalConfig);
} catch {
terminalConfig = {};
}
}
await createCurrentHostRepository().updateForUser(ownerId, hostId, {
terminalConfig: JSON.stringify({ ...terminalConfig, autoTmux }),
});
res.json({ success: true, autoTmux });
} catch (error) {
sshLogger.error("Failed to update host terminal config", error, {
operation: "host_terminal_config_update",
hostId,
userId,
});
res.status(500).json({ error: "Failed to update terminal config" });
}
},
);
router.get(
"/db/host",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -1717,7 +1601,6 @@ router.get(
router.get(
"/db/host/:id",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Array.isArray(req.params.id)
@@ -1812,78 +1695,6 @@ router.get(
},
);
/**
* Returns the minimum authentication material needed by the desktop app to
* connect to a shared host from the recipient's own network. The response is
* deliberately transient: callers must not persist it or include it in logs.
*/
router.get(
"/db/host/:id/local-connection-auth",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId) || !Number.isInteger(hostId) || hostId <= 0) {
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
const access = await permissionManager.canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess || !access.isShared) {
return res.status(404).json({ error: "Shared host not found" });
}
const repository = createCurrentHostResolutionRepository();
const ownerId = await repository.findHostOwnerId(hostId);
const host = ownerId
? await repository.findHostById(hostId, ownerId)
: null;
if (!host) {
return res.status(404).json({ error: "Shared host not found" });
}
const resolved = await resolveHostCredentials(
{
...transformHostResponse(host),
isShared: true,
permissionLevel: access.permissionLevel,
},
userId,
);
res.setHeader("Cache-Control", "no-store");
return res.json({
username: resolved.username,
authType: resolved.authType,
password: resolved.password || null,
key: resolved.key || null,
keyPassword: resolved.keyPassword || null,
keyType: resolved.keyType || null,
});
} catch (error) {
sshLogger.error(
"Failed to resolve shared host local authentication",
error,
{
operation: "shared_host_local_auth_resolve",
hostId,
userId,
},
);
return res
.status(500)
.json({ error: "Failed to resolve shared host authentication" });
}
},
);
/**
* @openapi
* /host/db/host/{id}/password:
@@ -2412,7 +2223,6 @@ router.get(
router.delete(
"/db/host/:id",
authenticateJWT,
permissionManager.requirePermission("hosts.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -2544,18 +2354,11 @@ router.delete(
},
);
registerHostFileManagerBookmarkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
registerHostFileManagerBookmarkRoutes(router, authenticateJWT);
router.get(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
@@ -2592,8 +2395,6 @@ router.get(
router.post(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
@@ -2625,12 +2426,7 @@ router.post(
}
},
);
registerHostCommandHistoryRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
registerHostCommandHistoryRoutes(router, authenticateJWT);
async function resolveHostCredentials(
host: Record<string, unknown>,
@@ -2659,24 +2455,6 @@ async function resolveHostCredentials(
required: needsPersonalCredential,
ownerAuthShared: !!host.shareSshAuth,
};
// Owner auth for the remote desktop protocols is always snapshotted
// for recipients; only their own override credential varies per user.
const authOverrides: Record<string, unknown> = {
ssh: baseSshOverrideState,
};
const overrideCredentialIds =
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
host.id,
requestingUserId,
);
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
if (!host[AUTH_PROTOCOL_METADATA[protocol].enableField]) continue;
authOverrides[protocol] = {
credentialId: overrideCredentialIds[protocol],
required: false,
ownerAuthShared: true,
};
}
const recipientHost: Record<string, unknown> = {
...host,
credentialId: null,
@@ -2684,7 +2462,9 @@ async function resolveHostCredentials(
key: null,
keyPassword: null,
keyType: null,
authOverrides,
authOverrides: {
ssh: baseSshOverrideState,
},
};
try {
@@ -2700,7 +2480,6 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
@@ -2726,7 +2505,6 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
@@ -2746,7 +2524,6 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: true,
@@ -2770,7 +2547,6 @@ async function resolveHostCredentials(
return {
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
required: false,
ownerAuthShared: !!host.shareSshAuth,
@@ -2791,7 +2567,10 @@ async function resolveHostCredentials(
const credential =
preloadedCredentials?.get(credentialId) ??
(await findUsableCredential(credentialId, credentialOwnerId));
(await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
credentialOwnerId,
));
if (credential) {
const resolvedHost: Record<string, unknown> = {
@@ -2826,27 +2605,13 @@ async function resolveHostCredentials(
registerHostFolderRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDeletePermission: permissionManager.requirePermission("hosts.delete"),
requireCredentialEditPermission:
permissionManager.requirePermission("credentials.edit"),
requireDataAccess,
statsServerUrl: STATS_SERVER_URL,
});
registerHostBulkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.create"),
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
);
registerHostBulkRoutes(router, authenticateJWT);
registerHostAutostartRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
});
@@ -2891,8 +2656,6 @@ registerHostAutostartRoutes(router, {
router.get(
"/ssh/opkssh/token/:hostId",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId;
@@ -2964,8 +2727,6 @@ router.get(
router.delete(
"/ssh/opkssh/token/:hostId",
authenticateJWT,
permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
requireDataAccess,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId;
@@ -2995,11 +2756,9 @@ router.delete(
);
registerHostOpksshRoutes(router);
registerHostStepCaRoutes(router);
registerHostNetworkRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireDataAccess,
});
@@ -4,9 +4,6 @@ const VALID_ACTION_TYPES = [
"sendControlCode",
"sendText",
"runSnippet",
"nextTab",
"previousTab",
"openCommandPalette",
];
export function isValidKeyCombo(combo: unknown): boolean {

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