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
build
.next
.nuxt
coverage
release
.env.local
.env.development.local
.env.test.local
.env.production.local
# Local data, secrets and editor state
db
old_db
ssl
uploads
plugins
.env
.env.*
.mcp.json
.claude
CLAUDE.md
termix-plugin-signing.key
*.tmxplug.sig
.vscode
.idea
@@ -29,63 +38,31 @@ Thumbs.db
.git
.gitignore
.husky
README.md
CONTRIBUTING.md
CHANGELOG.md
LICENSE
uploads/
electron/
electron-builder.json
Casks
packaging
*.log
*.tmp
*.temp
logs
*.log
pids
*.pid
*.seed
*.pid.lock
coverage
.nyc_output
jspm_packages/
.npm
.eslintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
*.tgz
.yarn-integrity
.cache
.parcel-cache
.next
.nuxt
.vuepress/dist
.serverless
.fusebox/
.dynamodb/
.tern-port
*.tgz
.node_repl_history
+13 -6
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version to build (e.g., 1.8.0)"
description: "Version to build (e.g., 26.10.0)"
required: true
build_type:
description: "Build type"
@@ -22,11 +22,11 @@ on:
workflow_call:
inputs:
version:
description: "Version to build (e.g., 1.8.0)"
description: "Version to build (e.g., 26.10.0)"
required: true
type: string
build_type:
description: "Build type (Development or Production)"
description: "Build type (Development, Beta or Production)"
required: true
type: string
dry_run:
@@ -52,7 +52,14 @@ jobs:
fetch-depth: 1
- 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
uses: docker/setup-qemu-action@v4
@@ -74,7 +81,7 @@ jobs:
ALL_TAGS=()
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("release-$VERSION" "$VERSION" "latest")
TAGS+=("$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta" "$VERSION")
else
@@ -120,7 +127,7 @@ jobs:
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
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' }}
- name: Login to Docker Hub as termixssh
+23 -7
View File
@@ -117,6 +117,11 @@ jobs:
- name: Install dependencies
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
id: package-version
run: |
@@ -220,6 +225,11 @@ jobs:
npm install --force @rollup/rollup-linux-arm64-gnu
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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -429,6 +439,11 @@ jobs:
npm install dmg-license
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
id: check_certs
run: |
@@ -468,14 +483,15 @@ jobs:
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
if: steps.check_certs.outputs.has_certs == 'true'
id: mas_build
if: steps.check_certs.outputs.has_certs == 'true' && inputs.version_override == ''
env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=4096
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
BUILD_VERSION="${{ github.run_number }}"
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
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
if [ "${{ steps.mas_build.outcome }}" != "success" ]; then
npm run build
fi
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
@@ -555,7 +571,7 @@ jobs:
echo "path=$MAS_PKG" >> "$GITHUB_OUTPUT"
- 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
with:
name: termix_macos_universal_mas
@@ -722,7 +738,7 @@ jobs:
choco pack termix-ssh.nuspec
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
@@ -952,8 +968,8 @@ jobs:
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/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-submission/Casks/t/termix.rb
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-submission/Casks/t/termix.rb
- name: Verify Cask syntax
run: |
+19 -2
View File
@@ -4,14 +4,31 @@
### 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
- 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
- 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
- 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. |
| [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 />
@@ -109,14 +109,14 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
</tr>
<tr>
<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>
<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>
</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>
</tr>
<tr>
@@ -149,6 +149,10 @@ services:
- termix-data:/app/data
environment:
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:
- guacd
networks:
@@ -158,8 +162,8 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
ports:
- "4822:4822"
volumes:
- termix-data:/termix-data
networks:
- termix-net
+2 -2
View File
@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"css": "src/ui/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
@@ -15,7 +15,7 @@
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"ui": "@/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
+4 -4
View File
@@ -1,7 +1,7 @@
# Stage 1: Install dependencies
# 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.
FROM --platform=$BUILDPLATFORM node:26-slim AS deps
FROM --platform=$BUILDPLATFORM node:24-slim AS deps
WORKDIR /app
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
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:26-slim AS opkssh-downloader
FROM node:24-slim AS opkssh-downloader
ARG TARGETARCH
ARG OPKSSH_VERSION=v0.16.0
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
@@ -58,7 +58,7 @@ RUN case "$TARGETARCH" in \
echo -n "$OPKSSH_VERSION" > version.txt
# Stage 5: Production dependencies only
FROM node:26-slim AS production-deps
FROM node:24-slim AS production-deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -81,7 +81,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force
# Stage 6: Final optimized image
FROM node:26-slim
FROM node:24-slim
WORKDIR /app
# The release the opkssh-downloader stage baked in, so the plugin trusts that
+60 -46
View File
@@ -4,152 +4,162 @@
"id": "acme-ssl",
"source": "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",
"source": "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",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "93274529eb3445db9e1dddc630b7608b15250eb179bb7919a43c3e6707fa20b5",
"sha256": "97e7be293ace897cd45615a8764e8395cf613546b86de82601ab49669dc36591",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "automations",
"source": "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",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "d1d1dd441b51a97231d5da94e38a12b2d2b25db6da5e2b13b4a4b9486881ed2d",
"sha256": "ca9b8cf573d27a71adb544fd6ff4d19278349f10fdca98f9cc63d7fec63bc441",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "file-manager",
"source": "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": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "fleets",
"source": "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",
"source": "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",
"source": "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": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "ldap",
"source": "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",
"source": "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",
"source": "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",
"source": "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",
"source": "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": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "secret-sources",
"source": "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",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "c8c3ac8c916253c928195005773e558ee87555fb47b7b56f3e43c0df0fb852c4",
"sha256": "a645144a11e591f9668f2e7b23b8423274006180862be9c5f3e59df361c27e5e",
"onboarding": {
"recommended": "desktop"
"recommended": "desktop",
"consent": false
}
},
{
"id": "session-recording",
"source": "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",
"source": "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",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "0239e63df6d33cb38a1212935dae972402b174a7eab8b351084dab45196f1fe5",
"sha256": "a434dd442c54fd7a124f4ebc9579a0af1aa4b07b1a90e3875f8ae72c35ced410",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "ssh-terminal",
"source": "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": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
"sha256": "88d7fcf8eb3b0916d69bcc7349a58d98e37c493bfadc8b90f81b8aa5c845abc5",
"sha256": "2045d1da03dcd47089cb6566a7f25fe021326cb556cbc36b01563a2c234d1230",
"onboarding": {
"recommended": false,
"consent": false,
"enabledByEnv": ["OIDC_CLIENT_ID"]
}
},
@@ -157,19 +167,19 @@
"id": "step-ca",
"source": "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",
"source": "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",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "9f973902320a01eed5fead9b31288face503a790bf68a2826176e48fb663c30c",
"sha256": "103e2ef2d52993bf15fdf2cda0addfa4aeba5817b4340f28e3fdea634aba6af0",
"onboarding": {
"recommended": true,
"consent": true
@@ -179,72 +189,76 @@
"id": "termix-identity",
"source": "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",
"source": "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",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "c0b6b43d778dfd47af7bdbca099667bf46539de62936c3291947ac2425c02de9",
"sha256": "aed42422ccc38c661f1aac2f7409f7872acf271552d6623b79bbcc43813d4fca",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "82df362cfc820437db8c56ebd1e3140d7fcef677d98a5439d7e868486cc21ad4",
"sha256": "215eae643d61d032bb6190bab7ed42140fd91c2165e2c4de079ff541e1522763",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "vault",
"source": "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",
"source": "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",
"source": "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",
"source": "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",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "fba89362c503813a12a7b73c9aed6994a10a1d010db8e1237a1f901131eb8713",
"sha256": "50994ae148b2a5e68cf924b922eb2fb22cf6215905049dd5b61a16e30a376a50",
"onboarding": {
"recommended": true
"recommended": true,
"consent": false
}
},
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
"sha256": "31d4d2ba02b59f2c2189c0400eb22d07aa8ba72728cd73a4512995581cf3afcd",
"sha256": "23c6d2b0845b0e854dc6dde0ab4b60b9a58af020099fd4f6fcc1463a48d9ee62",
"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,
"tag": "0069_plugin_channel",
"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,
"tag": "0069_plugin_channel",
"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,
"tag": "0064_plugin_channel",
"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",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution",
"minimumSystemVersion": "10.15",
"minimumSystemVersion": "12.0",
"mergeASARs": false,
"singleArchFiles": "**/*.{node,bare,dylib}",
"x64ArchFiles": "**/{*.{node,bare,dylib},node-pty/prebuilds/*/spawn-helper}"
+2 -2
View File
@@ -1041,7 +1041,7 @@ function getBackendPaths() {
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
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
const unpackedRoot = getUnpackedAppRoot(appRoot);
@@ -1303,7 +1303,7 @@ function createTray() {
try {
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.
const publicRoot = isDev
? 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
// 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.
//
// 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
// 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']",
message:
"MySQL has no ON CONFLICT. Use upsert() from ./returning.js.",
},
{
// 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
// "NaN" and fails an integer column. Three call sites did exactly
// this and only broke on Postgres.
-29
View File
@@ -20,35 +20,6 @@
<link rel="manifest" href="manifest.json" />
<title>Termix</title>
<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 {
scrollbar-width: thin;
scrollbar-color: #4a4a4a transparent;
+13 -1
View File
@@ -15015,7 +15015,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.1.0",
"version": "1.0.5",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -15054,9 +15054,21 @@
"drizzle-orm": {
"optional": true
},
"i18next": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
},
"ssh2": {
"optional": true
},
+11
View File
@@ -1,5 +1,16 @@
# 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
### Fixed
+1
View File
@@ -28,6 +28,7 @@ npm install --save-dev @termix-ssh/plugin-sdk
```bash
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 validate # check manifest.json and the files it names
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 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
* 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")));
}
/**
* 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) {
const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide");
@@ -17,7 +17,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
@@ -53,7 +53,7 @@ async function loadDefinitions(cwd) {
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;
if (!Array.isArray(tables)) {
throw new Error(
@@ -218,6 +218,11 @@ async function validateMigrations(cwd, pluginId) {
const present = DIALECTS.filter((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();
for (const dialect of present) {
+13 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
"license": "Apache-2.0",
"repository": {
@@ -148,6 +148,18 @@
},
"@testing-library/react": {
"optional": true
},
"i18next": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-i18next": {
"optional": true
},
"sonner": {
"optional": true
}
},
"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
* 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(
markdown: string,
@@ -206,7 +208,16 @@ export function validateChangelog(
return problems;
}
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(
`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 = () =>
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" });
/** 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
// text/json column, so in practice this only catches a primary key.
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";
}
}
/** 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([
"text",
"json",
+1 -1
View File
@@ -32,7 +32,7 @@ export function execCommand(
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
reject(new Error(`Command timeout after ${timeoutMs}ms`));
}
}, 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.
*
* 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
* into driver values — and the sqlite definitions supply both correctly for
* object: the identifiers to interpolate, and the encoders that turn JS values
* into driver values. The sqlite definitions supply both correctly for
* every engine, which is why all 44 repositories import schema.ts directly:
*
* - text and integer encode as themselves everywhere
@@ -17,15 +17,15 @@
* which is true for JS `true` as well as for 1
* - real is a plain number on all three
*
* What genuinely differs between the dialects is DDL — column types, key
* lengths, autoincrement syntax — and DDL is exactly what these files exist to
* What genuinely differs between the dialects is DDL (column types, key
* lengths, autoincrement syntax), and DDL is exactly what these files exist to
* generate. See scripts/verify-dialects.mjs, which asserts the round-trips
* above against real servers rather than trusting this comment.
*
* 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
* 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.
*
* 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
// 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
// expression default works on any type, and an expression is written
// parenthesised. MariaDB accepts the bare form, which is why this only
@@ -272,13 +272,13 @@ function transform(source, 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.
// Edit the sqlite schema and re-run \`node scripts/generate-dialect-schema.cjs\`.
// 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.
`;
}
+1 -1
View File
@@ -137,7 +137,7 @@ describe("mysql output", () => {
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('text("name")');
});
+2 -2
View File
@@ -46,7 +46,7 @@ await sharp(svgBuffer)
.toFile(join(publicDir, "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...");
const icoSizes = [16, 32, 48];
const icoBuffers = await Promise.all(
@@ -55,7 +55,7 @@ const icoBuffers = await Promise.all(
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
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...");
const winSizes = [16, 32, 48, 64, 128, 256];
const winBuffers = await Promise.all(
+6 -6
View File
@@ -28,7 +28,7 @@ function fail(message) {
function buildTable(version, mobileVersion, mobileTag) {
const tag = `v${version}`;
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 linux = (file) => `${base}/${file}`;
@@ -37,11 +37,11 @@ function buildTable(version, mobileVersion, mobileTag) {
return [
`| 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")}) | — | — |`,
`| **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")}) | — | — | — |`,
`| **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) | — | — |`,
`| **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) |`,
`| **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")}) | - | - | - | - |`,
`| **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");
}
+1 -1
View File
@@ -30,7 +30,7 @@ describe("buildBody", () => {
expect(body).not.toContain("A bug");
expect(body).toContain("/releases/download/v26.10.0/termix_");
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", () => {
expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([
'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.
*
* The unit tests only ever see SQLite, so the parts of this codebase that
* differ per engine — the RETURNING replacements, the read-then-write
* transactions, the value encoders — have no coverage there at all. This is
* differ per engine (the RETURNING replacements, the read-then-write
* 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
* rather than a test.
*
@@ -12,7 +12,7 @@
* npm run verify:dialect -- mysql://user:pass@host:3306/db
*
* 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.
*/
@@ -40,7 +40,7 @@ const { drizzle } = await import(
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 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);
// 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" });
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"];
/**
* 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(
identity: VerifiedIdentity,
deviceType: DeviceType,
@@ -253,6 +274,7 @@ export async function runLogin(
): Promise<LoginResult> {
const deviceType = parseUserAgent(req).type;
const user = await resolveUser(identity, deviceType);
assertMethodAllowedFor(user, identity, context.methodId);
await unlockUser(user, identity, deviceType);
if ("password" in identity && identity.password) {
+3 -3
View File
@@ -268,7 +268,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* localVersion:
* type: string
* description: Version of the running instance. Use this field for client compatibility checks.
* example: 2.7.1
* example: 26.10.0
* status:
* 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.
@@ -280,12 +280,12 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* remoteVersion:
* type: string
* description: Newest release version on the channel. Present only when the update check succeeds.
* example: 2.8.0
* example: 26.10.0
* version:
* type: string
* 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.
* example: 2.8.0
* example: 26.10.0
* latest_release:
* type: object
* 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.
*
* 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
* 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
// (`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.
if (dialect === "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
// 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");
return drizzle({
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
* 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.
*/
export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
+4 -13
View File
@@ -242,8 +242,7 @@ async function initializeCompleteDatabase(): Promise<void> {
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
@@ -970,9 +969,7 @@ const migrateSchema = () => {
// CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
addColumnIfNotExists("users", "registered_at", "TEXT");
if (!hadRegisteredAtColumn) {
// Pre-existing users are backdated past the 30 day mark so they see the
// donation modal immediately on upgrade instead of waiting a fresh
// 30 days as if they had just registered.
// Pre-existing users get a date in the past, since their real one is unknown.
try {
sqlite.exec(
`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("ssh_data", "name", "TEXT");
@@ -1103,7 +1094,7 @@ const migrateSchema = () => {
// Derive the replacement table from the live definition instead of
// restating it here. The table keeps gaining columns (cert_public_key,
// 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.
const createSql = sqlite
.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") {
throw new Error(
`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(
+1 -1
View File
@@ -25,7 +25,7 @@ export function migrationsFolder(
* Brings a remote database up to the current schema.
*
* 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.
*/
export async function runRemoteMigrations(
@@ -77,6 +77,7 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
"identifier_path",
"name_path",
"scopes",
"donation_modal_dismissed",
],
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
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.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// 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.
import {
@@ -27,9 +27,6 @@ export const users = mysqlTable("users", {
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
});
export const settings = mysqlTable("settings", {
@@ -542,7 +539,7 @@ export const sharedHostSecrets = mysqlTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// 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) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -592,7 +589,7 @@ export const userRoles = mysqlTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// 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.
// 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
* 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.
*/
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.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// 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.
import {
@@ -27,9 +27,6 @@ export const users = pgTable("users", {
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
});
export const settings = pgTable("settings", {
@@ -542,7 +539,7 @@ export const sharedHostSecrets = pgTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// 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) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -592,7 +589,7 @@ export const userRoles = pgTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// 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.
// 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
* 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.
*/
export const pluginInstallCounts = pgTable(
+3 -8
View File
@@ -15,11 +15,6 @@ export const users = sqliteTable("users", {
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: integer("donation_modal_dismissed", {
mode: "boolean",
})
.notNull()
.default(false),
});
export const settings = sqliteTable("settings", {
@@ -534,7 +529,7 @@ export const sharedHostSecrets = sqliteTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// 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) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -584,7 +579,7 @@ export const userRoles = sqliteTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// 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.
// 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
* 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.
*/
export const pluginInstallCounts = sqliteTable(
@@ -74,7 +74,7 @@ export class AuditLogRepository {
/**
* 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
* 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.
*/
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
* 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.
*
* 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
* 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.
*/
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";
// 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
// three, which silently made the dialect branches unreachable to the checker.
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
* 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
* 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
* 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.
*/
export interface DatabaseContext {
+5 -5
View File
@@ -49,7 +49,7 @@ import { UserRepository } from "./user-repository.js";
* write.
*
* 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 {
return {
@@ -64,7 +64,7 @@ export function createCurrentRepositoryContext(): DatabaseContext {
* 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
* 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(
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
* must not use this: they take a DatabaseContext, which is drizzle-only.
* 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
* 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
* data — a setting changed on one replica would otherwise never reach 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
* 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
@@ -31,7 +31,7 @@ export class HostFolderRepository {
now = new Date().toISOString(),
): Promise<RenameFolderResult> {
// 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 oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `;
@@ -4,18 +4,18 @@ import type { DatabaseDialect } from "../db/dialect.js";
* Reading the outcome of a write without depending on RETURNING.
*
* 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
* TypeError rather than a bad query. 175 call sites here read a write's result,
* so the difference has to be absorbed somewhere.
*
* 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
* `.returning()` and reading the driver's own count is both portable and one
* 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
* call sites are handled individually rather than behind a helper that hides
* an extra round trip.
@@ -33,7 +33,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
* | node-postgres | `{ rowCount, rows, command }` |
* | 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.
*/
interface WriteHeader {
@@ -47,7 +47,7 @@ interface WriteHeader {
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.
* 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.
*
* 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
* 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
* 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.
*
* 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()
* 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.
*/
export function countValue(value: unknown): number {
+12 -12
View File
@@ -11,15 +11,15 @@ import {
* Writes that need the affected rows back.
*
* `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.
*
* 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:
*
* - **insert** — write, then read the row back by its key.
* - **update** — write, then read. Reading first would return the old values.
* - **delete** — read, then write. Reading after would return nothing.
* - **insert**: write, then read the row back by its key.
* - **update**: write, then read. Reading first would return the old values.
* - **delete**: read, then write. Reading after would return nothing.
*
* 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
@@ -29,7 +29,7 @@ import {
* ## The trap, and why it cannot bite silently
*
* 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
* 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
@@ -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.
*/
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);
// 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
// readable back is exactly that case, so make it loud instead.
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
* 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
* 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.
*
* 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.
*
* `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.
*/
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
* `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
* calling it is a TypeError rather than a rejected query.
*
* 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
* 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.
*/
export async function upsert<T extends SQLiteTable>(
@@ -1,7 +1,7 @@
/**
* 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
* 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
@@ -35,7 +35,7 @@ export function primeSettingsCache(
/**
* 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
* 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.
*
* The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright — "Transaction function
* cannot return a promise" — so a single body cannot serve both. It fails
* rejects an async transaction callback outright, "Transaction function
* 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.
*/
private async createCheckingIfFirst(
@@ -17,7 +17,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
* /credential-sidebar/preferences:
* get:
* 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:
* - Credential Sidebar
* 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 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
* /open-tabs:
@@ -20,14 +28,6 @@ const authenticateJWT = authManager.createAuthMiddleware();
* 200:
* 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) => {
const userId = (req as AuthenticatedRequest).userId;
try {
+5 -2
View File
@@ -17,7 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.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 {
pluginDocsUrl,
@@ -2262,7 +2262,10 @@ router.post(
"/upload",
authenticateJWT,
requireManagePlugins,
express.raw({ type: "application/octet-stream", limit: "50mb" }),
express.raw({
type: "application/octet-stream",
limit: MAX_UPLOAD_BYTES,
}),
async (req: Request, res: Response) => {
if (await refuseOnLinkedDesktop(res)) return;
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
-49
View File
@@ -20,7 +20,6 @@ import {
isNativeTokenExportRequest,
resolveDesktopAutoSessionUser,
} from "./desktop-auto-session.js";
import { shouldShowDonationModal } from "./donation-modal-utils.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { registerUserApiKeyRoutes } from "./user-api-key-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 isDualAuth = hasPassword && isExternalAccount(user);
const showDonationModal = shouldShowDonationModal(
user.registeredAt,
!!user.donationModalDismissed,
);
res.json({
userId: user.id,
username: user.username,
@@ -610,7 +604,6 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
show_donation_modal: showDonationModal,
linked: await describeDesktopLink(user.id),
});
} 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
* /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
* displaying. When the desktop app delegates a connection to a remote sync
* 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 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.
* 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
* 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
* sides, so it names the host the user actually picked.
*
@@ -8,10 +8,7 @@
* needs no core change.
*/
import {
createCurrentHostProtocolAuthRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../../database/repositories/factory.js";
import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
import {
decryptProtocolLogin,
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. */
export interface PortableProtocolLogin {
authType: string;
+1 -1
View File
@@ -169,7 +169,7 @@ export class SSHConnectionPool {
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again.
// Fall through, may create or wait again.
} else {
const waiter = queue.shift()!;
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.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 { fileURLToPath } from "node:url";
import {
@@ -43,29 +42,21 @@ export function getUnpackedPluginsDir(): string {
* plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory.
*
* Resolved from this module's location rather than cwd, because the build
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside
* dist/backend.
* Resolved from this module's location rather than cwd. The build emits to
* dist/backend/backend/plugins/ with the bundled plugins beside dist/backend,
* and a run from source reads the same dist/plugins.
*/
export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
if (override) return override;
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Built server: dist/backend/backend/plugins -> dist/plugins
// Source tree: src/backend/plugins -> plugins
// Both are three levels up, which is why there is one entry, not two.
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];
// Built: dist/backend/backend/plugins -> dist/plugins
// Source (vitest, tsx): src/backend/plugins -> dist/plugins
const fromSource = path.basename(path.resolve(here, "../..")) === "src";
return fromSource
? path.resolve(here, "../../../dist/plugins")
: path.resolve(here, "../../../plugins");
}
/** 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. */
export function getPluginPublicWsRoutes(pluginId: string): string[] {
return [...routes.values()]
+80 -6
View File
@@ -16,10 +16,12 @@ import { sendCoreAlert } from "../../notify/core-notify.js";
import {
getPluginRuntime,
installPluginArtifact,
registerLoadedPlugins,
setPluginEnabled,
unloadPlugin,
} from "../../plugins/index.js";
import { getPluginsDir } from "../../plugins/paths.js";
import type { LoadedPlugin } from "../../plugins/loader.js";
import {
createCurrentPluginPermissionGrantRepository,
createCurrentPluginRepository,
@@ -56,6 +58,7 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
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> {
const response = await remoteFetch(
link,
@@ -68,7 +71,7 @@ async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
const buffer = Buffer.from(await response.arrayBuffer());
const dir = getPluginsDir();
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);
const signature = response.headers.get("x-termix-signature");
const sigFile = `${file}.sig`;
@@ -83,6 +86,68 @@ async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
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> {
const repository = createCurrentPluginPermissionGrantRepository();
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
// and that the version really is newer.
const wasEnabled = await isEnabledLocally(plugin.id);
if (local) await unloadPlugin(plugin.id);
try {
local = await installPluginArtifact(await download(link, plugin));
const downloaded = await download(link, plugin);
local = await replaceWithDownload(
plugin.id,
downloaded,
local,
wasEnabled,
);
} catch (error) {
await alertOlderDesktop(plugin);
throw error;
@@ -180,9 +250,13 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
local.manifest.version !== plugin.version;
if (outdated && (!local || local.source === "user")) {
const wasEnabled = local ? await isEnabledLocally(plugin.id) : false;
if (local) await unloadPlugin(plugin.id);
const file = await download(link, plugin);
local = await installPluginArtifact(file);
const downloaded = await download(link, plugin);
local = await replaceWithDownload(
plugin.id,
downloaded,
local,
wasEnabled,
);
if (wasEnabled && !plugin.enabled) {
await setPluginEnabled(plugin.id, false);
}
-5
View File
@@ -126,11 +126,6 @@ export async function reconcileUser(
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>>();
let debounce: 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.
* 401:
* description: Not signed in.
* 403:
* description: API keys cannot link a desktop.
*/
router.post(
"/v2/link",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
if ((req as AuthenticatedRequest).apiKeyId) {
return res.status(403).json({ error: "API keys cannot link a desktop" });
}
try {
const user = await createCurrentUserRepository().findById(userId);
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))!;
}
export function rowSyncId(entity: RegisteredSyncEntity, row: SyncRow): string {
return entity.singleton
? singletonSyncId(entity.type)
: (row.syncId as string);
}
let hashKey: Buffer | null = null;
export async function getHashKey(): Promise<Buffer> {
@@ -527,6 +527,46 @@ describe("external identities", () => {
});
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 () => {
const result = await runLogin(fakeRequest() as never, external, {
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
* 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
* existed — so every fresh install got the old constraint and every user
* `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
* deletion (including the OIDC account-link cleanup) failed once the account
* 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
* 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
* (`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 () => {
// 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.
const seed = new Database(":memory:");
seed.exec(`
@@ -59,7 +59,7 @@ describe("needsExplicitPersist", () => {
/**
* 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.
*/
describe("generated schemas", () => {
@@ -133,7 +133,7 @@ describe("generated schemas", () => {
* SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and
* 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
* 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", () => {
// 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
* 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
* 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,
* 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
* `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", () => {
// 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.
expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1);
expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0);
@@ -81,7 +81,7 @@ describe("supportsReturning", () => {
expect(supportsReturning("sqlite")).toBe(true);
expect(supportsReturning("postgres")).toBe(true);
// 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);
});
});
@@ -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
* 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
* 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.
*/
@@ -169,7 +169,7 @@ describe("deleteReturning", () => {
await deleteReturning(context, {} as never, where);
// 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.
expect(calls[0]).toBe("begin");
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
* 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
* database — a setting changed on one replica would otherwise never reach the
* 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
* others, because the synchronous read cannot go back to the database.
*
* 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 () => {
// 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
.fn()
.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
* 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 {
const value = env.TEST_DIALECT?.trim().toLowerCase();
@@ -125,7 +125,7 @@ export class TestSqliteDatabase {
* Portable write for test setup.
*
* 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> {
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.
*/
function splitStatements(sql: string): string[] {
@@ -274,7 +274,7 @@ function portableSql(statement: string, dialect: DatabaseDialect): string {
if (dialect !== "mysql") return out;
// 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.
const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out);
if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`");
@@ -354,7 +354,7 @@ async function resyncAutoIncrement(
for (const table of tables) {
// 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
// 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") {
// pg_get_serial_sequence() raises 42703 rather than returning null when
// 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
* 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.
*/
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
* 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
* schema silently, and it is the reason the same tests could not be pointed at
* 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 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.
*/
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
* 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.
*/
describe("resolveHostBySyncId", () => {
@@ -6,6 +6,7 @@ import {
isValidPackageName,
buildSudoCommand,
parseUpgradable,
execCommand,
} from "@termix-ssh/plugin-sdk/host-commands";
describe("parsePlatformProbe", () => {
@@ -114,3 +115,13 @@ describe("parseUpgradable", () => {
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,
\`host_id\` int NOT NULL,
\`title\` varchar(200) NOT NULL,
\`body\` text,
\`meta\` text,
\`secret\` text,
\`body\` longtext,
\`meta\` longtext,
\`secret\` longtext,
\`pinned\` boolean DEFAULT false,
\`position\` int DEFAULT 0,
\`size\` bigint,
@@ -286,9 +286,9 @@ describe("adoptTableSql", () => {
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(
"`name` text NOT NULL DEFAULT ('x')",
"`name` longtext NOT NULL DEFAULT ('x')",
);
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
"\"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"));
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
@@ -140,7 +140,7 @@ describe("forwardAuditEntry", () => {
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(
(call) => call[0] === "Failed to forward audit entry",
);
@@ -19,7 +19,10 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({
findById: async (id: string) => ({ id }),
findById: async (id: string) =>
id === "expired"
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
: { id },
}),
createCurrentUserRepository: () => ({}),
createCurrentApiKeyRepository: () => ({}),
@@ -151,6 +154,15 @@ describe("AuthManager token handling", () => {
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 () => {
const token = jwt.sign({ userId: "user-1", pendingTOTP: true }, jwtSecret, {
expiresIn: "10m",
@@ -22,6 +22,7 @@ describe("isBlockedAddress", () => {
expect(isBlockedAddress("192.168.1.1")).toBe(true);
expect(isBlockedAddress("127.0.0.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);
});
+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
* 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.
*/
const QUIET_AFTER_FAILURES = 5;
+4
View File
@@ -414,6 +414,10 @@ class AuthManager {
return null;
}
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
return null;
}
await this.migrateDataKeyFromPayload(payload);
} catch (dbError) {
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
* 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,
* audit pages, fleet inventories — are orders of magnitude above this.
* the extra headers are not worth it. The payloads that matter, host lists,
* audit pages, fleet inventories, are orders of magnitude above this.
*/
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
// 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.
if (!needsExplicitPersist(resolveDatabaseDialect())) return result;
+1 -1
View File
@@ -201,7 +201,7 @@ class DataCrypto {
migratedFieldsCount: number;
}> {
// 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
// has, so this would throw rather than find nothing to do.
if (!needsExplicitPersist(resolveDatabaseDialect())) {
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more