Compare commits

...
27 Commits
Author SHA1 Message Date
LukeGus 931761190f fix: plugin validate accepts beta versions 2026-10-09 14:37:54 -05:00
LukeGus 28fe98e787 feat: note in the plugins tab that community plugins are coming 2026-10-09 14:16:29 -05:00
LukeGus 2ba11652eb fix: sso only accounts cannot sign in with a passkey or other local method 2026-10-09 11:14:56 -05:00
LukeGus 2e3c242e34 chore: default mobile download links to v tags 2026-10-09 10:33:32 -05:00
LukeGus 4c68d35c21 fix: linked desktops keep a plugin when its update from the server fails 2026-10-09 10:07:10 -05:00
LukeGus 0ce378ec55 feat: plugin text columns are longtext on mysql so they are not capped at 64KB 2026-10-09 09:39:08 -05:00
LukeGus 83cce344e8 fix: link server wizard always opens inline, onboarding card grows for it 2026-10-09 09:29:27 -05:00
LukeGus 6f4198cc83 docs: note the removed donation reminder 2026-10-09 08:06:37 -05:00
LukeGus abdbb34ce2 feat: remove the donation reminder and its column 2026-10-09 08:06:22 -05:00
LukeGus 0b41672a3c chore: stop pushing the release- docker tag 2026-10-09 08:06:19 -05:00
LukeGus 0f5c2ac862 fix: current version in the version route example 2026-10-09 07:38:28 -05:00
LukeGus 6f7fc05892 docs: 26.10.0 changelog 2026-10-09 07:37:59 -05:00
LukeGus 4eac6d977d fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms 2026-10-09 07:37:56 -05:00
LukeGus 406766eddd fix: load plugin strings on guest pages, plugin wording and drop unused keys 2026-10-09 07:37:51 -05:00
LukeGus f803bfc140 fix: bundled plugins path for source runs and backend cleanup 2026-10-09 07:37:46 -05:00
LukeGus b0a0ee3e00 fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks 2026-10-09 07:37:42 -05:00
LukeGus af09aa1028 fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24 2026-10-09 07:37:38 -05:00
LukeGus 45908b5df3 chore: merge dev-2.9.2 into dev-26.10.0 2026-10-08 22:56:41 -05:00
LukeGus b156829ef3 feat: upload banner 2026-10-08 21:58:19 -05:00
LukeGus 399835d8bc fix: recent activity rows use the same separator as host status 2026-10-08 18:15:44 -05:00
LukeGus e1d7b3e7c6 chore: repin bundled plugins after the 1.0.0 re-release 2026-10-08 17:53:50 -05:00
LukeGus 970aae4740 fix: repin remote-desktop after its 1.0.0 re-release 2026-10-08 17:41:51 -05:00
LukeGus 4fd9aebee4 fix: use TERMIX_PAT in workflows 2026-10-05 20:14:22 -05:00
LukeGus acc9484280 fix: restore release video link in 2.9.2 notes 2026-10-05 20:12:12 -05:00
LukeGus 7508a45f36 fix: stop full database rewrites on every plugin db call 2026-10-05 20:06:32 -05:00
LukeGus 40df00bd95 chore: point cli install to @termix-ssh/cli 2026-10-05 18:14:46 -05:00
LukeGus 6c342f3957 chore: sync Crowdin translations 2026-10-05 06:28:23 +00:00
167 changed files with 15686 additions and 1568 deletions

No files matched your search

+21 -44
View File
@@ -5,13 +5,22 @@ yarn-error.log*
dist dist
build build
.next coverage
.nuxt release
.env.local # Local data, secrets and editor state
.env.development.local db
.env.test.local old_db
.env.production.local ssl
uploads
plugins
.env
.env.*
.mcp.json
.claude
CLAUDE.md
termix-plugin-signing.key
*.tmxplug.sig
.vscode .vscode
.idea .idea
@@ -29,63 +38,31 @@ Thumbs.db
.git .git
.gitignore .gitignore
.husky
README.md README.md
CONTRIBUTING.md CONTRIBUTING.md
CHANGELOG.md
LICENSE LICENSE
uploads/
electron/ electron/
electron-builder.json electron-builder.json
Casks
packaging
*.log *.log
*.tmp *.tmp
*.temp *.temp
logs logs
*.log
pids pids
*.pid *.pid
*.seed *.seed
*.pid.lock *.pid.lock
coverage
.nyc_output .nyc_output
jspm_packages/
.npm .npm
.eslintcache .eslintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
*.tgz
.yarn-integrity
.cache .cache
.parcel-cache .parcel-cache
*.tgz
.next .node_repl_history
.nuxt
.vuepress/dist
.serverless
.fusebox/
.dynamodb/
.tern-port
+13 -6
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: "Version to build (e.g., 1.8.0)" description: "Version to build (e.g., 26.10.0)"
required: true required: true
build_type: build_type:
description: "Build type" description: "Build type"
@@ -22,11 +22,11 @@ on:
workflow_call: workflow_call:
inputs: inputs:
version: version:
description: "Version to build (e.g., 1.8.0)" description: "Version to build (e.g., 26.10.0)"
required: true required: true
type: string type: string
build_type: build_type:
description: "Build type (Development or Production)" description: "Build type (Development, Beta or Production)"
required: true required: true
type: string type: string
dry_run: dry_run:
@@ -52,7 +52,14 @@ jobs:
fetch-depth: 1 fetch-depth: 1
- name: Resolve source revision - name: Resolve source revision
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" run: |
echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
echo "BUILD_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
# The image reads its version from package.json, so a beta has to say so.
- name: Stamp beta version
if: ${{ inputs.build_type == 'Beta' }}
run: node scripts/sync-version.cjs --version "${{ inputs.version }}"
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v4
@@ -74,7 +81,7 @@ jobs:
ALL_TAGS=() ALL_TAGS=()
if [ "$BUILD_TYPE" = "Production" ]; then if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("release-$VERSION" "$VERSION" "latest") TAGS+=("$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta" "$VERSION") TAGS+=("beta" "$VERSION")
else else
@@ -120,7 +127,7 @@ jobs:
labels: | labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }} org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
org.opencontainers.image.created=${{ github.run_id }} org.opencontainers.image.created=${{ env.BUILD_CREATED }}
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }} outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
- name: Login to Docker Hub as termixssh - name: Login to Docker Hub as termixssh
+23 -7
View File
@@ -117,6 +117,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
- name: Get version - name: Get version
id: package-version id: package-version
run: | run: |
@@ -220,6 +225,11 @@ jobs:
npm install --force @rollup/rollup-linux-arm64-gnu npm install --force @rollup/rollup-linux-arm64-gnu
npm install --force @rollup/rollup-linux-arm-gnueabihf npm install --force @rollup/rollup-linux-arm-gnueabihf
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
- name: Build Linux x64 - name: Build Linux x64
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -429,6 +439,11 @@ jobs:
npm install dmg-license npm install dmg-license
node scripts/install-macos-sharp.cjs node scripts/install-macos-sharp.cjs
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
run: node scripts/sync-version.cjs --version "${{ inputs.version_override }}"
- name: Check for Code Signing Certificates - name: Check for Code Signing Certificates
id: check_certs id: check_certs
run: | run: |
@@ -468,14 +483,15 @@ jobs:
security find-identity -v -p codesigning $KEYCHAIN_PATH security find-identity -v -p codesigning $KEYCHAIN_PATH
# Betas never go to the App Store, so they skip the MAS package.
- name: Build macOS App Store Package - name: Build macOS App Store Package
if: steps.check_certs.outputs.has_certs == 'true' id: mas_build
if: steps.check_certs.outputs.has_certs == 'true' && inputs.version_override == ''
env: env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=4096
run: | run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
BUILD_VERSION="${{ github.run_number }}" BUILD_VERSION="${{ github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
@@ -534,7 +550,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
npm run build npm run build
fi fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
@@ -555,7 +571,7 @@ jobs:
echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT" echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT"
- name: Upload macOS MAS PKG - name: Upload macOS MAS PKG
if: steps.check_certs.outputs.has_certs == 'true' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit') if: steps.mas_build.outcome == 'success' && steps.locate_mas_pkg.outputs.path != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: termix_macos_universal_mas name: termix_macos_universal_mas
@@ -722,7 +738,7 @@ jobs:
choco pack termix-ssh.nuspec choco pack termix-ssh.nuspec
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Chocolatey push failed with exit code $LASTEXITCODE" throw "Chocolatey pack failed with exit code $LASTEXITCODE"
} }
- name: Check for Chocolatey API Key - name: Check for Chocolatey API Key
@@ -952,8 +968,8 @@ jobs:
cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-submission/Casks/t/termix.rb
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-submission/Casks/t/termix.rb
- name: Verify Cask syntax - name: Verify Cask syntax
run: | run: |
+19 -2
View File
@@ -4,14 +4,31 @@
### Added ### Added
- Temp - Plugins. Every feature beyond hosts, credentials and sharing is now its own plugin with its own repo, and you pick which ones you want
- A Plugins tab to install, update, pin, turn off and remove plugins from the official registry, with release notes and a beta channel per plugin
- Developer mode to install a plugin from a file, and a plugin SDK on npm to build your own
- The community plugin registry is open for submissions. Termix can install community plugins in a later update
- Redesigned UI with a Manage tab for hosts and credentials and a Settings tab with search
- New full screen onboarding where you choose which plugins to start with
- Beta channel for Termix itself in Settings > Updates
- Docs links on every settings page, in the Plugins tab, in plugin settings and in the command palette - Docs links on every settings page, in the Plugins tab, in plugin settings and in the command palette
- Plugins can link their own docs and list the environment variables they read, shown on their page in the Plugins tab - Plugins can link their own docs and list the environment variables they read, shown on their page in the Plugins tab
- Report a bug links that open a prefilled issue in the right repo
- `TRUSTED_PROXIES` to choose which proxies nginx takes client IPs from
### Changed ### Changed
- Tem - Termix now uses calendar versions (year, month, patch), starting with 26.10.0
- Redesigned host defaults with admin, user and folder levels
- Simpler host status: online or offline, kept live without a refresh
- Bug reports and feature requests now go to each repo instead of the Support repo
### Fixed
- A lot of bugs across the app, the desktop app and every plugin
- Several security issues reported through GitHub advisories
### Removed ### Removed
- Downgrading to 2.8 is no longer supported. A 2.8 install has to start 2.9 once before upgrading - Downgrading to 2.8 is no longer supported. A 2.8 install has to start 2.9 once before upgrading
- The donation reminder popup
+10 -6
View File
@@ -92,7 +92,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | | [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | | [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Its community registry is open for submissions now, and Termix will be able to install community plugins in a later update. See [submitting a plugin](https://docs.termix.site/develop/community-registry).
<br /> <br />
@@ -109,14 +109,14 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
</tr> </tr>
<tr> <tr>
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td> <td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
<td>Portable · MSI Installer · Chocolatey</td> <td>Portable · EXE and MSI Installer · Chocolatey</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>Linux</b> <sub>x64/ia32</sub></td> <td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
<td>Portable · AUR · AppImage · Deb · Flatpak</td> <td>Portable · AUR · AppImage · Deb · Flatpak</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>macOS</b> <sub>x64/ia32, v12.0+</sub></td> <td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
<td>Apple App Store · DMG · Homebrew</td> <td>Apple App Store · DMG · Homebrew</td>
</tr> </tr>
<tr> <tr>
@@ -149,6 +149,10 @@ services:
- termix-data:/app/data - termix-data:/app/data
environment: environment:
PORT: "8080" PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on: depends_on:
- guacd - guacd
networks: networks:
@@ -158,8 +162,8 @@ services:
image: guacamole/guacd:1.6.0 image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: volumes:
- "4822:4822" - termix-data:/termix-data
networks: networks:
- termix-net - termix-net
+2 -2
View File
@@ -5,7 +5,7 @@
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/index.css", "css": "src/ui/index.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
@@ -15,7 +15,7 @@
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
+4 -4
View File
@@ -1,7 +1,7 @@
# Stage 1: Install dependencies # Stage 1: Install dependencies
# The builders only produce JS, so they run on the build machine's own arch # The builders only produce JS, so they run on the build machine's own arch
# once instead of under QEMU per target. Rolldown hangs under arm64 emulation. # once instead of under QEMU per target. Rolldown hangs under arm64 emulation.
FROM --platform=$BUILDPLATFORM node:26-slim AS deps FROM --platform=$BUILDPLATFORM node:24-slim AS deps
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -37,7 +37,7 @@ COPY . .
RUN npm run build:backend RUN npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline # 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 TARGETARCH
ARG OPKSSH_VERSION=v0.16.0 ARG OPKSSH_VERSION=v0.16.0
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
@@ -58,7 +58,7 @@ RUN case "$TARGETARCH" in \
echo -n "$OPKSSH_VERSION" > version.txt echo -n "$OPKSSH_VERSION" > version.txt
# Stage 5: Production dependencies only # Stage 5: Production dependencies only
FROM node:26-slim AS production-deps FROM node:24-slim AS production-deps
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -81,7 +81,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force npm cache clean --force
# Stage 6: Final optimized image # Stage 6: Final optimized image
FROM node:26-slim FROM node:24-slim
WORKDIR /app WORKDIR /app
# The release the opkssh-downloader stage baked in, so the plugin trusts that # The release the opkssh-downloader stage baked in, so the plugin trusts that
+60 -46
View File
@@ -4,152 +4,162 @@
"id": "acme-ssl", "id": "acme-ssl",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
"sha256": "1725735a058065b211436b166c0ef7d4ca6e74438102764664381160a2b305d3" "sha256": "05b7b4c27897135437af6340c5c31501d2e96ac013dca0672940b74c76085433"
}, },
{ {
"id": "ai", "id": "ai",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
"sha256": "ea121658305bed9d28e046e096fc458976258d7cb77387e1b8ac82ccb9a001cb" "sha256": "ed7a64a4be5978238427d4abbd2153ef2b44c17ebfec78fcb5df579967d01eed"
}, },
{ {
"id": "alerts", "id": "alerts",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5", "sha256": "97e7be293ace897cd45615a8764e8395cf613546b86de82601ab49669dc36591",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "automations", "id": "automations",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
"sha256": "6cb00cf9464e42dbc46786a6ef74b07d62da39ece9905197584d2934265b9b10" "sha256": "0972aef1c14917321c035f7497db81fe95f6b9e47d854b95b1c762444d9cb2a2"
}, },
{ {
"id": "docker", "id": "docker",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d", "sha256": "ca9b8cf573d27a71adb544fd6ff4d19278349f10fdca98f9cc63d7fec63bc441",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "file-manager", "id": "file-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
"sha256": "8d60f6299a38fcc69a17ca30abb0e2a168aa880c0cfb50d57f1dd37158d811a5", "sha256": "d1d92ae051c79c7ce6f05c7dbf33c96de9d8b58083eaccb104d1d21a6bd8b58f",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "fleets", "id": "fleets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
"sha256": "4a774d4a3685b0ee8da5b73d14c60d40f10b6a3f1c5b31a5dfefb94ba30fb7ad" "sha256": "410b19f3a6f47e2a292821b11876b8b5a0bdf779f8d852d5ac04a7501d0f12c0"
}, },
{ {
"id": "homepage", "id": "homepage",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
"sha256": "403513bc1f96a7c21d39d55c8fd1d4e55fde7b8e6ec63228839bf3243d93e68a" "sha256": "5652e378dbb208c7bb349ba2262034077f1cfca95b2d6d029f4186c70ed27999"
}, },
{ {
"id": "host-metrics", "id": "host-metrics",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
"sha256": "1cfc81f724418fd68306e3ac30daa529b03aa065740a866f6fbcaa67edb440ac", "sha256": "ca50e27f06bc7a275bd1bfb1deb5ac97f19023c84567105fe74e075602ec86da",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "ldap", "id": "ldap",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
"sha256": "2d8cb269242c12522a5195fa44ec14e95ade3b00d7e7be12e9204631518a0f88" "sha256": "44d3f9e9be913b9791a084aff588d7d4217dd5cd3f61178006ed4fa2455cad23"
}, },
{ {
"id": "network-topology", "id": "network-topology",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
"sha256": "5c0a585dcffd1cd523e94d45f2eda32800c403a4c832854fec7331b651da7b7d" "sha256": "ba3db2a6bc87533c8273648549c466b95f2c7538700407b2d62e48f813526bac"
}, },
{ {
"id": "opkssh", "id": "opkssh",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
"sha256": "7e8a17aa9053954e937ac2274928e29b821dfd671759e356551b27c13e23c9ad" "sha256": "05884e2244b4cc7f7f0465fe4b775ff95a99183ce6aba60848b68f1f3ec4078f"
}, },
{ {
"id": "proxmox", "id": "proxmox",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
"sha256": "cb6149ab69e348e5a0a1939b31943cb64aded3e1ee930b91dc77c46a715990d0" "sha256": "3a304ba327352093f4bf5dff1a7d6fc981a83180ffc2e06fe1c63e59600e8e3c"
}, },
{ {
"id": "remote-desktop", "id": "remote-desktop",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
"sha256": "5201dc1ce4846ef9f509a4c609698e2b2970e31fbc152bb2fe5c39c72c5c6d39", "sha256": "2dd13c79066c99816f78b3c8aa5f3e31e17d3d2568f2e48575ef1e01f1195c28",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "secret-sources", "id": "secret-sources",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
"sha256": "b6fbe38cd6d62d0b361c102f13f050ee8a281983dc806e32e8d8feeb416ceb20" "sha256": "fdab87ca97ea05c60d7008b22da4f56c4897abf11505b6fa8b5c7bd8bc1a53e3"
}, },
{ {
"id": "serial", "id": "serial",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4", "sha256": "a645144a11e591f9668f2e7b23b8423274006180862be9c5f3e59df361c27e5e",
"onboarding": { "onboarding": {
"recommended": "desktop" "recommended": "desktop",
"consent": false
} }
}, },
{ {
"id": "session-recording", "id": "session-recording",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
"sha256": "ff0e6948806e0127399d41a6abd0f41fce404e65ba2c9d8044fb6dced401f4bb" "sha256": "3a9ab3a3638d0cf13a4c7debbcd315d9a324b9991faea1c192ae1e8fda1e525b"
}, },
{ {
"id": "session-sharing", "id": "session-sharing",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
"sha256": "4d2e363086a8fe79815bf76b4ed359ac82d5926373ab24854028e4dbc5a57ac6" "sha256": "f838e53401f6d75923821797829806b9ca03f0dd64fe87f8524d14a2e05f684e"
}, },
{ {
"id": "snippets", "id": "snippets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5", "sha256": "a434dd442c54fd7a124f4ebc9579a0af1aa4b07b1a90e3875f8ae72c35ced410",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "ssh-terminal", "id": "ssh-terminal",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
"sha256": "66d2a4e87a2b5ac5551c74a4fad5808a6ee81d6e8f0a43c1f6456a12d6232714", "sha256": "64031970a7b6eb1c860aac79c1587a582ccd38b2ffcd5974b51a716240a8474d",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "sso", "id": "sso",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5", "sha256": "2045d1da03dcd47089cb6566a7f25fe021326cb556cbc36b01563a2c234d1230",
"onboarding": { "onboarding": {
"recommended": false,
"consent": false,
"enabledByEnv": ["OIDC_CLIENT_ID"] "enabledByEnv": ["OIDC_CLIENT_ID"]
} }
}, },
@@ -157,19 +167,19 @@
"id": "step-ca", "id": "step-ca",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
"sha256": "997af2068f88c017c76a057683ac5e367566b5b8bc0961fc1e29c32b702a9059" "sha256": "fc38ac6dcf8c43c550b7a7c65836a095ec5a8bae76c8797b5563b2c6c8e8dad1"
}, },
{ {
"id": "tailscale", "id": "tailscale",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
"sha256": "963a84859f8411bcb27e2950d46a7a43b239ace1295ac2abc4a77ecec43806d9" "sha256": "f7732afe584044258cc46205fee327223cdd290e1ba6e6858ddd22be6b3ef12d"
}, },
{ {
"id": "telemetry", "id": "telemetry",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c", "sha256": "103e2ef2d52993bf15fdf2cda0addfa4aeba5817b4340f28e3fdea634aba6af0",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": true "consent": true
@@ -179,72 +189,76 @@
"id": "termix-identity", "id": "termix-identity",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
"sha256": "00dfe3da65f78d7d085817c93a4e01bfef6931fb70ff5576c76e2f641e337555" "sha256": "f1f712178b5d64ae263227640b61d1f5ea5a301eae3ff8adb8b52de5f20892fe"
}, },
{ {
"id": "tmux-monitor", "id": "tmux-monitor",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
"sha256": "bbc508410296ca3434b5ba8c40fa5ef87646d104ec6f75da3bf0e22313bbbf18" "sha256": "d6a98bcf99dbb31201d7079baf348100bca1c0eda0928aec4aeb075ea373a8d5"
}, },
{ {
"id": "totp", "id": "totp",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9", "sha256": "aed42422ccc38c661f1aac2f7409f7872acf271552d6623b79bbcc43813d4fca",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4", "sha256": "215eae643d61d032bb6190bab7ed42140fd91c2165e2c4de079ff541e1522763",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "vault", "id": "vault",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
"sha256": "d84b0ef1a309bb3a89288f488a242dd19df9e46b237df778c2f2a77e77867960" "sha256": "0bbd95002866807ddd469f102d09bff9cf1b39e005dec6b96530066c6c3b0d19"
}, },
{ {
"id": "wake-on-lan", "id": "wake-on-lan",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
"sha256": "e680c22279438a566d1033939e4a3063a0fbbeb7f57b6db3bd6712bed09a4388" "sha256": "39b97642e44e7b128df3632b10962bff6a26313f2e989e5acd29760cf91c4e04"
}, },
{ {
"id": "warpgate", "id": "warpgate",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
"sha256": "c974be686488905277a52440b248d14370f3f543a7bd53f3485d5b6db4fab40b" "sha256": "a7b7ed6d39639d0d6050958417d33d646a0304f99dec4eb9da71a46e42ae80f1"
}, },
{ {
"id": "web-endpoint", "id": "web-endpoint",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
"sha256": "e93d8535617783bc538d415f25a5acc04e3805417002d68d4ebc4044c8fbed8c" "sha256": "22f5f276f6dab85e9b211f8e990f82fcbf63ebb8fa90379463f64d0fa38905ab"
}, },
{ {
"id": "webauthn", "id": "webauthn",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713", "sha256": "50994ae148b2a5e68cf924b922eb2fb22cf6215905049dd5b61a16e30a376a50",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
}, },
{ {
"id": "workspaces", "id": "workspaces",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd", "sha256": "23c6d2b0845b0e854dc6dde0ab4b60b9a58af020099fd4f6fcc1463a48d9ee62",
"onboarding": { "onboarding": {
"recommended": true "recommended": true,
"consent": false
} }
} }
] ]
@@ -0,0 +1 @@
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1791388888997, "when": 1791388888997,
"tag": "0069_plugin_channel", "tag": "0069_plugin_channel",
"breakpoints": true "breakpoints": true
},
{
"idx": 70,
"version": "5",
"when": 1791550497926,
"tag": "0070_drop_donation_modal",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1 @@
ALTER TABLE "users" DROP COLUMN "donation_modal_dismissed";
File diff suppressed because it is too large. Load diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1791388888397, "when": 1791388888397,
"tag": "0069_plugin_channel", "tag": "0069_plugin_channel",
"breakpoints": true "breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1791550496661,
"tag": "0070_drop_donation_modal",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1 @@
ALTER TABLE `users` DROP COLUMN `donation_modal_dismissed`;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -456,6 +456,13 @@
"when": 1791388887785, "when": 1791388887785,
"tag": "0064_plugin_channel", "tag": "0064_plugin_channel",
"breakpoints": true "breakpoints": true
},
{
"idx": 65,
"version": "6",
"when": 1791550495357,
"tag": "0065_drop_donation_modal",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -123,7 +123,7 @@
"entitlements": "packaging/build/entitlements.mac.plist", "entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist", "entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution", "type": "distribution",
"minimumSystemVersion": "10.15", "minimumSystemVersion": "12.0",
"mergeASARs": false, "mergeASARs": false,
"singleArchFiles": "**/*.{node,bare,dylib}", "singleArchFiles": "**/*.{node,bare,dylib}",
"x64ArchFiles": "**/{*.{node,bare,dylib},node-pty/prebuilds/*/spawn-helper}" "x64ArchFiles": "**/{*.{node,bare,dylib},node-pty/prebuilds/*/spawn-helper}"
+2 -2
View File
@@ -1041,7 +1041,7 @@ function getBackendPaths() {
backendCwd: backendDir, backendCwd: backendDir,
}; };
} }
// fork() does not go through Electron's asar redirector — use the unpacked path. // fork() does not go through Electron's asar redirector, use the unpacked path.
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR // On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants. // app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
const unpackedRoot = getUnpackedAppRoot(appRoot); const unpackedRoot = getUnpackedAppRoot(appRoot);
@@ -1303,7 +1303,7 @@ function createTray() {
try { try {
const { nativeImage } = require("electron"); const { nativeImage } = require("electron");
// Native APIs (Tray, nativeImage) can't load files from inside app.asar — // Native APIs (Tray, nativeImage) can't load files from inside app.asar,
// use the unpacked path so the OS sees a real file. // use the unpacked path so the OS sees a real file.
const publicRoot = isDev const publicRoot = isDev
? path.join(appRoot, "public") ? path.join(appRoot, "public")
+3 -3
View File
@@ -53,7 +53,7 @@ export default tseslint.config([
}, },
{ {
// MySQL has no RETURNING clause, and drizzle's mysql-core does not expose // MySQL has no RETURNING clause, and drizzle's mysql-core does not expose
// the method at all — a bare .returning() is a TypeError there, not a bad // the method at all. A bare .returning() is a TypeError there, not a bad
// query, and it only fails on the engine no test in this repo runs against. // query, and it only fails on the engine no test in this repo runs against.
// //
// 175 call sites were migrated off it. This is what stops number 176. // 175 call sites were migrated off it. This is what stops number 176.
@@ -85,14 +85,14 @@ export default tseslint.config([
{ {
// Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON // Postgres and SQLite spell it ON CONFLICT; MySQL spells it ON
// DUPLICATE KEY and names no columns, so drizzle's mysql-core has no // DUPLICATE KEY and names no columns, so drizzle's mysql-core has no
// onConflictDoUpdate at all — another TypeError, not a bad query. // onConflictDoUpdate at all, another TypeError, not a bad query.
selector: "CallExpression[callee.property.name='onConflictDoUpdate']", selector: "CallExpression[callee.property.name='onConflictDoUpdate']",
message: message:
"MySQL has no ON CONFLICT. Use upsert() from ./returning.js.", "MySQL has no ON CONFLICT. Use upsert() from ./returning.js.",
}, },
{ {
// better-sqlite3 puts these on a write result; node-postgres and // better-sqlite3 puts these on a write result; node-postgres and
// mysql2 do not, so reading them directly yields undefined — and // mysql2 do not, so reading them directly yields undefined, and
// Number(undefined) is NaN, which reaches the database as the string // Number(undefined) is NaN, which reaches the database as the string
// "NaN" and fails an integer column. Three call sites did exactly // "NaN" and fails an integer column. Three call sites did exactly
// this and only broke on Postgres. // this and only broke on Postgres.
-29
View File
@@ -20,35 +20,6 @@
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<title>Termix</title> <title>Termix</title>
<style> <style>
.hide-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.skinny-scrollbar {
scrollbar-width: thin;
scrollbar-color: #4a4a4a #1e1e21;
}
.skinny-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.skinny-scrollbar::-webkit-scrollbar-track {
background: #1e1e21;
}
.skinny-scrollbar::-webkit-scrollbar-thumb {
background-color: #4a4a4a;
border-radius: 3px;
border: 1px solid #1e1e21;
}
.toolbar-scrollbar { .toolbar-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #4a4a4a transparent; scrollbar-color: #4a4a4a transparent;
+13 -1
View File
@@ -15015,7 +15015,7 @@
}, },
"packages/plugin-sdk": { "packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.1.0", "version": "1.0.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2", "@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -15054,9 +15054,21 @@
"drizzle-orm": { "drizzle-orm": {
"optional": true "optional": true
}, },
"i18next": {
"optional": true
},
"react": { "react": {
"optional": true "optional": true
}, },
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
},
"ssh2": { "ssh2": {
"optional": true "optional": true
}, },
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## 1.0.5
### Fixed
- `execCommand` timeout errors no longer include the command, which could hold a sudo password
- `termix-plugin validate` fails when a plugin ships migrations for some database engines but not all three
- `termix-plugin migrations` loads the table definitions from a path with a `#` or other URL characters in it
- Text, json and encrypted columns are `longtext` on MySQL in new migrations, so they are no longer capped at 64KB. Existing migrations keep `text`
- `i18next`, `react-dom`, `react-i18next` and `sonner` are optional peer dependencies, so npm stops installing them into backend-only plugins
- `termix-plugin validate` accepts a beta version like `1.1.0-beta.1` when the changelog's newest release is `1.1.0` or older, so beta releases pass
## 1.0.4 ## 1.0.4
### Fixed ### Fixed
+1
View File
@@ -28,6 +28,7 @@ npm install --save-dev @termix-ssh/plugin-sdk
```bash ```bash
npx termix-plugin build # bundle dist/backend.js and dist/frontend.js npx termix-plugin build # bundle dist/backend.js and dist/frontend.js
npx termix-plugin patch # apply the plugin's dependency patches (build does this too)
npx termix-plugin test # run the plugin's Vitest suite npx termix-plugin test # run the plugin's Vitest suite
npx termix-plugin validate # check manifest.json and the files it names npx termix-plugin validate # check manifest.json and the files it names
npx termix-plugin openapi # write dist/openapi.json from @openapi comments npx termix-plugin openapi # write dist/openapi.json from @openapi comments
+7 -7
View File
@@ -108,13 +108,6 @@ const HOST_DIR = path.join(
const THEME_CSS = path.join(HOST_DIR, "theme.css"); const THEME_CSS = path.join(HOST_DIR, "theme.css");
const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json"); const CORE_CLASSES = path.join(HOST_DIR, "core-classes.json");
/**
* Compiles the Tailwind classes the plugin's frontend uses into
* dist/frontend.css, against the theme core ships in the SDK. Core only
* compiles its own classes, so a class no core file happens to use would
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
/** /**
* Moves a plugin's Tailwind layers under their own names. Core orders * Moves a plugin's Tailwind layers under their own names. Core orders
* termix-plugin-utilities above its base styles and below its own utilities, * termix-plugin-utilities above its base styles and below its own utilities,
@@ -136,6 +129,13 @@ function readCoreClasses() {
return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8"))); return new Set(JSON.parse(fs.readFileSync(CORE_CLASSES, "utf8")));
} }
/**
* Compiles the Tailwind classes the plugin's frontend uses into
* dist/frontend.css, against the theme core ships in the SDK. Core only
* compiles its own classes, so a class no core file happens to use would
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
export async function buildTailwind(cwd, outDir) { export async function buildTailwind(cwd, outDir) {
const { compile, optimize } = await import("@tailwindcss/node"); const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide"); const { Scanner } = await import("@tailwindcss/oxide");
@@ -17,7 +17,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS } from "../lib/externals.mjs"; import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs"; import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
@@ -53,7 +53,7 @@ async function loadDefinitions(cwd) {
external: BACKEND_EXTERNALS, external: BACKEND_EXTERNALS,
}); });
const module = await import(`file://${outfile.split(path.sep).join("/")}`); const module = await import(pathToFileURL(outfile).href);
const tables = module.tables ?? module.default; const tables = module.tables ?? module.default;
if (!Array.isArray(tables)) { if (!Array.isArray(tables)) {
throw new Error( throw new Error(
@@ -218,6 +218,11 @@ async function validateMigrations(cwd, pluginId) {
const present = DIALECTS.filter((dialect) => const present = DIALECTS.filter((dialect) =>
fs.existsSync(path.join(root, dialect)), fs.existsSync(path.join(root, dialect)),
); );
for (const dialect of DIALECTS) {
if (present.length > 0 && !present.includes(dialect)) {
problems.push(`migrations/${dialect} is missing`);
}
}
const byDialect = new Map(); const byDialect = new Map();
for (const dialect of present) { for (const dialect of present) {
+13 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.4", "version": "1.0.5",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -148,6 +148,18 @@
}, },
"@testing-library/react": { "@testing-library/react": {
"optional": true "optional": true
},
"i18next": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
} }
}, },
"publishConfig": { "publishConfig": {
+13 -2
View File
@@ -194,7 +194,9 @@ export function parseChangelog(markdown: string): {
/** /**
* Checks a CHANGELOG.md. With a manifest version, the newest release has to * Checks a CHANGELOG.md. With a manifest version, the newest release has to
* be that version, so a release never ships without its notes. * be that version, so a release never ships without its notes. A beta like
* 1.1.0-beta.2 gets its notes from git, so it only needs the newest release
* to be 1.1.0 or older.
*/ */
export function validateChangelog( export function validateChangelog(
markdown: string, markdown: string,
@@ -206,7 +208,16 @@ export function validateChangelog(
return problems; return problems;
} }
const newest = changelog.releases[0].version; const newest = changelog.releases[0].version;
if (manifestVersion && newest !== manifestVersion) { const base = manifestVersion
? semver.valid(semver.coerce(manifestVersion))
: null;
const betaOk =
!!manifestVersion &&
!!semver.prerelease(manifestVersion) &&
!!base &&
!!semver.valid(newest) &&
semver.lte(newest, base);
if (manifestVersion && newest !== manifestVersion && !betaOk) {
problems.push( problems.push(
`the newest release is ${newest}, but the manifest version is ${manifestVersion}`, `the newest release is ${newest}, but the manifest version is ${manifestVersion}`,
); );
+1 -1
View File
@@ -69,7 +69,7 @@ function builder(column: PluginColumn): Builder {
export const id = () => export const id = () =>
builder({ type: "id", primaryKey: true, notNull: true }); builder({ type: "id", primaryKey: true, notNull: true });
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */ /** Unbounded string, LONGTEXT on MySQL. Never indexable: MySQL cannot index TEXT. */
export const text = () => builder({ type: "text" }); export const text = () => builder({ type: "text" });
/** Bounded string. Indexable on every engine. */ /** Bounded string. Indexable on every engine. */
+3 -1
View File
@@ -80,11 +80,13 @@ function columnType(
// though it was declared unbounded. defineTable refuses to index a // though it was declared unbounded. defineTable refuses to index a
// text/json column, so in practice this only catches a primary key. // text/json column, so in practice this only catches a primary key.
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`; if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
// MySQL TEXT stops at 64KB, so free text, json and secrets go long.
if (dialect === "mysql" && column.type !== "timestamp") return "longtext";
return "text"; return "text";
} }
} }
/** Column types that are TEXT on MySQL when nothing keys them. */ /** Column types that are TEXT or LONGTEXT on MySQL when nothing keys them. */
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([ const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
"text", "text",
"json", "json",
+1 -1
View File
@@ -32,7 +32,7 @@ export function execCommand(
if (!settled) { if (!settled) {
settled = true; settled = true;
cleanup(); cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`)); reject(new Error(`Command timeout after ${timeoutMs}ms`));
} }
}, timeoutMs); }, timeoutMs);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+8 -8
View File
@@ -7,8 +7,8 @@
* drizzle/mysql. Nothing imports them to run a query. * drizzle/mysql. Nothing imports them to run a query.
* *
* That is not an oversight. The query builder needs two things from a table * That is not an oversight. The query builder needs two things from a table
* object — the identifiers to interpolate, and the encoders that turn JS values * object: the identifiers to interpolate, and the encoders that turn JS values
* into driver values — and the sqlite definitions supply both correctly for * into driver values. The sqlite definitions supply both correctly for
* every engine, which is why all 44 repositories import schema.ts directly: * every engine, which is why all 44 repositories import schema.ts directly:
* *
* - text and integer encode as themselves everywhere * - text and integer encode as themselves everywhere
@@ -17,15 +17,15 @@
* which is true for JS `true` as well as for 1 * which is true for JS `true` as well as for 1
* - real is a plain number on all three * - real is a plain number on all three
* *
* What genuinely differs between the dialects is DDL — column types, key * What genuinely differs between the dialects is DDL (column types, key
* lengths, autoincrement syntax — and DDL is exactly what these files exist to * lengths, autoincrement syntax), and DDL is exactly what these files exist to
* generate. See scripts/verify-dialects.mjs, which asserts the round-trips * generate. See scripts/verify-dialects.mjs, which asserts the round-trips
* above against real servers rather than trusting this comment. * above against real servers rather than trusting this comment.
* *
* The schema is declared once, in sqlite-core, and the other two dialects are * The schema is declared once, in sqlite-core, and the other two dialects are
* derived. Hand-maintaining three copies of 52 tables would mean a renamed * derived. Hand-maintaining three copies of 52 tables would mean a renamed
* table has to land in three places consistently or a foreign key silently * table has to land in three places consistently or a foreign key silently
* points at the wrong one — and the schema is regular enough that the mapping * points at the wrong one, and the schema is regular enough that the mapping
* is mechanical. * is mechanical.
* *
* What varies between dialects is small and closed: * What varies between dialects is small and closed:
@@ -198,7 +198,7 @@ function transform(source, dialect) {
); );
// Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts // Timestamps are stored as text (see sql-timestamp.ts). MySQL only accepts
// DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column — on a TEXT // DEFAULT CURRENT_TIMESTAMP on a DATETIME or TIMESTAMP column. On a TEXT
// one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an // one it is ER_INVALID_DEFAULT, "Invalid default value". Since 8.0.13 an
// expression default works on any type, and an expression is written // expression default works on any type, and an expression is written
// parenthesised. MariaDB accepts the bare form, which is why this only // parenthesised. MariaDB accepts the bare form, which is why this only
@@ -272,13 +272,13 @@ function transform(source, dialect) {
} }
function header(dialect) { function header(dialect) {
return `// GENERATED FILE — do not edit. return `// GENERATED FILE, do not edit.
// //
// Produced from schema.ts by scripts/generate-dialect-schema.cjs. // Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`. // Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`.
// Target dialect: ${dialect}. // Target dialect: ${dialect}.
// //
// DDL source for drizzle-kit. NOT imported to run queries — repositories use // DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct. // schema.ts on every dialect. See the generator header for why that is correct.
`; `;
} }
+1 -1
View File
@@ -137,7 +137,7 @@ describe("mysql output", () => {
expect(out).toContain('double("score")'); expect(out).toContain('double("score")');
}); });
it("makes key columns varchar — MySQL cannot index unbounded TEXT", () => { it("makes key columns varchar since MySQL cannot index unbounded TEXT", () => {
expect(out).toContain('varchar("user_id", { length: 255 })'); expect(out).toContain('varchar("user_id", { length: 255 })');
expect(out).toContain('text("name")'); expect(out).toContain('text("name")');
}); });
+2 -2
View File
@@ -46,7 +46,7 @@ await sharp(svgBuffer)
.toFile(join(publicDir, "full-icon.png")); .toFile(join(publicDir, "full-icon.png"));
console.log(" ✓ full-icon.png"); console.log(" ✓ full-icon.png");
// favicon.ico — embed 16, 32, 48 px layers // favicon.ico: embed 16, 32, 48 px layers
console.log("Generating favicon.ico..."); console.log("Generating favicon.ico...");
const icoSizes = [16, 32, 48]; const icoSizes = [16, 32, 48];
const icoBuffers = await Promise.all( const icoBuffers = await Promise.all(
@@ -55,7 +55,7 @@ const icoBuffers = await Promise.all(
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes)); writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
console.log(" ✓ favicon.ico"); console.log(" ✓ favicon.ico");
// icon.ico — embed 16, 32, 48, 64, 128, 256 px layers // icon.ico: embed 16, 32, 48, 64, 128, 256 px layers
console.log("Generating icon.ico..."); console.log("Generating icon.ico...");
const winSizes = [16, 32, 48, 64, 128, 256]; const winSizes = [16, 32, 48, 64, 128, 256];
const winBuffers = await Promise.all( const winBuffers = await Promise.all(
+6 -6
View File
@@ -28,7 +28,7 @@ function fail(message) {
function buildTable(version, mobileVersion, mobileTag) { function buildTable(version, mobileVersion, mobileTag) {
const tag = `v${version}`; const tag = `v${version}`;
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`; const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `release-${mobileVersion}-tag`}`; const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `v${mobileVersion}`}`;
const win = (file) => `${base}/${file}`; const win = (file) => `${base}/${file}`;
const linux = (file) => `${base}/${file}`; const linux = (file) => `${base}/${file}`;
@@ -37,11 +37,11 @@ function buildTable(version, mobileVersion, mobileTag) {
return [ return [
`| Architecture | Windows | Linux | Mac | Android | iOS |`, `| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`, `|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | — | — |`, `| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
`| **AArch64 (ARM64)** | — | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`, `| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **ARMv7 (32-bit)** | — | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | — | — | — |`, `| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | — | — | — | — |`, `| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/connector/windows) | [Flatpak](https://docs.termix.site/install/connector/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/connector/macos) | — | — |`, `| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
].join("\n"); ].join("\n");
} }
+1 -1
View File
@@ -30,7 +30,7 @@ describe("buildBody", () => {
expect(body).not.toContain("A bug"); expect(body).not.toContain("A bug");
expect(body).toContain("/releases/download/v26.10.0/termix_"); expect(body).toContain("/releases/download/v26.10.0/termix_");
expect(body).toContain( expect(body).toContain(
"Mobile/releases/download/release-1.2.0-tag/termix_android.apk", "Mobile/releases/download/v1.2.0/termix_android.apk",
); );
}); });
+11
View File
@@ -131,6 +131,17 @@ describe("validateChangelog", () => {
); );
}); });
it("lets a beta through when its notes are not written yet", () => {
expect(validateChangelog(FILE, "1.1.0-beta.1")).toEqual([]);
expect(validateChangelog(FILE, "1.2.0-beta.3")).toEqual([]);
});
it("flags a beta older than the newest release", () => {
expect(validateChangelog(FILE, "1.0.1-beta.1")).toContain(
"the newest release is 1.1.0, but the manifest version is 1.0.1-beta.1",
);
});
it("flags a file with no releases", () => { it("flags a file with no releases", () => {
expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([ expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([
'no releases found, add a "## 1.0.0" heading', 'no releases found, add a "## 1.0.0" heading',
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { validate } from "../packages/plugin-sdk/cli/commands/validate.mjs";
const roots: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const root of roots.splice(0))
fs.rmSync(root, { recursive: true, force: true });
});
function plugin(dialects: string[]) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-validate-"));
roots.push(root);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({ id: "fixture", version: "1.0.0" }),
);
for (const dialect of dialects) {
const dir = path.join(root, "migrations", dialect);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, "0001_init.sql"),
"CREATE TABLE p_fixture_items (id INTEGER PRIMARY KEY);\n",
);
}
return root;
}
async function problems(cwd: string) {
const lines: string[] = [];
vi.spyOn(console, "error").mockImplementation((line) => lines.push(line));
vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(validate({ cwd })).rejects.toThrow();
return lines.join("\n");
}
describe("termix-plugin validate migrations", () => {
it("flags an engine with no migrations folder", async () => {
const output = await problems(plugin(["sqlite", "postgres"]));
expect(output).toContain("migrations/mysql is missing");
expect(output).not.toContain("migrations/postgres is missing");
});
it("accepts all three engines", async () => {
const output = await problems(plugin(["sqlite", "postgres", "mysql"]));
expect(output).not.toMatch(/migrations\/\w+ is missing/);
});
});
+5 -5
View File
@@ -2,8 +2,8 @@
* Runs the repository layer against a real Postgres or MySQL server. * Runs the repository layer against a real Postgres or MySQL server.
* *
* The unit tests only ever see SQLite, so the parts of this codebase that * The unit tests only ever see SQLite, so the parts of this codebase that
* differ per engine — the RETURNING replacements, the read-then-write * differ per engine (the RETURNING replacements, the read-then-write
* transactions, the value encoders — have no coverage there at all. This is * transactions, the value encoders) have no coverage there at all. This is
* what covers them, and it needs a live server, which is why it is a script * what covers them, and it needs a live server, which is why it is a script
* rather than a test. * rather than a test.
* *
@@ -12,7 +12,7 @@
* npm run verify:dialect -- mysql://user:pass@host:3306/db * npm run verify:dialect -- mysql://user:pass@host:3306/db
* *
* Applies the migrations first, through the same runRemoteMigrations() the * Applies the migrations first, through the same runRemoteMigrations() the
* application uses at startup — so a broken migration fails here rather than in * application uses at startup, so a broken migration fails here rather than in
* production. Writes real rows: point it at a scratch database. * production. Writes real rows: point it at a scratch database.
*/ */
@@ -40,7 +40,7 @@ const { drizzle } = await import(
dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2" dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2"
); );
// No schema option on purpose — see connect.ts. // No schema option on purpose, see connect.ts.
const db = drizzle(url); const db = drizzle(url);
const context = { dialect, drizzle: db }; const context = { dialect, drizzle: db };
@@ -87,7 +87,7 @@ check("insert returns the stored row", created?.username, "before");
check("boolean true survives the round trip", created?.isAdmin, true); check("boolean true survives the round trip", created?.isAdmin, true);
// updateReturning must report the state AFTER the write. Reading first would // updateReturning must report the state AFTER the write. Reading first would
// return the value the update replaced — silently, with no error. // return the value the update replaced, silently, with no error.
const updated = await users.update(userId, { username: "after" }); const updated = await users.update(userId, { username: "after" });
check("update returns the new value", updated?.username, "after"); check("update returns the new value", updated?.username, "after");
+22
View File
@@ -171,6 +171,27 @@ async function isTrustedDevice(req: Request, userId: string): Promise<boolean> {
type DeviceType = ReturnType<typeof parseUserAgent>["type"]; type DeviceType = ReturnType<typeof parseUserAgent>["type"];
/**
* An account that only signs in through SSO or LDAP must keep doing so. A
* passkey or any other local method would skip the identity provider, so a
* user disabled there could still get in.
*/
export function assertMethodAllowedFor(
user: Pick<UserRecord, "isExternal" | "passwordHash">,
identity: VerifiedIdentity,
methodId: string,
): void {
if (identity.kind === "external" || !user.isExternal) return;
if (user.passwordHash && user.passwordHash.trim() !== "") return;
ensureCoreLoginProviders();
if (getLoginMethod(methodId)?.external) return;
throw new LoginMethodError(
"This account signs in through its identity provider",
403,
"external_login_required",
);
}
async function resolveUser( async function resolveUser(
identity: VerifiedIdentity, identity: VerifiedIdentity,
deviceType: DeviceType, deviceType: DeviceType,
@@ -253,6 +274,7 @@ export async function runLogin(
): Promise<LoginResult> { ): Promise<LoginResult> {
const deviceType = parseUserAgent(req).type; const deviceType = parseUserAgent(req).type;
const user = await resolveUser(identity, deviceType); const user = await resolveUser(identity, deviceType);
assertMethodAllowedFor(user, identity, context.methodId);
await unlockUser(user, identity, deviceType); await unlockUser(user, identity, deviceType);
if ("password" in identity && identity.password) { if ("password" in identity && identity.password) {
+3 -3
View File
@@ -268,7 +268,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* localVersion: * localVersion:
* type: string * type: string
* description: Version of the running instance. Use this field for client compatibility checks. * description: Version of the running instance. Use this field for client compatibility checks.
* example: 2.7.1 * example: 26.10.0
* status: * status:
* type: string * type: string
* description: Update comparison result, update_check_disabled when explicitly disabled, or unknown when the remote lookup fails or the release tag cannot be parsed. * description: Update comparison result, update_check_disabled when explicitly disabled, or unknown when the remote lookup fails or the release tag cannot be parsed.
@@ -280,12 +280,12 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* remoteVersion: * remoteVersion:
* type: string * type: string
* description: Newest release version on the channel. Present only when the update check succeeds. * description: Newest release version on the channel. Present only when the update check succeeds.
* example: 2.8.0 * example: 26.10.0
* version: * version:
* type: string * type: string
* deprecated: true * deprecated: true
* description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release. * description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release.
* example: 2.8.0 * example: 26.10.0
* latest_release: * latest_release:
* type: object * type: object
* description: GitHub release metadata. Present only when the update check succeeds. * description: GitHub release metadata. Present only when the update check succeeds.
+3 -3
View File
@@ -50,7 +50,7 @@ export function sslOption(
/** /**
* Opens a connection to a client-server engine. * Opens a connection to a client-server engine.
* *
* SQLite is not handled here — it has its own lifecycle in db/index.ts, where * SQLite is not handled here, it has its own lifecycle in db/index.ts, where
* the database is decrypted into memory and serialised back to a file. This * the database is decrypted into memory and serialised back to a file. This
* covers the engines that connect to something already running. * covers the engines that connect to something already running.
* *
@@ -109,7 +109,7 @@ export async function connectRemoteDatabase(
// No `schema` option: it only feeds drizzle's relational query API // No `schema` option: it only feeds drizzle's relational query API
// (`db.query.*`), which nothing here uses. The query builder takes its table // (`db.query.*`), which nothing here uses. The query builder takes its table
// names and value encoders from the table objects the repositories import — // names and value encoders from the table objects the repositories import,
// see the note in schema.pg.ts on why the generated schemas are DDL-only. // see the note in schema.pg.ts on why the generated schemas are DDL-only.
if (dialect === "postgres") { if (dialect === "postgres") {
const { drizzle } = await import("drizzle-orm/node-postgres"); const { drizzle } = await import("drizzle-orm/node-postgres");
@@ -119,7 +119,7 @@ export async function connectRemoteDatabase(
} }
// mysql2 names the pool limit differently and wants no `ssl` key at all when // mysql2 names the pool limit differently and wants no `ssl` key at all when
// TLS is off — passing false is not the same as omitting it. // TLS is off, passing false is not the same as omitting it.
const { drizzle } = await import("drizzle-orm/mysql2"); const { drizzle } = await import("drizzle-orm/mysql2");
return drizzle({ return drizzle({
connection: { connection: {
+1 -1
View File
@@ -42,7 +42,7 @@ export function resolveDatabaseDialect(
* *
* SQLite here is an in-memory database serialised back to an encrypted file, so * SQLite here is an in-memory database serialised back to an encrypted file, so
* every write needs a trigger to flush it. Client-server engines have already * every write needs a trigger to flush it. Client-server engines have already
* durably committed by the time the query returns — there is no file to write * durably committed by the time the query returns, there is no file to write
* and nothing to schedule. * and nothing to schedule.
*/ */
export function needsExplicitPersist(dialect: DatabaseDialect): boolean { export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
+4 -13
View File
@@ -242,8 +242,7 @@ async function initializeCompleteDatabase(): Promise<void> {
username TEXT NOT NULL, username TEXT NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0, is_admin INTEGER NOT NULL DEFAULT 0,
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
@@ -970,9 +969,7 @@ const migrateSchema = () => {
// CURRENT_TIMESTAMP, so the column is added empty and backfilled below. // CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
addColumnIfNotExists("users", "registered_at", "TEXT"); addColumnIfNotExists("users", "registered_at", "TEXT");
if (!hadRegisteredAtColumn) { if (!hadRegisteredAtColumn) {
// Pre-existing users are backdated past the 30 day mark so they see the // Pre-existing users get a date in the past, since their real one is unknown.
// donation modal immediately on upgrade instead of waiting a fresh
// 30 days as if they had just registered.
try { try {
sqlite.exec( sqlite.exec(
`UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`, `UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
@@ -1000,12 +997,6 @@ const migrateSchema = () => {
); );
} }
} }
addColumnIfNotExists(
"users",
"donation_modal_dismissed",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("sessions", "external_session_ref", "TEXT"); addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("ssh_data", "name", "TEXT"); addColumnIfNotExists("ssh_data", "name", "TEXT");
@@ -1103,7 +1094,7 @@ const migrateSchema = () => {
// Derive the replacement table from the live definition instead of // Derive the replacement table from the live definition instead of
// restating it here. The table keeps gaining columns (cert_public_key, // restating it here. The table keeps gaining columns (cert_public_key,
// pin, sort_order, sync_id, ...), and a second copy of the column list // pin, sort_order, sync_id, ...), and a second copy of the column list
// falls behind every time one is added — leaving the copy narrower than // falls behind every time one is added, leaving the copy narrower than
// the table, so the INSERT below fails and the constraint stays put. // the table, so the INSERT below fails and the constraint stays put.
const createSql = sqlite const createSql = sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'") .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
@@ -2108,7 +2099,7 @@ export function getSqlite(): Database.Database {
if (dialect !== "sqlite") { if (dialect !== "sqlite") {
throw new Error( throw new Error(
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` + `No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
`synchronous query, which only SQLite offers — give it an async path instead.`, `synchronous query, which only SQLite offers. Give it an async path instead.`,
); );
} }
throw new Error( throw new Error(
+1 -1
View File
@@ -25,7 +25,7 @@ export function migrationsFolder(
* Brings a remote database up to the current schema. * Brings a remote database up to the current schema.
* *
* drizzle's migrator records what it has applied in its own table, so this is * drizzle's migrator records what it has applied in its own table, so this is
* safe to run on every start — including against a database another instance * safe to run on every start, including against a database another instance
* already migrated. * already migrated.
*/ */
export async function runRemoteMigrations( export async function runRemoteMigrations(
@@ -77,6 +77,7 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
"identifier_path", "identifier_path",
"name_path", "name_path",
"scopes", "scopes",
"donation_modal_dismissed",
], ],
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"], sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
user_preferences: [ user_preferences: [
+5 -8
View File
@@ -1,10 +1,10 @@
// GENERATED FILE — do not edit. // GENERATED FILE, do not edit.
// //
// Produced from schema.ts by scripts/generate-dialect-schema.cjs. // Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. // Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: mysql. // Target dialect: mysql.
// //
// DDL source for drizzle-kit. NOT imported to run queries — repositories use // DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct. // schema.ts on every dialect. See the generator header for why that is correct.
import { import {
@@ -27,9 +27,6 @@ export const users = mysqlTable("users", {
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false), isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
}); });
export const settings = mysqlTable("settings", { export const settings = mysqlTable("settings", {
@@ -542,7 +539,7 @@ export const sharedHostSecrets = mysqlTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [ (table) => [
uniqueIndex("idx_shared_host_secrets_scope").on( uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId, table.hostAccessId,
@@ -592,7 +589,7 @@ export const userRoles = mysqlTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
// //
// The unique pair already serves lookups by user, since user_id leads it. // The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve. // Listing a role's members starts from role_id, which it cannot serve.
@@ -1060,7 +1057,7 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
/** /**
* Install counts populated by a background job (GitHub release download * Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the * counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so * install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state. * that job can overwrite counts without touching install state.
*/ */
export const pluginInstallCounts = mysqlTable( export const pluginInstallCounts = mysqlTable(
+5 -8
View File
@@ -1,10 +1,10 @@
// GENERATED FILE — do not edit. // GENERATED FILE, do not edit.
// //
// Produced from schema.ts by scripts/generate-dialect-schema.cjs. // Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. // Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: postgres. // Target dialect: postgres.
// //
// DDL source for drizzle-kit. NOT imported to run queries — repositories use // DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct. // schema.ts on every dialect. See the generator header for why that is correct.
import { import {
@@ -27,9 +27,6 @@ export const users = pgTable("users", {
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false), isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`), registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
}); });
export const settings = pgTable("settings", { export const settings = pgTable("settings", {
@@ -542,7 +539,7 @@ export const sharedHostSecrets = pgTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [ (table) => [
uniqueIndex("idx_shared_host_secrets_scope").on( uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId, table.hostAccessId,
@@ -592,7 +589,7 @@ export const userRoles = pgTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
// //
// The unique pair already serves lookups by user, since user_id leads it. // The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve. // Listing a role's members starts from role_id, which it cannot serve.
@@ -1060,7 +1057,7 @@ export const pluginRegistries = pgTable("plugin_registries", {
/** /**
* Install counts populated by a background job (GitHub release download * Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the * counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so * install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state. * that job can overwrite counts without touching install state.
*/ */
export const pluginInstallCounts = pgTable( export const pluginInstallCounts = pgTable(
+3 -8
View File
@@ -15,11 +15,6 @@ export const users = sqliteTable("users", {
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`), registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: integer("donation_modal_dismissed", {
mode: "boolean",
})
.notNull()
.default(false),
}); });
export const settings = sqliteTable("settings", { export const settings = sqliteTable("settings", {
@@ -534,7 +529,7 @@ export const sharedHostSecrets = sqliteTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [ (table) => [
uniqueIndex("idx_shared_host_secrets_scope").on( uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId, table.hostAccessId,
@@ -584,7 +579,7 @@ export const userRoles = sqliteTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
// //
// The unique pair already serves lookups by user, since user_id leads it. // The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve. // Listing a role's members starts from role_id, which it cannot serve.
@@ -1058,7 +1053,7 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
/** /**
* Install counts populated by a background job (GitHub release download * Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the * counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so * install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state. * that job can overwrite counts without touching install state.
*/ */
export const pluginInstallCounts = sqliteTable( export const pluginInstallCounts = sqliteTable(
@@ -74,7 +74,7 @@ export class AuditLogRepository {
/** /**
* Cached row count backing the cap check. Static because the factory builds * Cached row count backing the cap check. Static because the factory builds
* a repository per call, so a per-instance count would never survive to be * a repository per call, so a per-instance count would never survive to be
* reused. Null means "unknown, re-read" — which is also how any path that * reused. Null means "unknown, re-read", which is also how any path that
* deletes rows invalidates it. * deletes rows invalidates it.
*/ */
private static cachedCount: number | null = null; private static cachedCount: number | null = null;
@@ -233,13 +233,13 @@ export class AuditLogRepository {
* *
* Both passes used to run inline on every insert: a retention DELETE plus a * Both passes used to run inline on every insert: a retention DELETE plus a
* COUNT over the whole table, thousands of times an hour on a busy install, * COUNT over the whole table, thousands of times an hour on a busy install,
* almost always to find nothing to do — and audit writes sit in the request * almost always to find nothing to do, and audit writes sit in the request
* path of the actions they record. * path of the actions they record.
* *
* They are split by what they cost and what they guarantee. Retention is * They are split by what they cost and what they guarantee. Retention is
* time-based, so nothing is lost by checking it on an interval. The row cap * time-based, so nothing is lost by checking it on an interval. The row cap
* is a disk-space guard that has to react to inserts, so it is still checked * is a disk-space guard that has to react to inserts, so it is still checked
* on the write that crosses it — but against a cached count, so the common * on the write that crosses it, but against a cached count, so the common
* case is an integer compare rather than a COUNT. * case is an integer compare rather than a COUNT.
*/ */
private async pruneIfDue(): Promise<void> { private async pruneIfDue(): Promise<void> {
@@ -2,7 +2,7 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type * as schema from "../db/schema.js"; import type * as schema from "../db/schema.js";
// Re-exported so repositories can keep importing it from here, but defined in // Re-exported so repositories can keep importing it from here, but defined in
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and // db/dialect.ts, a local copy that said "sqlite" survived here for a while and
// typed every context as SQLite-only while the runtime already carried all // typed every context as SQLite-only while the runtime already carried all
// three, which silently made the dialect branches unreachable to the checker. // three, which silently made the dialect branches unreachable to the checker.
export type { DatabaseDialect } from "../db/dialect.js"; export type { DatabaseDialect } from "../db/dialect.js";
@@ -18,7 +18,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
* *
* This is a deliberate approximation, not an accident. The query-builder * This is a deliberate approximation, not an accident. The query-builder
* surface the repositories actually use is the same on all three engines, and * surface the repositories actually use is the same on all three engines, and
* that equivalence is asserted in multi-dialect.test.ts rather than assumed — * that equivalence is asserted in multi-dialect.test.ts rather than assumed,
* identifier quoting, placeholder style and value coercion are all covered * identifier quoting, placeholder style and value coercion are all covered
* there. At runtime this may hold a Postgres or MySQL instance. * there. At runtime this may hold a Postgres or MySQL instance.
* *
@@ -32,7 +32,7 @@ export type PortableDatabase = BetterSQLite3Database<typeof schema>;
* *
* Deliberately drizzle-only: with no raw driver handle here, no repository can * Deliberately drizzle-only: with no raw driver handle here, no repository can
* reach for engine-specific SQL. Retention queries that previously needed * reach for engine-specific SQL. Retention queries that previously needed
* `datetime('now', ?)` compute their cutoff in JS instead — see * `datetime('now', ?)` compute their cutoff in JS instead, see
* ./sql-timestamp.ts. * ./sql-timestamp.ts.
*/ */
export interface DatabaseContext { export interface DatabaseContext {
+5 -5
View File
@@ -49,7 +49,7 @@ import { UserRepository } from "./user-repository.js";
* write. * write.
* *
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither * Both cross-dialect harnesses build a DatabaseContext themselves, so neither
* exercises this function — see tests/database/repositories/factory-context. * exercises this function, see tests/database/repositories/factory-context.
*/ */
export function createCurrentRepositoryContext(): DatabaseContext { export function createCurrentRepositoryContext(): DatabaseContext {
return { return {
@@ -64,7 +64,7 @@ export function createCurrentRepositoryContext(): DatabaseContext {
* Only meaningful for SQLite, where the database lives in memory and has to be * Only meaningful for SQLite, where the database lives in memory and has to be
* serialised back to its encrypted file. On Postgres and MySQL the write is * serialised back to its encrypted file. On Postgres and MySQL the write is
* already durable, so no hook is installed at all rather than one that does * already durable, so no hook is installed at all rather than one that does
* nothing — repositories call it as `this.onWrite?.()`. * nothing, repositories call it as `this.onWrite?.()`.
*/ */
export function createCurrentRepositoryWriteHook( export function createCurrentRepositoryWriteHook(
reason: string, reason: string,
@@ -91,7 +91,7 @@ export function createCurrentRepositoryLazyWriteHook(
} }
/** /**
* Raw driver handle for the few synchronous call sites that cannot await — * Raw driver handle for the few synchronous call sites that cannot await,
* getCurrentSettingValue below, and settings reads during startup. Repositories * getCurrentSettingValue below, and settings reads during startup. Repositories
* must not use this: they take a DatabaseContext, which is drizzle-only. * must not use this: they take a DatabaseContext, which is drizzle-only.
* Porting to another engine means giving these callers an async path first. * Porting to another engine means giving these callers an async path first.
@@ -403,8 +403,8 @@ let refreshTimer: NodeJS.Timeout | null = null;
* *
* The cache is per-process and updated in the process that writes. That is * The cache is per-process and updated in the process that writes. That is
* enough for SQLite, where there is only ever one process. On Postgres and * enough for SQLite, where there is only ever one process. On Postgres and
* MySQL — which exist here precisely so more than one instance can share the * MySQL, which exist here precisely so more than one instance can share the
* data — a setting changed on one replica would otherwise never reach the * data, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read has no way to go back to the database. * others, because the synchronous read has no way to go back to the database.
* *
* Periodic re-priming does not make the value immediately consistent. It bounds * Periodic re-priming does not make the value immediately consistent. It bounds
@@ -31,7 +31,7 @@ export class HostFolderRepository {
now = new Date().toISOString(), now = new Date().toISOString(),
): Promise<RenameFolderResult> { ): Promise<RenameFolderResult> {
// CAST target: every engine spells the text type differently enough to // CAST target: every engine spells the text type differently enough to
// matter here — MySQL has no `text` cast and wants `char`. // matter here, MySQL has no `text` cast and wants `char`.
const textType = this.context.dialect === "mysql" ? "char" : "text"; const textType = this.context.dialect === "mysql" ? "char" : "text";
const oldPrefix = `${oldName} / `; const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `; const newPrefix = `${newName} / `;
@@ -4,18 +4,18 @@ import type { DatabaseDialect } from "../db/dialect.js";
* Reading the outcome of a write without depending on RETURNING. * Reading the outcome of a write without depending on RETURNING.
* *
* SQLite and Postgres can attach `.returning()` to a delete or update and get * SQLite and Postgres can attach `.returning()` to a delete or update and get
* the affected rows back. **MySQL cannot** — it has no RETURNING clause, and * the affected rows back. **MySQL cannot**: it has no RETURNING clause, and
* drizzle's mysql-core does not expose the method at all, so the call is a * drizzle's mysql-core does not expose the method at all, so the call is a
* TypeError rather than a bad query. 175 call sites here read a write's result, * TypeError rather than a bad query. 175 call sites here read a write's result,
* so the difference has to be absorbed somewhere. * so the difference has to be absorbed somewhere.
* *
* The split that matters is what the caller actually needs: * The split that matters is what the caller actually needs:
* *
* - **How many rows changed** — the majority, and none of them need the rows. * - **How many rows changed**: the majority, and none of them need the rows.
* They used to ask for them anyway, via `.returning().length`. Dropping the * They used to ask for them anyway, via `.returning().length`. Dropping the
* `.returning()` and reading the driver's own count is both portable and one * `.returning()` and reading the driver's own count is both portable and one
* less thing for the database to send back. * less thing for the database to send back.
* - **The rows themselves** — cannot be emulated on MySQL without reading * - **The rows themselves**: cannot be emulated on MySQL without reading
* first, which needs a transaction to stay correct under concurrency. Those * first, which needs a transaction to stay correct under concurrency. Those
* call sites are handled individually rather than behind a helper that hides * call sites are handled individually rather than behind a helper that hides
* an extra round trip. * an extra round trip.
@@ -33,7 +33,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
* | node-postgres | `{ rowCount, rows, command }` | * | node-postgres | `{ rowCount, rows, command }` |
* | mysql2 | `[{ affectedRows, insertId }, fields]` | * | mysql2 | `[{ affectedRows, insertId }, fields]` |
* *
* These are the shapes returned when NO `.returning()` is attached — which is * These are the shapes returned when NO `.returning()` is attached, which is
* the portable way to write, since MySQL has no RETURNING clause at all. * the portable way to write, since MySQL has no RETURNING clause at all.
*/ */
interface WriteHeader { interface WriteHeader {
@@ -47,7 +47,7 @@ interface WriteHeader {
const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const; const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const;
/** /**
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so * mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array, so
* "is it an array" cannot distinguish a write header from a returning() result. * "is it an array" cannot distinguish a write header from a returning() result.
* The header is identified by carrying one of the fields above instead. * The header is identified by carrying one of the fields above instead.
*/ */
@@ -70,7 +70,7 @@ function asWriteHeader(result: unknown): WriteHeader | null {
/** /**
* Number of rows a write touched. * Number of rows a write touched.
* *
* Pass the result of the write itself — every driver's header is understood, so * Pass the result of the write itself, every driver's header is understood, so
* the caller neither branches on the dialect nor attaches `.returning()` just to * the caller neither branches on the dialect nor attaches `.returning()` just to
* count what came back. * count what came back.
* *
@@ -97,7 +97,7 @@ export function rowsAffected(result: unknown): number {
* *
* **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and * **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and
* MySQL's `insertId` are connection-level values that survive the statement that * MySQL's `insertId` are connection-level values that survive the statement that
* set them — after a delete, SQLite still reports whatever the last insert * set them, after a delete, SQLite still reports whatever the last insert
* produced. Passing an update or delete result here gets a stale id, not null. * produced. Passing an update or delete result here gets a stale id, not null.
* *
* Returns null when the table has no autoincrement key. * Returns null when the table has no autoincrement key.
@@ -143,7 +143,7 @@ export function supportsReturning(dialect: DatabaseDialect): boolean {
* *
* `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT() * `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT()
* as bigint, which node-postgres hands back as a **string** so that values past * as bigint, which node-postgres hands back as a **string** so that values past
* 2^53 survive — so the annotation is a lie there and comparisons like * 2^53 survive, so the annotation is a lie there and comparisons like
* `count < max` compare a string to a number. * `count < max` compare a string to a number.
*/ */
export function countValue(value: unknown): number { export function countValue(value: unknown): number {
+12 -12
View File
@@ -11,15 +11,15 @@ import {
* Writes that need the affected rows back. * Writes that need the affected rows back.
* *
* `mutation-result.ts` covers the call sites that only wanted a count. These are * `mutation-result.ts` covers the call sites that only wanted a count. These are
* the ones that genuinely read the rows — an updated record to return to the * the ones that genuinely read the rows, an updated record to return to the
* caller, a deleted row's fields to clean up alongside it. * caller, a deleted row's fields to clean up alongside it.
* *
* SQLite and Postgres do this in one statement with RETURNING. MySQL has no * SQLite and Postgres do this in one statement with RETURNING. MySQL has no
* such clause, so the read is a second statement, and the pair has to be atomic: * such clause, so the read is a second statement, and the pair has to be atomic:
* *
* - **insert** — write, then read the row back by its key. * - **insert**: write, then read the row back by its key.
* - **update** — write, then read. Reading first would return the old values. * - **update**: write, then read. Reading first would return the old values.
* - **delete** — read, then write. Reading after would return nothing. * - **delete**: read, then write. Reading after would return nothing.
* *
* Both run in a transaction. Without one, a concurrent write between the two * Both run in a transaction. Without one, a concurrent write between the two
* statements makes the returned rows describe a state that never existed, and * statements makes the returned rows describe a state that never existed, and
@@ -29,7 +29,7 @@ import {
* ## The trap, and why it cannot bite silently * ## The trap, and why it cannot bite silently
* *
* On MySQL the update path re-reads using the same `where`. If the update * On MySQL the update path re-reads using the same `where`. If the update
* changes a column that `where` tests, the read finds nothing — SQLite would * changes a column that `where` tests, the read finds nothing, SQLite would
* have returned the row. Every current caller filters on an id it does not * have returned the row. Every current caller filters on an id it does not
* modify, but that is a convention, not a guarantee, so the mismatch is * modify, but that is a convention, not a guarantee, so the mismatch is
* detected and thrown rather than returned as an empty array. Same for an * detected and thrown rather than returned as an empty array. Same for an
@@ -40,7 +40,7 @@ import {
*/ */
/** /**
* What `.set()` accepts: a column's own type, or a SQL expression in its place — * What `.set()` accepts: a column's own type, or a SQL expression in its place,
* `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here. * `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here.
*/ */
type UpdateValues<T extends SQLiteTable> = { type UpdateValues<T extends SQLiteTable> = {
@@ -68,7 +68,7 @@ export async function updateReturning<T extends SQLiteTable>(
const rows = await tx.select().from(table).where(where); const rows = await tx.select().from(table).where(where);
// The trap this catches: if the update changed a column that `where` tests, // The trap this catches: if the update changed a column that `where` tests,
// the read finds nothing and the caller gets [] — on MySQL only, with no // the read finds nothing and the caller gets [], on MySQL only, with no
// error, where SQLite would have returned the row. Rows changed but none // error, where SQLite would have returned the row. Rows changed but none
// readable back is exactly that case, so make it loud instead. // readable back is exactly that case, so make it loud instead.
if (rows.length === 0 && rowsAffected(written) > 0) { if (rows.length === 0 && rowsAffected(written) > 0) {
@@ -108,10 +108,10 @@ type Keyed = SQLiteTable & { id: SQLiteColumn };
/** /**
* Inserts one row and returns it as stored, including whatever the database * Inserts one row and returns it as stored, including whatever the database
* filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP. * filled in, defaults, an autoincrement id, a CURRENT_TIMESTAMP.
* *
* This is the one case Postgres cannot shortcut either: without RETURNING there * This is the one case Postgres cannot shortcut either: without RETURNING there
* is no id to read back by. Hence the split is genuinely three-way — except * is no id to read back by. Hence the split is genuinely three-way, except
* that sqlite and pg both have RETURNING, so it collapses to two again. * that sqlite and pg both have RETURNING, so it collapses to two again.
* *
* On MySQL the key comes from one of two places: * On MySQL the key comes from one of two places:
@@ -161,7 +161,7 @@ export async function insertReturning<T extends Keyed>(
* back by an explicit condition. * back by an explicit condition.
* *
* `user_preferences` is keyed by `userId` and has no `id` column at all, so * `user_preferences` is keyed by `userId` and has no `id` column at all, so
* there is no insertId to read back by — the caller has to say what identifies * there is no insertId to read back by, the caller has to say what identifies
* the row it just wrote. * the row it just wrote.
*/ */
export async function insertReturningWhere<T extends SQLiteTable>( export async function insertReturningWhere<T extends SQLiteTable>(
@@ -195,13 +195,13 @@ export async function insertReturningWhere<T extends SQLiteTable>(
* *
* The clause has three spellings. SQLite and Postgres take * The clause has three spellings. SQLite and Postgres take
* `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and * `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and
* names no columns** — it uses whichever unique key was violated. drizzle * names no columns**: it uses whichever unique key was violated. drizzle
* follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and * follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and
* calling it is a TypeError rather than a rejected query. * calling it is a TypeError rather than a rejected query.
* *
* The conflict target still has to be passed: it is what SQLite and Postgres * The conflict target still has to be passed: it is what SQLite and Postgres
* need, and stating it keeps the caller honest about which unique constraint it * need, and stating it keeps the caller honest about which unique constraint it
* is relying on — four of those were missing from the schema entirely until the * is relying on, four of those were missing from the schema entirely until the
* cross-dialect tests went looking. * cross-dialect tests went looking.
*/ */
export async function upsert<T extends SQLiteTable>( export async function upsert<T extends SQLiteTable>(
@@ -1,7 +1,7 @@
/** /**
* Synchronous read-through cache for the settings table. * Synchronous read-through cache for the settings table.
* *
* 27 call sites read settings synchronously — during startup, inside request * 27 call sites read settings synchronously, during startup, inside request
* handlers, and from the guacd server bootstrap. On SQLite that works because * handlers, and from the guacd server bootstrap. On SQLite that works because
* better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous * better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous
* query at all, and making all 27 async would push `await` through code paths * query at all, and making all 27 async would push `await` through code paths
@@ -35,7 +35,7 @@ export function primeSettingsCache(
/** /**
* Reads a cached setting. * Reads a cached setting.
* *
* Returns null both for "not set" and "cache not primed yet" — every caller * Returns null both for "not set" and "cache not primed yet", every caller
* already treats a missing setting as "use the default", and startup ordering * already treats a missing setting as "use the default", and startup ordering
* means a read before priming should behave the same way rather than throw. * means a read before priming should behave the same way rather than throw.
*/ */
@@ -141,8 +141,8 @@ export class UserRepository {
* at once would otherwise both see an empty table and both become admin. * at once would otherwise both see an empty table and both become admin.
* *
* The two branches are not a style choice. better-sqlite3 is synchronous and * The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright — "Transaction function * rejects an async transaction callback outright, "Transaction function
* cannot return a promise" — so a single body cannot serve both. It fails * cannot return a promise", so a single body cannot serve both. It fails
* loudly rather than silently skipping the write, which is the one mercy here. * loudly rather than silently skipping the write, which is the one mercy here.
*/ */
private async createCheckingIfFirst( private async createCheckingIfFirst(
@@ -17,7 +17,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
* /credential-sidebar/preferences: * /credential-sidebar/preferences:
* get: * get:
* summary: Get the credential sidebar preferences for the current user * summary: Get the credential sidebar preferences for the current user
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here — credentials never had exploded preference columns on userPreferences — so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT. * description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here, credentials never had exploded preference columns on userPreferences, so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
* tags: * tags:
* - Credential Sidebar * - Credential Sidebar
* responses: * responses:
@@ -1,12 +0,0 @@
const DONATION_MODAL_DELAY_MS = 30 * 24 * 60 * 60 * 1000;
export function shouldShowDonationModal(
registeredAt: string,
donationModalDismissed: boolean,
now: number = Date.now(),
): boolean {
if (donationModalDismissed) return false;
const registeredAtMs = Date.parse(registeredAt);
if (Number.isNaN(registeredAtMs)) return false;
return now - registeredAtMs >= DONATION_MODAL_DELAY_MS;
}
+8 -8
View File
@@ -9,6 +9,14 @@ const router = express.Router();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
/** /**
* @openapi * @openapi
* /open-tabs: * /open-tabs:
@@ -20,14 +28,6 @@ const authenticateJWT = authManager.createAuthMiddleware();
* 200: * 200:
* description: List of open tabs ordered by tab_order. * description: List of open tabs ordered by tab_order.
*/ */
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => { router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
+5 -2
View File
@@ -17,7 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js"; import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js"; import { parsePluginChannel } from "../../plugins/registry-index.js";
import { import {
pluginDocsUrl, pluginDocsUrl,
@@ -2262,7 +2262,10 @@ router.post(
"/upload", "/upload",
authenticateJWT, authenticateJWT,
requireManagePlugins, requireManagePlugins,
express.raw({ type: "application/octet-stream", limit: "50mb" }), express.raw({
type: "application/octet-stream",
limit: MAX_UPLOAD_BYTES,
}),
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
if (await refuseOnLinkedDesktop(res)) return; if (await refuseOnLinkedDesktop(res)) return;
if (!Buffer.isBuffer(req.body) || req.body.length === 0) { if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
-49
View File
@@ -20,7 +20,6 @@ import {
isNativeTokenExportRequest, isNativeTokenExportRequest,
resolveDesktopAutoSessionUser, resolveDesktopAutoSessionUser,
} from "./desktop-auto-session.js"; } from "./desktop-auto-session.js";
import { shouldShowDonationModal } from "./donation-modal-utils.js";
import { PermissionManager } from "../../utils/permission-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js"; import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerBrandingRoutes } from "./branding-routes.js"; import { registerBrandingRoutes } from "./branding-routes.js";
@@ -595,11 +594,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
const hasPassword = user.passwordHash && user.passwordHash.trim() !== ""; const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const isDualAuth = hasPassword && isExternalAccount(user); const isDualAuth = hasPassword && isExternalAccount(user);
const showDonationModal = shouldShowDonationModal(
user.registeredAt,
!!user.donationModalDismissed,
);
res.json({ res.json({
userId: user.id, userId: user.id,
username: user.username, username: user.username,
@@ -610,7 +604,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor( totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id, user.id,
), ),
show_donation_modal: showDonationModal,
linked: await describeDesktopLink(user.id), linked: await describeDesktopLink(user.id),
}); });
} catch (err) { } catch (err) {
@@ -619,48 +612,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
} }
}); });
/**
* @openapi
* /users/me/dismiss-donation-modal:
* post:
* summary: Permanently dismiss the donation reminder modal
* description: Marks the donation reminder modal as dismissed for the currently authenticated user so it is never shown to them again.
* tags:
* - Users
* responses:
* 200:
* description: Donation modal dismissed.
* 401:
* description: Invalid userId or user not found.
* 500:
* description: Failed to dismiss donation modal.
*/
router.post(
"/me/dismiss-donation-modal",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(401).json({ error: "Invalid userId" });
}
try {
const updated = await createCurrentUserRepository().update(userId, {
donationModalDismissed: true,
});
if (!updated) {
return res.status(401).json({ error: "User not found" });
}
return res.json({ success: true });
} catch (err) {
authLogger.error("Failed to dismiss donation modal", err);
return res
.status(500)
.json({ error: "Failed to dismiss donation modal" });
}
},
);
/** /**
* @openapi * @openapi
* /users/me/token: * /users/me/token:
+1 -1
View File
@@ -16,7 +16,7 @@ export function normalizeHostAddress(value: unknown): string {
* A client addresses a host by the numeric row id of the database it is * A client addresses a host by the numeric row id of the database it is
* displaying. When the desktop app delegates a connection to a remote sync * displaying. When the desktop app delegates a connection to a remote sync
* server, the id is resolved against *that* server's table instead, and * server, the id is resolved against *that* server's table instead, and
* autoincrement ids need not line up between the two — they diverge as soon as * autoincrement ids need not line up between the two, they diverge as soon as
* the sides accumulate inserts and deletes in a different order. * the sides accumulate inserts and deletes in a different order.
* *
* The resolved row then supplies the address, the credentials, the jump hosts * The resolved row then supplies the address, the credentials, the jump hosts
+1 -1
View File
@@ -25,7 +25,7 @@ const sshLogger = logger;
* `id` is an autoincrement belonging to whichever database produced the row. * `id` is an autoincrement belonging to whichever database produced the row.
* When the desktop app delegates a connection to a sync server, the two * When the desktop app delegates a connection to a sync server, the two
* sequences have no reason to agree, and resolving the client's id here lands * sequences have no reason to agree, and resolving the client's id here lands
* on whatever host happens to own that number — a different machine, with its * on whatever host happens to own that number, a different machine, with its
* own address, credentials and host key. `syncId` is the same string on both * own address, credentials and host key. `syncId` is the same string on both
* sides, so it names the host the user actually picked. * sides, so it names the host the user actually picked.
* *
@@ -8,10 +8,7 @@
* needs no core change. * needs no core change.
*/ */
import { import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
createCurrentHostProtocolAuthRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../../database/repositories/factory.js";
import { import {
decryptProtocolLogin, decryptProtocolLogin,
type HostProtocolAuthRecord, type HostProtocolAuthRecord,
@@ -605,17 +602,6 @@ export async function resolveRecipientProtocolLogin(
}; };
} }
/** Credential ids a recipient chose for this host, per protocol. */
export async function listRecipientOverrideIds(
hostId: number,
userId: string,
): Promise<Record<string, number>> {
return (await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
hostId,
userId,
)) as Record<string, number>;
}
/** A login in an export or on the sync wire: plaintext, keyed by protocol. */ /** A login in an export or on the sync wire: plaintext, keyed by protocol. */
export interface PortableProtocolLogin { export interface PortableProtocolLogin {
authType: string; authType: string;
+1 -1
View File
@@ -169,7 +169,7 @@ export class SSHConnectionPool {
if (available) { if (available) {
if (!this.isConnectionHealthy(available.client)) { if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available); connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again. // Fall through, may create or wait again.
} else { } else {
const waiter = queue.shift()!; const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key); if (queue.length === 0) this.waiters.delete(key);
-5
View File
@@ -61,8 +61,3 @@ export function startPluginAutoUpdate(): void {
timer = setInterval(run, CHECK_EVERY_MS); timer = setInterval(run, CHECK_EVERY_MS);
timer.unref?.(); timer.unref?.();
} }
export function stopPluginAutoUpdate(): void {
if (timer) clearInterval(timer);
timer = null;
}
+9 -18
View File
@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import {
@@ -43,29 +42,21 @@ export function getUnpackedPluginsDir(): string {
* plugin is part of the install and must not be deletable or shadowable by * plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory. * whatever happens to be in the data directory.
* *
* Resolved from this module's location rather than cwd, because the build * Resolved from this module's location rather than cwd. The build emits to
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside * dist/backend/backend/plugins/ with the bundled plugins beside dist/backend,
* dist/backend. * and a run from source reads the same dist/plugins.
*/ */
export function getBundledPluginsDir(): string { export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR; const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
if (override) return override; if (override) return override;
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
// Built: dist/backend/backend/plugins -> dist/plugins
const candidates = [ // Source (vitest, tsx): src/backend/plugins -> dist/plugins
// Built server: dist/backend/backend/plugins -> dist/plugins const fromSource = path.basename(path.resolve(here, "../..")) === "src";
// Source tree: src/backend/plugins -> plugins return fromSource
// Both are three levels up, which is why there is one entry, not two. ? path.resolve(here, "../../../dist/plugins")
path.resolve(here, "../../../plugins"), : path.resolve(here, "../../../plugins");
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
path.resolve(here, "../../../../plugins"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return candidates[0];
} }
/** The backend entry, from the manifest's own `backend` field. */ /** The backend entry, from the manifest's own `backend` field. */
-4
View File
@@ -371,10 +371,6 @@ function disposeRoute(routeKey: string, pluginId: string, path: string): void {
}); });
} }
export function getRegisteredWsRoutes(): string[] {
return [...routes.keys()];
}
/** The socket paths a plugin serves without core's login check. */ /** The socket paths a plugin serves without core's login check. */
export function getPluginPublicWsRoutes(pluginId: string): string[] { export function getPluginPublicWsRoutes(pluginId: string): string[] {
return [...routes.values()] return [...routes.values()]
+80 -6
View File
@@ -16,10 +16,12 @@ import { sendCoreAlert } from "../../notify/core-notify.js";
import { import {
getPluginRuntime, getPluginRuntime,
installPluginArtifact, installPluginArtifact,
registerLoadedPlugins,
setPluginEnabled, setPluginEnabled,
unloadPlugin, unloadPlugin,
} from "../../plugins/index.js"; } from "../../plugins/index.js";
import { getPluginsDir } from "../../plugins/paths.js"; import { getPluginsDir } from "../../plugins/paths.js";
import type { LoadedPlugin } from "../../plugins/loader.js";
import { import {
createCurrentPluginPermissionGrantRepository, createCurrentPluginPermissionGrantRepository,
createCurrentPluginRepository, createCurrentPluginRepository,
@@ -56,6 +58,7 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/** Downloads next to the installed copy, which stays until the swap. */
async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> { async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
const response = await remoteFetch( const response = await remoteFetch(
link, link,
@@ -68,7 +71,7 @@ async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
const buffer = Buffer.from(await response.arrayBuffer()); const buffer = Buffer.from(await response.arrayBuffer());
const dir = getPluginsDir(); const dir = getPluginsDir();
await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `${plugin.id}.tmxplug`); const file = path.join(dir, `${plugin.id}.tmxplug.download`);
await fs.promises.writeFile(file, buffer); await fs.promises.writeFile(file, buffer);
const signature = response.headers.get("x-termix-signature"); const signature = response.headers.get("x-termix-signature");
const sigFile = `${file}.sig`; const sigFile = `${file}.sig`;
@@ -83,6 +86,68 @@ async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
return file; return file;
} }
async function moveIfThere(from: string, to: string): Promise<void> {
await fs.promises.rm(to, { force: true });
if (fs.existsSync(from)) await fs.promises.rename(from, to);
}
/**
* Swaps a downloaded copy in for the installed one. If it fails to install,
* the old copy goes back on disk and is loaded again, so a bad download never
* leaves the desktop without the plugin.
*/
async function replaceWithDownload(
pluginId: string,
downloaded: string,
previous: LoadedPlugin | undefined,
wasEnabled: boolean,
): Promise<LoadedPlugin> {
const file = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
const backup = `${file}.previous`;
await moveIfThere(file, backup);
await moveIfThere(`${file}.sig`, `${backup}.sig`);
await moveIfThere(downloaded, file);
await moveIfThere(`${downloaded}.sig`, `${file}.sig`);
if (previous) await unloadPlugin(pluginId);
try {
const installed = await installPluginArtifact(file);
await fs.promises.rm(backup, { force: true });
await fs.promises.rm(`${backup}.sig`, { force: true });
return installed;
} catch (error) {
await moveIfThere(backup, file);
await moveIfThere(`${backup}.sig`, `${file}.sig`);
if (previous) await restorePrevious(pluginId, previous, file, wasEnabled);
throw error;
}
}
async function restorePrevious(
pluginId: string,
previous: LoadedPlugin,
file: string,
wasEnabled: boolean,
): Promise<void> {
const { loader } = getPluginRuntime();
if (loader.get(pluginId)) return;
try {
if (fs.existsSync(file)) {
await installPluginArtifact(file);
} else if (previous.source === "bundled") {
await registerLoadedPlugins([await loader.loadBundled(pluginId)]);
}
if (wasEnabled && loader.get(pluginId)) {
await setPluginEnabled(pluginId, true);
}
} catch (error) {
syncLogger.warn("Could not restore a feature after a failed update", {
operation: "sync_plugin_restore",
pluginId,
error: error instanceof Error ? error.message : String(error),
});
}
}
async function matchGrants(plugin: RemotePlugin): Promise<void> { async function matchGrants(plugin: RemotePlugin): Promise<void> {
const repository = createCurrentPluginPermissionGrantRepository(); const repository = createCurrentPluginPermissionGrantRepository();
const local = getPluginRuntime().loader.get(plugin.id); const local = getPluginRuntime().loader.get(plugin.id);
@@ -161,9 +226,14 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
// A signed update of a bundled plugin. The loader checks the signature // A signed update of a bundled plugin. The loader checks the signature
// and that the version really is newer. // and that the version really is newer.
const wasEnabled = await isEnabledLocally(plugin.id); const wasEnabled = await isEnabledLocally(plugin.id);
if (local) await unloadPlugin(plugin.id);
try { try {
local = await installPluginArtifact(await download(link, plugin)); const downloaded = await download(link, plugin);
local = await replaceWithDownload(
plugin.id,
downloaded,
local,
wasEnabled,
);
} catch (error) { } catch (error) {
await alertOlderDesktop(plugin); await alertOlderDesktop(plugin);
throw error; throw error;
@@ -180,9 +250,13 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
local.manifest.version !== plugin.version; local.manifest.version !== plugin.version;
if (outdated && (!local || local.source === "user")) { if (outdated && (!local || local.source === "user")) {
const wasEnabled = local ? await isEnabledLocally(plugin.id) : false; const wasEnabled = local ? await isEnabledLocally(plugin.id) : false;
if (local) await unloadPlugin(plugin.id); const downloaded = await download(link, plugin);
const file = await download(link, plugin); local = await replaceWithDownload(
local = await installPluginArtifact(file); plugin.id,
downloaded,
local,
wasEnabled,
);
if (wasEnabled && !plugin.enabled) { if (wasEnabled && !plugin.enabled) {
await setPluginEnabled(plugin.id, false); await setPluginEnabled(plugin.id, false);
} }
-5
View File
@@ -126,11 +126,6 @@ export async function reconcileUser(
return { changed, wire }; return { changed, wire };
} }
/** Forgets a user's snapshot so the next reconcile reads everything. */
export function invalidateSnapshot(userId: string): void {
snapshots.delete(userId);
}
const listeners = new Map<string, Set<Response>>(); const listeners = new Map<string, Set<Response>>();
let debounce: NodeJS.Timeout | null = null; let debounce: NodeJS.Timeout | null = null;
let heartbeat: NodeJS.Timeout | null = null; let heartbeat: NodeJS.Timeout | null = null;
+5
View File
@@ -87,12 +87,17 @@ router.get("/v2/info", async (_req: Request, res: Response) => {
* description: The desktop session token and the account it belongs to. * description: The desktop session token and the account it belongs to.
* 401: * 401:
* description: Not signed in. * description: Not signed in.
* 403:
* description: API keys cannot link a desktop.
*/ */
router.post( router.post(
"/v2/link", "/v2/link",
authenticateJWT, authenticateJWT,
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = userOf(req); const userId = userOf(req);
if ((req as AuthenticatedRequest).apiKeyId) {
return res.status(403).json({ error: "API keys cannot link a desktop" });
}
try { try {
const user = await createCurrentUserRepository().findById(userId); const user = await createCurrentUserRepository().findById(userId);
if (!user) return res.status(401).json({ error: "User not found" }); if (!user) return res.status(401).json({ error: "User not found" });
-6
View File
@@ -64,12 +64,6 @@ export function locate(
return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!; return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!;
} }
export function rowSyncId(entity: RegisteredSyncEntity, row: SyncRow): string {
return entity.singleton
? singletonSyncId(entity.type)
: (row.syncId as string);
}
let hashKey: Buffer | null = null; let hashKey: Buffer | null = null;
export async function getHashKey(): Promise<Buffer> { export async function getHashKey(): Promise<Buffer> {
@@ -527,6 +527,46 @@ describe("external identities", () => {
}); });
afterEach(() => disposeOidc()); afterEach(() => disposeOidc());
function addSsoUser(passwordHash = "") {
addUser({ passwordHash });
h.state.identities.push({
userId: "u1",
providerId: "3",
subject: "sub-1",
} as never);
}
it("refuses a passkey sign in for an account that only uses SSO", async () => {
addSsoUser();
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
),
).rejects.toMatchObject({ status: 403, code: "external_login_required" });
});
it("still lets an SSO account with a password use a passkey", async () => {
addSsoUser(bcrypt.hashSync(PASSWORD, 4));
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
});
it("lets an external method sign an SSO account in by user id", async () => {
addSsoUser();
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("session");
});
it("provisions the first user as admin and links the identity", async () => { it("provisions the first user as admin and links the identity", async () => {
const result = await runLogin(fakeRequest() as never, external, { const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc", methodId: "oidc",
@@ -10,8 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
* *
* The Drizzle schema said so, the repository was written against it, but the * The Drizzle schema said so, the repository was written against it, but the
* runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected * runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected
* `CREATE TABLE IF NOT EXISTS` further down was a no-op — the table already * `CREATE TABLE IF NOT EXISTS` further down was a no-op, the table already
* existed — so every fresh install got the old constraint and every user * existed, so every fresh install got the old constraint and every user
* deletion (including the OIDC account-link cleanup) failed once the account * deletion (including the OIDC account-link cleanup) failed once the account
* had logged in at least once. * had logged in at least once.
*/ */
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a * `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
* number of tables that the primary bootstrap already creates. The probe never * number of tables that the primary bootstrap already creates. The probe never
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one * threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran, and one
* of those unreachable copies had drifted away from the real definition * of those unreachable copies had drifted away from the real definition
* (`sessions` had lost `ON DELETE CASCADE`). * (`sessions` had lost `ON DELETE CASCADE`).
* *
@@ -61,7 +61,7 @@ describe("bootstrap creates the tables the removed probes covered", () => {
}); });
it("creates them on a database old enough to predate them", async () => { it("creates them on a database old enough to predate them", async () => {
// A database with users and hosts but none of the tables above — the // A database with users and hosts but none of the tables above, the
// upgrade path the deleted probes appeared to be protecting. // upgrade path the deleted probes appeared to be protecting.
const seed = new Database(":memory:"); const seed = new Database(":memory:");
seed.exec(` seed.exec(`
@@ -59,7 +59,7 @@ describe("needsExplicitPersist", () => {
/** /**
* schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the * schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the
* generated output is usable rather than merely syntactically valid — the * generated output is usable rather than merely syntactically valid, the
* repository layer's correctness rests on all three behaving the same way. * repository layer's correctness rests on all three behaving the same way.
*/ */
describe("generated schemas", () => { describe("generated schemas", () => {
@@ -133,7 +133,7 @@ describe("generated schemas", () => {
* SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and * SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and
* MySQL only ever get what the migrations declare, which comes from schema.ts. * MySQL only ever get what the migrations declare, which comes from schema.ts.
* Anything listed in one and missing from the other is an index the engines * Anything listed in one and missing from the other is an index the engines
* chosen for scale silently do without — which is how 31 of them went missing. * chosen for scale silently do without, which is how 31 of them went missing.
*/ */
it("declares every performance index in schema.ts", () => { it("declares every performance index in schema.ts", () => {
// Both are the leading column of an existing composite unique index, which // Both are the leading column of an existing composite unique index, which
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* Sharing a host used to hand the owner's SSH authentication to the recipient * Sharing a host used to hand the owner's SSH authentication to the recipient
* unconditionally. 2.6.1 put that behind `ssh_data.share_ssh_auth`, added as * unconditionally. 2.6.1 put that behind `ssh_data.share_ssh_auth`, added as
* `NOT NULL DEFAULT 0` — so every host shared before the upgrade silently * `NOT NULL DEFAULT 0`, so every host shared before the upgrade silently
* stopped supplying credentials, and recipients hit "No valid authentication * stopped supplying credentials, and recipients hit "No valid authentication
* method provided" on hosts that had worked the day before. * method provided" on hosts that had worked the day before.
* *
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* `ssh_credentials.username` became nullable when key-only credentials landed, * `ssh_credentials.username` became nullable when key-only credentials landed,
* and databases created before that are rebuilt on startup to drop the * and databases created before that are rebuilt on startup to drop the
* constraint — SQLite cannot ALTER a column. * constraint, SQLite cannot ALTER a column.
* *
* The rebuild restated the table's columns as a literal, then copied rows with * The rebuild restated the table's columns as a literal, then copied rows with
* `INSERT INTO temp SELECT <every live column>`. The table has gained columns * `INSERT INTO temp SELECT <every live column>`. The table has gained columns
@@ -17,7 +17,7 @@ describe("rowsAffected", () => {
}); });
it("reads changes from a better-sqlite3 write result", () => { it("reads changes from a better-sqlite3 write result", () => {
// The shape of a write with no .returning() attached — verified against // The shape of a write with no .returning() attached, verified against
// the driver, not assumed. // the driver, not assumed.
expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1); expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1);
expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0); expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0);
@@ -81,7 +81,7 @@ describe("supportsReturning", () => {
expect(supportsReturning("sqlite")).toBe(true); expect(supportsReturning("sqlite")).toBe(true);
expect(supportsReturning("postgres")).toBe(true); expect(supportsReturning("postgres")).toBe(true);
// No RETURNING clause in MySQL, and drizzle's mysql-core does not expose // No RETURNING clause in MySQL, and drizzle's mysql-core does not expose
// the method — call sites that need rows back must read first. // the method, call sites that need rows back must read first.
expect(supportsReturning("mysql")).toBe(false); expect(supportsReturning("mysql")).toBe(false);
}); });
}); });
@@ -11,7 +11,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
* The MySQL path cannot be exercised against a real engine here, and its whole * The MySQL path cannot be exercised against a real engine here, and its whole
* correctness is an ordering property: an update must be read AFTER the write, * correctness is an ordering property: an update must be read AFTER the write,
* a delete BEFORE it. Get either backwards and the rows describe the wrong * a delete BEFORE it. Get either backwards and the rows describe the wrong
* state — silently, with no error anywhere. * state, silently, with no error anywhere.
* *
* So the drizzle handle is stubbed and the order of calls is recorded. * So the drizzle handle is stubbed and the order of calls is recorded.
*/ */
@@ -169,7 +169,7 @@ describe("deleteReturning", () => {
await deleteReturning(context, {} as never, where); await deleteReturning(context, {} as never, where);
// Without this, a concurrent write between them makes the returned rows // Without this, a concurrent write between them makes the returned rows
// describe a state that never existed — and with a pool the second // describe a state that never existed, and with a pool the second
// statement need not even reach the same connection. // statement need not even reach the same connection.
expect(calls[0]).toBe("begin"); expect(calls[0]).toBe("begin");
expect(calls[calls.length - 1]).toBe("commit"); expect(calls[calls.length - 1]).toBe("commit");
@@ -8,8 +8,8 @@ import {
/** /**
* The settings cache lives in one process and is updated by whichever process * The settings cache lives in one process and is updated by whichever process
* wrote the setting. On SQLite that is the only process there is. On Postgres * wrote the setting. On SQLite that is the only process there is. On Postgres
* and MySQL — the reason those exist here is to let several instances share one * and MySQL, the reason those exist here is to let several instances share one
* database — a setting changed on one replica would otherwise never reach the * database, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read cannot go back to the database. * others, because the synchronous read cannot go back to the database.
* *
* Re-priming on a timer does not make settings immediately consistent. It * Re-priming on a timer does not make settings immediately consistent. It
@@ -57,7 +57,7 @@ describe("settings cache refresh", () => {
it("keeps running after a refresh throws", async () => { it("keeps running after a refresh throws", async () => {
// A transient database blip must not stop the loop, or the replica is stuck // A transient database blip must not stop the loop, or the replica is stuck
// on stale settings until it restarts — the exact failure this prevents. // on stale settings until it restarts, the exact failure this prevents.
const refresh = vi const refresh = vi
.fn() .fn()
.mockRejectedValueOnce(new Error("connection reset")) .mockRejectedValueOnce(new Error("connection reset"))
@@ -20,7 +20,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
* *
* Defaults to SQLite, so `npm test` behaves as it always has and needs no * Defaults to SQLite, so `npm test` behaves as it always has and needs no
* server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run * server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run
* the same tests against a real one — see the database-dialects CI job. * the same tests against a real one, see the database-dialects CI job.
*/ */
export function testDialect(env = process.env): DatabaseDialect { export function testDialect(env = process.env): DatabaseDialect {
const value = env.TEST_DIALECT?.trim().toLowerCase(); const value = env.TEST_DIALECT?.trim().toLowerCase();
@@ -125,7 +125,7 @@ export class TestSqliteDatabase {
* Portable write for test setup. * Portable write for test setup.
* *
* Separate from query() because better-sqlite3 refuses `.all()` on a * Separate from query() because better-sqlite3 refuses `.all()` on a
* statement that returns no rows — "This statement does not return data". * statement that returns no rows, "This statement does not return data".
*/ */
async run(statement: SQL): Promise<void> { async run(statement: SQL): Promise<void> {
const context = this.context; const context = this.context;
@@ -207,7 +207,7 @@ async function truncateAll(context: DatabaseContext): Promise<void> {
} }
/** /**
* Splits seed SQL into statements, ignoring semicolons inside string literals — * Splits seed SQL into statements, ignoring semicolons inside string literals,
* JSON payloads in the fixtures contain them. * JSON payloads in the fixtures contain them.
*/ */
function splitStatements(sql: string): string[] { function splitStatements(sql: string): string[] {
@@ -274,7 +274,7 @@ function portableSql(statement: string, dialect: DatabaseDialect): string {
if (dialect !== "mysql") return out; if (dialect !== "mysql") return out;
// Only the column list, before VALUES. A blanket replace also mangles the // Only the column list, before VALUES. A blanket replace also mangles the
// double quotes inside JSON payloads in the values — '{"slots":[]}' became // double quotes inside JSON payloads in the values, '{"slots":[]}' became
// '{`slots`:[]}', which is valid SQL and silently wrong data. // '{`slots`:[]}', which is valid SQL and silently wrong data.
const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out); const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out);
if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`"); if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`");
@@ -354,7 +354,7 @@ async function resyncAutoIncrement(
for (const table of tables) { for (const table of tables) {
// Only tables whose id is generated. A text primary key, like users.id, // Only tables whose id is generated. A text primary key, like users.id,
// has no sequence and no counter to move, and a table keyed on something // has no sequence and no counter to move, and a table keyed on something
// else entirely — host_sidebar_preferences.user_id — has no id at all. // else entirely, host_sidebar_preferences.user_id, has no id at all.
if (context.dialect === "postgres") { if (context.dialect === "postgres") {
// pg_get_serial_sequence() raises 42703 rather than returning null when // pg_get_serial_sequence() raises 42703 rather than returning null when
// the column is missing, so let information_schema decide whether there // the column is missing, so let information_schema decide whether there
@@ -408,7 +408,7 @@ async function resyncAutoIncrement(
* *
* Every test builds a fixture, and each would otherwise re-run the migrator * Every test builds a fixture, and each would otherwise re-run the migrator
* against the same shared database. drizzle's journal makes that a no-op only * against the same shared database. drizzle's journal makes that a no-op only
* when the first run finished — several fixtures racing inside one file hit * when the first run finished, several fixtures racing inside one file hit
* "table already exists" instead. * "table already exists" instead.
*/ */
const migrations = new Map<string, Promise<void>>(); const migrations = new Map<string, Promise<void>>();
@@ -436,7 +436,7 @@ let cachedSqliteSchema: string | null = null;
* The full schema, from the generated SQLite migrations rather than * The full schema, from the generated SQLite migrations rather than
* hand-written DDL in each test file. * hand-written DDL in each test file.
* *
* Tests used to declare a cut-down version of every table they touched — a * Tests used to declare a cut-down version of every table they touched, a
* `users` with five columns where the real one has thirty. That drifts from the * `users` with five columns where the real one has thirty. That drifts from the
* schema silently, and it is the reason the same tests could not be pointed at * schema silently, and it is the reason the same tests could not be pointed at
* another engine. * another engine.
@@ -1,37 +0,0 @@
import { describe, it, expect } from "vitest";
import { shouldShowDonationModal } from "../../../database/routes/donation-modal-utils.js";
describe("shouldShowDonationModal", () => {
const now = Date.parse("2026-07-17T00:00:00.000Z");
it("returns false when the user already dismissed it", () => {
const registeredAt = new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, true, now)).toBe(false);
});
it("returns false before the 30 day mark", () => {
const registeredAt = new Date(now - 29 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(false);
});
it("returns true at exactly 30 days", () => {
const registeredAt = new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
it("returns true well past the 30 day mark", () => {
const registeredAt = new Date(
now - 200 * 24 * 60 * 60 * 1000,
).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
it("returns false for an unparseable registeredAt", () => {
expect(shouldShowDonationModal("not-a-date", false, now)).toBe(false);
});
it("treats a backdated registeredAt for pre-existing users as immediately eligible", () => {
const registeredAt = new Date(now - 31 * 24 * 60 * 60 * 1000).toISOString();
expect(shouldShowDonationModal(registeredAt, false, now)).toBe(true);
});
});
@@ -19,7 +19,7 @@ import {
* *
* The resolved row supplies the address, the credentials, the jump hosts and * The resolved row supplies the address, the credentials, the jump hosts and
* the stored host key, so the session opened on whichever machine owned that * the stored host key, so the session opened on whichever machine owned that
* id on the server — the host list stayed correct the whole time, and nothing * id on the server, the host list stayed correct the whole time, and nothing
* announced the substitution. * announced the substitution.
*/ */
describe("hostAddressMismatch", () => { describe("hostAddressMismatch", () => {
@@ -26,7 +26,7 @@ vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
/** /**
* A numeric host id belongs to whichever database produced it. Resolving the * A numeric host id belongs to whichever database produced it. Resolving the
* desktop app's id against a sync server's table lands on whatever host owns * desktop app's id against a sync server's table lands on whatever host owns
* that number there — a different machine, with its own address, credentials * that number there, a different machine, with its own address, credentials
* and host key. `sync_id` is the same string on both sides. * and host key. `sync_id` is the same string on both sides.
*/ */
describe("resolveHostBySyncId", () => { describe("resolveHostBySyncId", () => {
@@ -6,6 +6,7 @@ import {
isValidPackageName, isValidPackageName,
buildSudoCommand, buildSudoCommand,
parseUpgradable, parseUpgradable,
execCommand,
} from "@termix-ssh/plugin-sdk/host-commands"; } from "@termix-ssh/plugin-sdk/host-commands";
describe("parsePlatformProbe", () => { describe("parsePlatformProbe", () => {
@@ -114,3 +115,13 @@ describe("parseUpgradable", () => {
expect(parseUpgradable(null, "anything")).toEqual([]); expect(parseUpgradable(null, "anything")).toEqual([]);
}); });
}); });
describe("execCommand", () => {
it("leaves the command out of the timeout error", async () => {
const client = { exec: () => {} } as never;
const command = buildSudoCommand("apt update", "hunter2");
await expect(execCommand(client, command, 5)).rejects.toThrow(
/^Command timeout after 5ms$/,
);
});
});
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getBundledPluginsDir } from "../../plugins/paths.js";
describe("getBundledPluginsDir", () => {
const saved = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
afterEach(() => {
if (saved === undefined) delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
else process.env.TERMIX_BUNDLED_PLUGINS_DIR = saved;
});
it("reads dist/plugins when run from source", () => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
expect(getBundledPluginsDir()).toBe(
path.resolve(process.cwd(), "dist", "plugins"),
);
});
it("honors the override", () => {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/somewhere/else";
expect(getBundledPluginsDir()).toBe("/somewhere/else");
});
});
+5 -5
View File
@@ -184,9 +184,9 @@ describe("createTableSql", () => {
\`user_id\` varchar(255) NOT NULL, \`user_id\` varchar(255) NOT NULL,
\`host_id\` int NOT NULL, \`host_id\` int NOT NULL,
\`title\` varchar(200) NOT NULL, \`title\` varchar(200) NOT NULL,
\`body\` text, \`body\` longtext,
\`meta\` text, \`meta\` longtext,
\`secret\` text, \`secret\` longtext,
\`pinned\` boolean DEFAULT false, \`pinned\` boolean DEFAULT false,
\`position\` int DEFAULT 0, \`position\` int DEFAULT 0,
\`size\` bigint, \`size\` bigint,
@@ -286,9 +286,9 @@ describe("adoptTableSql", () => {
expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;"); expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;");
}); });
it("writes a TEXT default as an expression on MySQL", () => { it("writes a long text default as an expression on MySQL", () => {
expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain( expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain(
"`name` text NOT NULL DEFAULT ('x')", "`name` longtext NOT NULL DEFAULT ('x')",
); );
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain( expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
"\"name\" text NOT NULL DEFAULT 'x'", "\"name\" text NOT NULL DEFAULT 'x'",
@@ -0,0 +1,157 @@
import fs from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
dir: "",
loaded: new Map<string, Record<string, unknown>>(),
enabled: new Set<string>(),
install: vi.fn(),
fetch: vi.fn(),
loadBundled: vi.fn(),
}));
vi.mock("../../../utils/logger.js", () => ({
syncLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../notify/core-notify.js", () => ({
sendCoreAlert: vi.fn(async () => {}),
}));
vi.mock("../../../plugins/paths.js", () => ({
getPluginsDir: () => state.dir,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => ({
state: state.enabled.has(id) ? "enabled" : "disabled",
}),
}),
createCurrentPluginPermissionGrantRepository: () => ({}),
}));
vi.mock("../../../sync/client/http.js", () => ({
remoteFetch: state.fetch,
remoteJson: vi.fn(),
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
get: (id: string) => state.loaded.get(id),
list: () => [...state.loaded.values()],
loadBundled: state.loadBundled,
},
}),
installPluginArtifact: state.install,
registerLoadedPlugins: vi.fn(async () => {}),
setPluginEnabled: vi.fn(async (id: string, on: boolean) => {
if (on) state.enabled.add(id);
else state.enabled.delete(id);
}),
unloadPlugin: vi.fn(async (id: string) => {
state.loaded.delete(id);
}),
}));
const { mirrorPlugins } = await import("../../../sync/client/plugins.js");
const { remoteJson } = await import("../../../sync/client/http.js");
const bundled = {
id: "ssh-terminal",
source: "bundled",
state: "active",
manifest: { id: "ssh-terminal", version: "1.0.0", capabilities: [] },
};
function serverHas(version: string) {
vi.mocked(remoteJson).mockResolvedValue({
plugins: [
{
id: "ssh-terminal",
name: "SSH Terminal",
version,
source: "bundled",
artifact: true,
enabled: true,
active: true,
desktop: "mirror",
syncEntities: [],
granted: [],
},
],
});
}
beforeEach(() => {
state.dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-mirror-"));
state.loaded = new Map([["ssh-terminal", { ...bundled }]]);
state.enabled = new Set(["ssh-terminal"]);
state.install.mockReset();
state.fetch.mockReset();
state.loadBundled.mockReset();
state.loadBundled.mockImplementation(async (id: string) => {
const plugin = { ...bundled, id };
state.loaded.set(id, plugin);
return plugin;
});
});
afterEach(() => {
fs.rmSync(state.dir, { recursive: true, force: true });
});
describe("mirrorPlugins", () => {
it("keeps the installed copy when the download fails", async () => {
serverHas("1.1.0");
state.fetch.mockResolvedValue({ ok: false, status: 502 });
await mirrorPlugins({} as never);
expect(state.loaded.has("ssh-terminal")).toBe(true);
expect(state.enabled.has("ssh-terminal")).toBe(true);
expect(state.install).not.toHaveBeenCalled();
});
it("puts the shipped copy back when the new one fails to install", async () => {
serverHas("1.1.0");
state.fetch.mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(4),
headers: new Headers(),
});
state.install.mockRejectedValue(new Error("signature check failed"));
await mirrorPlugins({} as never);
expect(state.loadBundled).toHaveBeenCalledWith("ssh-terminal");
expect(state.loaded.has("ssh-terminal")).toBe(true);
expect(state.enabled.has("ssh-terminal")).toBe(true);
expect(fs.existsSync(path.join(state.dir, "ssh-terminal.tmxplug"))).toBe(
false,
);
});
it("restores an earlier update file when a newer one fails", async () => {
serverHas("1.2.0");
const file = path.join(state.dir, "ssh-terminal.tmxplug");
fs.writeFileSync(file, "old");
state.fetch.mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(4),
headers: new Headers(),
});
state.install
.mockRejectedValueOnce(new Error("bad archive"))
.mockImplementationOnce(async () => {
const plugin = { ...bundled };
state.loaded.set("ssh-terminal", plugin);
return plugin;
});
await mirrorPlugins({} as never);
expect(fs.readFileSync(file, "utf8")).toBe("old");
expect(state.install).toHaveBeenCalledTimes(2);
expect(state.loaded.has("ssh-terminal")).toBe(true);
expect(state.enabled.has("ssh-terminal")).toBe(true);
});
});
@@ -123,7 +123,7 @@ describe("forwardAuditEntry", () => {
); );
}); });
it("swallows transport errors — a dead SIEM must not break auditing", async () => { it("swallows transport errors, a dead SIEM must not break auditing", async () => {
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
@@ -140,7 +140,7 @@ describe("forwardAuditEntry", () => {
await forwardAuditEntry(ENTRY, NOW, env); await forwardAuditEntry(ENTRY, NOW, env);
} }
// 5 per-entry warnings, then one suppression notice — not 8. // 5 per-entry warnings, then one suppression notice, not 8.
const perEntry = logs.warn.mock.calls.filter( const perEntry = logs.warn.mock.calls.filter(
(call) => call[0] === "Failed to forward audit entry", (call) => call[0] === "Failed to forward audit entry",
); );
@@ -19,7 +19,10 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }), createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({ createCurrentSessionRepository: () => ({
findById: async (id: string) => ({ id }), findById: async (id: string) =>
id === "expired"
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
: { id },
}), }),
createCurrentUserRepository: () => ({}), createCurrentUserRepository: () => ({}),
createCurrentApiKeyRepository: () => ({}), createCurrentApiKeyRepository: () => ({}),
@@ -151,6 +154,15 @@ describe("AuthManager token handling", () => {
expect(await authManager.verifyJWTToken(token)).toBeNull(); expect(await authManager.verifyJWTToken(token)).toBeNull();
}); });
it("rejects a token whose session has expired", async () => {
const token = jwt.sign(
{ userId: "user-1", sessionId: "expired" },
jwtSecret,
{ expiresIn: "1h" },
);
expect(await authManager.verifyJWTToken(token)).toBeNull();
});
it("still accepts the sessionless pending second-factor token", async () => { it("still accepts the sessionless pending second-factor token", async () => {
const token = jwt.sign({ userId: "user-1", pendingTOTP: true }, jwtSecret, { const token = jwt.sign({ userId: "user-1", pendingTOTP: true }, jwtSecret, {
expiresIn: "10m", expiresIn: "10m",
@@ -22,6 +22,7 @@ describe("isBlockedAddress", () => {
expect(isBlockedAddress("192.168.1.1")).toBe(true); expect(isBlockedAddress("192.168.1.1")).toBe(true);
expect(isBlockedAddress("127.0.0.1")).toBe(true); expect(isBlockedAddress("127.0.0.1")).toBe(true);
expect(isBlockedAddress("169.254.1.1")).toBe(true); expect(isBlockedAddress("169.254.1.1")).toBe(true);
expect(isBlockedAddress("192.0.0.170")).toBe(true);
expect(isBlockedAddress("100.64.0.1")).toBe(true); expect(isBlockedAddress("100.64.0.1")).toBe(true);
}); });
+1 -1
View File
@@ -22,7 +22,7 @@ function readForwardSetting(key: string): string | null {
/** /**
* How many consecutive failures before the forwarder stops complaining on every * How many consecutive failures before the forwarder stops complaining on every
* entry. It keeps trying — this only throttles the log noise, and it reports * entry. It keeps trying, this only throttles the log noise, and it reports
* again once delivery recovers. * again once delivery recovers.
*/ */
const QUIET_AFTER_FAILURES = 5; const QUIET_AFTER_FAILURES = 5;
+4
View File
@@ -414,6 +414,10 @@ class AuthManager {
return null; return null;
} }
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
return null;
}
await this.migrateDataKeyFromPayload(payload); await this.migrateDataKeyFromPayload(payload);
} catch (dbError) { } catch (dbError) {
databaseLogger.error( databaseLogger.error(
+2 -2
View File
@@ -6,8 +6,8 @@ import { type Request, type RequestHandler, type Response } from "express";
* *
* The default is 1KB; this is slightly higher because almost every response * The default is 1KB; this is slightly higher because almost every response
* under a few KB here is a small status or preference object where the CPU and * under a few KB here is a small status or preference object where the CPU and
* the extra headers are not worth it. The payloads that matter — host lists, * the extra headers are not worth it. The payloads that matter, host lists,
* audit pages, fleet inventories — are orders of magnitude above this. * audit pages, fleet inventories, are orders of magnitude above this.
*/ */
const MIN_RESPONSE_BYTES = 2048; const MIN_RESPONSE_BYTES = 2048;
@@ -47,7 +47,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{
// Nothing to clean up on an engine this application has never run on. A // Nothing to clean up on an engine this application has never run on. A
// Postgres or MySQL database is created by the drizzle migrations, which have // Postgres or MySQL database is created by the drizzle migrations, which have
// never emitted these legacy columns, so there is nothing to drop — and the // never emitted these legacy columns, so there is nothing to drop, and the
// check itself needs a synchronous PRAGMA that only SQLite offers. // check itself needs a synchronous PRAGMA that only SQLite offers.
if (!needsExplicitPersist(resolveDatabaseDialect())) return result; if (!needsExplicitPersist(resolveDatabaseDialect())) return result;
+1 -1
View File
@@ -201,7 +201,7 @@ class DataCrypto {
migratedFieldsCount: number; migratedFieldsCount: number;
}> { }> {
// Only a database that predates field encryption has plaintext to migrate, // Only a database that predates field encryption has plaintext to migrate,
// and only SQLite deployments can predate it — Postgres and MySQL support // and only SQLite deployments can predate it, Postgres and MySQL support
// arrived after. The store also needs synchronous queries no other driver // arrived after. The store also needs synchronous queries no other driver
// has, so this would throw rather than find nothing to do. // has, so this would throw rather than find nothing to do.
if (!needsExplicitPersist(resolveDatabaseDialect())) { if (!needsExplicitPersist(resolveDatabaseDialect())) {
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more