* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:58:27 -05:00
1 parent a075ff4808
commit 0149260926
3656 files changed
+116210 -460999

No files matched your search

+176 -98
View File
@@ -47,11 +47,19 @@ import "dotenv/config";
import { databaseLogger, apiLogger } from "../utils/logger.js";
import { getLocalVersion } from "../utils/app-version.js";
import {
compareSemver,
fetchGitHubAPI,
fetchLatestRelease,
isPrereleaseVersion,
REPO_NAME,
REPO_OWNER,
updateStatus,
} from "../utils/latest-release.js";
import {
getCoreChannel,
getStoredCoreChannel,
parseChannel,
setCoreChannel,
} from "../updates/update-channel.js";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
@@ -73,6 +81,10 @@ import {
} from "./repositories/factory.js";
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
import {
selectExportableSettings,
type SettingData,
} from "../utils/export-settings.js";
import {
keepUsableProtocolCredentials,
listProtocolLogins,
@@ -99,19 +111,9 @@ const requireAdmin = authManager.createAdminMiddleware();
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
type SettingData = {
key: string;
value: string;
};
function shouldExportSetting(key: string): boolean {
return !key.startsWith("reset_code_") && !key.startsWith("temp_reset_token_");
}
async function getExportableSettings(): Promise<SettingData[]> {
async function getExportableSettings(isAdmin: boolean): Promise<SettingData[]> {
const settingsRows = await createCurrentSettingsRepository().listAll();
return settingsRows.filter((setting) => shouldExportSetting(setting.key));
return selectExportableSettings(settingsRows, isAdmin);
}
function writeSettingsToExportDatabase(
@@ -242,7 +244,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* /version:
* get:
* summary: Get version information
* description: Returns the running instance's version in localVersion. When the update check succeeds, remoteVersion is the latest GitHub release and version is its legacy alias, not the instance's version. Remote fields are omitted when the check is disabled, fails, or returns an unparseable release tag.
* description: Returns the running instance's version in localVersion. When the update check succeeds, remoteVersion is the newest GitHub release on the instance's update channel and version is its legacy alias, not the instance's version. Remote fields are omitted when the check is disabled, fails, or returns an unparseable release tag.
* tags:
* - General
* parameters:
@@ -266,20 +268,24 @@ 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.
* enum: [up_to_date, beta, requires_update, update_check_disabled, unknown]
* channel:
* type: string
* enum: [stable, beta]
* description: The update channel the check used. Always beta on a beta build.
* remoteVersion:
* type: string
* description: Latest GitHub release version. Present only when the update check succeeds.
* example: 2.8.0
* description: Newest release version on the channel. Present only when the update check succeeds.
* 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.
@@ -296,12 +302,8 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* html_url:
* type: string
* format: uri
* cached:
* type: boolean
* description: Whether the release lookup used a cached response. Present only when the update check succeeds.
* cache_age:
* type: number
* description: Age of the cached response in milliseconds, when available.
* prerelease:
* type: boolean
* 404:
* description: Local version not set.
*/
@@ -320,48 +322,31 @@ app.get("/version", authenticateJWT, async (req, res) => {
}
try {
const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI<GitHubRelease>(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey,
);
const channel = await getCoreChannel(localVersion);
const latest = await fetchLatestRelease(channel);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
if (!latest) {
databaseLogger.warn("Remote version not found in GitHub response", {
operation: "version_check",
rawTag,
channel,
});
return res.json({ localVersion, status: "unknown" });
return res.json({ localVersion, status: "unknown", channel });
}
const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const response = {
status,
localVersion: localVersion,
version: remoteVersion,
remoteVersion: remoteVersion,
res.json({
status: updateStatus(localVersion, latest.version),
channel,
localVersion,
version: latest.version,
remoteVersion: latest.version,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
tag_name: latest.tagName,
name: latest.name,
published_at: latest.publishedAt,
html_url: latest.url,
prerelease: latest.prerelease,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
res.json(response);
});
} catch (err) {
databaseLogger.error("Version check failed", err, {
operation: "version_check",
@@ -370,6 +355,122 @@ app.get("/version", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /version/channel:
* get:
* summary: Get the update channel
* description: Which Termix releases this instance is told about. A beta build always reports beta; stored is the admin's choice for stable builds.
* tags:
* - General
* responses:
* 200:
* description: The channel.
* content:
* application/json:
* schema:
* type: object
* properties:
* channel:
* type: string
* enum: [stable, beta]
* stored:
* type: string
* enum: [stable, beta]
* runningBeta:
* type: boolean
* put:
* summary: Set the update channel
* description: Admin only. Switches which Termix releases update checks and the Updates page offer. It does not change the running build.
* tags:
* - General
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [channel]
* properties:
* channel:
* type: string
* enum: [stable, beta]
* responses:
* 200:
* description: The channel after the change.
* 400:
* description: Not a known channel.
*/
app.get("/version/channel", authenticateJWT, async (_req, res) => {
try {
const localVersion = getLocalVersion();
res.json({
channel: await getCoreChannel(localVersion),
stored: await getStoredCoreChannel(),
runningBeta: isPrereleaseVersion(localVersion ?? undefined),
});
} catch (error) {
databaseLogger.error("Failed to read the update channel", error, {
operation: "update_channel",
});
res.status(500).json({ error: "Failed to read the update channel" });
}
});
app.put("/version/channel", requireAdmin, async (req, res) => {
const channel = parseChannel(req.body?.channel);
if (!channel) {
return res.status(400).json({ error: "channel must be stable or beta" });
}
try {
await setCoreChannel(channel);
const localVersion = getLocalVersion();
res.json({
channel: await getCoreChannel(localVersion),
stored: channel,
runningBeta: isPrereleaseVersion(localVersion ?? undefined),
});
} catch (error) {
databaseLogger.error("Failed to save the update channel", error, {
operation: "update_channel",
});
res.status(500).json({ error: "Failed to save the update channel" });
}
});
/**
* @openapi
* /version/releases:
* get:
* summary: Get the newest stable and beta releases
* description: The newest stable Termix release and the newest beta, if one is newer than stable, for the Updates page.
* tags:
* - General
* responses:
* 200:
* description: The releases. Either may be null.
* 500:
* description: GitHub could not be reached.
*/
app.get("/version/releases", authenticateJWT, async (_req, res) => {
try {
const [stable, newest] = await Promise.all([
fetchLatestRelease("stable"),
fetchLatestRelease("beta"),
]);
res.json({
localVersion: getLocalVersion(),
stable,
beta: newest?.prerelease ? newest : null,
});
} catch (error) {
databaseLogger.error("Failed to read releases", error, {
operation: "version_releases",
});
res.status(500).json({ error: "Failed to read releases" });
}
});
/**
* @openapi
* /releases/rss:
@@ -630,15 +731,15 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!user.isOidc;
const isExternalUser = !!user.isExternal;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateExternalUser(
if (isExternalUser) {
const externalUnlocked = await authManager.authenticateExternalUser(
userId,
deviceInfo.type,
);
if (!oidcUnlocked) {
if (!externalUnlocked) {
return res.status(403).json({
error: "Failed to unlock user data with SSO credentials",
});
@@ -699,17 +800,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_id TEXT,
client_secret TEXT,
issuer_url TEXT,
authorization_url TEXT,
token_url TEXT,
identifier_path TEXT,
name_path TEXT,
scopes TEXT DEFAULT 'openid email profile'
is_admin INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE settings (
@@ -740,9 +831,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
jump_hosts TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
terminal_config TEXT,
ssh_options TEXT,
quick_actions TEXT,
notes TEXT,
use_socks5 INTEGER,
socks5_host TEXT,
@@ -805,31 +894,21 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const userRecord = user;
const insertUser = exportDb.prepare(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, password_hash, is_admin)
VALUES (?, ?, ?, ?)
`);
insertUser.run(
userRecord.id,
userRecord.username,
"[EXPORTED_USER_NO_PASSWORD]",
userRecord.isAdmin ? 1 : 0,
userRecord.isOidc ? 1 : 0,
userRecord.oidcIdentifier || null,
userRecord.clientId || null,
userRecord.clientSecret || null,
userRecord.issuerUrl || null,
userRecord.authorizationUrl || null,
userRecord.tokenUrl || null,
userRecord.identifierPath || null,
userRecord.namePath || null,
userRecord.scopes || null,
);
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, terminal_config, ssh_options, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, credential_id, override_credential_username, jump_hosts, status_check_enabled, status_check_interval, ssh_options, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -856,9 +935,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.jumpHosts || null,
decrypted.statusCheckEnabled === false ? 0 : 1,
decrypted.statusCheckInterval ?? null,
decrypted.terminalConfig || null,
decrypted.sshOptions || null,
decrypted.quickActions || null,
decrypted.notes || null,
decrypted.useSocks5 ? 1 : 0,
decrypted.socks5Host || null,
@@ -964,7 +1041,10 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
writeSettingsToExportDatabase(
exportDb,
await getExportableSettings(!!user.isAdmin),
);
await writeHostDefaultsToExport(exportDb, userId);
} finally {
exportDb.close();
@@ -1074,15 +1154,15 @@ app.post(
return res.status(404).json({ error: "User not found" });
}
const isOidcUser = !!userRecord.isOidc;
const isExternalUser = !!userRecord.isExternal;
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateExternalUser(
if (isExternalUser) {
const externalUnlocked = await authManager.authenticateExternalUser(
userId,
deviceInfo.type,
);
if (!oidcUnlocked) {
if (!externalUnlocked) {
return res.status(403).json({
error: "Failed to unlock user data with SSO credentials",
});
@@ -1198,7 +1278,6 @@ app.post(
),
jumpHosts: host.jump_hosts,
...legacyStatusCheck(host),
terminalConfig: host.terminal_config,
// Exports from before 2.9.0 carry these in terminal_config.
sshOptions:
host.ssh_options ??
@@ -1206,7 +1285,6 @@ app.post(
terminalConfig: host.terminal_config,
}) ??
null,
quickActions: host.quick_actions,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
socks5Host: host.socks5_host,
+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 {
+277 -135
View File
@@ -1,4 +1,4 @@
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
import { repairSnippetsColumns } from "../../upgrade/snippets-schema-migration.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
@@ -24,6 +24,16 @@ import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
import { connectRemoteDatabase } from "./connect.js";
import { runRemoteMigrations } from "./migrate.js";
import { assertNotPre29Database } from "./upgrade-guard.js";
import {
LEGACY_USERNAME_PLACEHOLDER_UPDATES,
legacySudoMoves,
RETIRED_COLUMNS,
RETIRED_SETTINGS,
REMOTE_RETIRED_COLUMNS,
type LegacySudoRow,
} from "./retired-columns.js";
import { sql, type SQL } from "drizzle-orm";
import type { PortableDatabase } from "../repositories/database-context.js";
const dataDir = process.env.DATA_DIR || "./db/data";
@@ -217,24 +227,22 @@ async function initializeCompleteDatabase(): Promise<void> {
db = drizzle(sqlite, { schema });
await assertNotPre29Database(
(name) =>
!!sqlite
.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.get(name),
);
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_id TEXT,
client_secret TEXT,
issuer_url TEXT,
authorization_url TEXT,
token_url TEXT,
identifier_path TEXT,
name_path TEXT,
scopes TEXT DEFAULT 'openid email profile',
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 (
@@ -308,7 +316,6 @@ async function initializeCompleteDatabase(): Promise<void> {
force_keyboard_interactive TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
terminal_config TEXT,
notes TEXT,
use_socks5 INTEGER,
socks5_host TEXT,
@@ -357,12 +364,10 @@ async function initializeCompleteDatabase(): Promise<void> {
name TEXT NOT NULL,
color TEXT,
icon TEXT,
credential_id INTEGER,
sort_order INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS recent_activity (
@@ -502,6 +507,8 @@ async function initializeCompleteDatabase(): Promise<void> {
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
pinned_version TEXT,
channel TEXT NOT NULL DEFAULT 'stable',
manifest_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugins_registry_id ON plugins (registry_id);
@@ -672,6 +679,14 @@ async function initializeCompleteDatabase(): Promise<void> {
});
}
// 2.9.1 added this fix, so a 2.9.0 install has not run it yet. It reads the
// column migrateSchema drops.
if (columnNames("users").includes("oidc_identifier")) {
const { runSsoLegacyIdentityMigration } =
await import("../../upgrade/sso-legacy-identity-migration.js");
await runSsoLegacyIdentityMigration();
}
migrateSchema();
vacuumIfFreelistBloated();
@@ -782,14 +797,142 @@ const relaxPluginGrantGrantedBy = () => {
}
};
const columnNames = (table: string): string[] =>
(
sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
name: string;
}>
).map((col) => col.name);
/**
* ssh_folders.credential_id became the folder's auth default. It carries a
* foreign key, which ALTER TABLE DROP COLUMN refuses, so the table is rebuilt.
*/
const dropFolderCredentialColumn = () => {
const columns = columnNames("ssh_folders");
if (!columns.includes("credential_id")) return;
const kept = [
"id",
"user_id",
"name",
"color",
"icon",
"sort_order",
"sync_id",
"local_only",
"created_at",
"updated_at",
].filter((name) => columns.includes(name));
const list = kept.map((name) => `"${name}"`).join(", ");
sqlite.exec("PRAGMA foreign_keys = OFF");
try {
sqlite.exec(`
BEGIN;
CREATE TABLE ssh_folders_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
sort_order INTEGER,
sync_id TEXT,
local_only INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
INSERT INTO ssh_folders_new (${list}) SELECT ${list} FROM ssh_folders;
DROP TABLE ssh_folders;
ALTER TABLE ssh_folders_new RENAME TO ssh_folders;
CREATE UNIQUE INDEX IF NOT EXISTS idx_ssh_folders_sync_id ON ssh_folders(sync_id);
COMMIT;
`);
} catch (error) {
if (sqlite.inTransaction) sqlite.exec("ROLLBACK");
databaseLogger.warn("Failed to drop ssh_folders.credential_id", {
operation: "schema_migration",
error: getErrorMessage(error),
});
} finally {
sqlite.exec("PRAGMA foreign_keys = ON");
}
};
const moveLegacySudoPasswords = () => {
if (!columnNames("ssh_data").includes("terminal_config")) return;
try {
const rows = sqlite
.prepare(
"SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'",
)
.all() as LegacySudoRow[];
const update = sqlite.prepare(
"UPDATE ssh_data SET sudo_password = ? WHERE id = ?",
);
for (const move of legacySudoMoves(rows)) {
update.run(move.sudoPassword, move.id);
}
} catch (error) {
databaseLogger.warn("Failed to move legacy sudo passwords", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
};
const dropRetiredColumns = () => {
moveLegacySudoPasswords();
for (const [table, retired] of Object.entries(RETIRED_COLUMNS)) {
const columns = new Set(columnNames(table));
for (const column of retired) {
if (!columns.has(column)) continue;
try {
sqlite.exec(`ALTER TABLE ${table} DROP COLUMN "${column}"`);
} catch (error) {
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
}
dropFolderCredentialColumn();
// 2.8 spelled $external.username as $oidc.preferred_username.
for (const statement of LEGACY_USERNAME_PLACEHOLDER_UPDATES) {
try {
sqlite.exec(statement);
} catch (error) {
databaseLogger.warn("Failed to rename a legacy username placeholder", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
try {
sqlite
.prepare(
`DELETE FROM settings WHERE key IN (${RETIRED_SETTINGS.map(() => "?").join(", ")})`,
)
.run(...RETIRED_SETTINGS);
} catch (error) {
databaseLogger.warn("Failed to delete retired settings", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
};
const migrateSchema = () => {
repairSnippetsNoteColumn(sqlite);
repairSnippetsColumns(sqlite);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
addColumnIfNotExists("user_preferences", "language", "TEXT");
addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
@@ -805,31 +948,15 @@ const migrateSchema = () => {
"INTEGER",
);
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
addColumnIfNotExists("users", "client_id", "TEXT");
addColumnIfNotExists("users", "client_secret", "TEXT");
addColumnIfNotExists("users", "issuer_url", "TEXT");
addColumnIfNotExists("users", "authorization_url", "TEXT");
addColumnIfNotExists("users", "token_url", "TEXT");
addColumnIfNotExists("users", "identifier_path", "TEXT");
addColumnIfNotExists("users", "name_path", "TEXT");
addColumnIfNotExists("users", "scopes", "TEXT");
const hadRegisteredAtColumn = (() => {
try {
sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
@@ -842,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`,
@@ -872,15 +997,7 @@ const migrateSchema = () => {
);
}
}
addColumnIfNotExists(
"users",
"donation_modal_dismissed",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT");
@@ -926,8 +1043,6 @@ const migrateSchema = () => {
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
addColumnIfNotExists("ssh_data", "domain", "TEXT");
@@ -979,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'")
@@ -1145,19 +1260,6 @@ const migrateSchema = () => {
});
}
try {
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
} catch {
try {
sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
} catch (alterError) {
databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
operation: "schema_migration",
error: alterError,
});
}
}
try {
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
} catch {
@@ -1177,7 +1279,6 @@ const migrateSchema = () => {
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
{ column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
@@ -1459,76 +1560,6 @@ const migrateSchema = () => {
});
}
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
try {
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
cid: number;
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
}>;
const legacyNotNullColumns = new Set([
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
"identifier_path",
"name_path",
"scopes",
]);
const hasStaleNotNull = usersTableInfo.some(
(col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
);
if (hasStaleNotNull) {
const tempTableName = "users_temp_migration";
const columnDefs = usersTableInfo
.map((col) => {
const parts = [`"${col.name}"`, col.type || "TEXT"];
if (col.pk === 1) parts.push("PRIMARY KEY");
if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
parts.push("NOT NULL");
}
if (col.dflt_value !== null) {
parts.push(`DEFAULT ${col.dflt_value}`);
}
return parts.join(" ");
})
.join(",\n ");
const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
sqlite.exec(`PRAGMA foreign_keys = OFF`);
sqlite.exec(`
CREATE TABLE ${tempTableName} (
${columnDefs}
);
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
DROP TABLE users;
ALTER TABLE ${tempTableName} RENAME TO users;
`);
sqlite.exec(`PRAGMA foreign_keys = ON`);
databaseLogger.info(
"Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
{
operation: "schema_migration_users_oidc_nullable",
},
);
}
} catch (migrationError) {
databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
operation: "schema_migration",
error: migrationError,
});
}
// --- alerts begin ---
// alert_rules, alert_rule_channels and alert_firings were only ever created
// here, never declared in schema.ts, so they never existed on Postgres or
@@ -1567,6 +1598,8 @@ const migrateSchema = () => {
// Plugin runtime: lastError reports why a plugin is blocked or failed, and
// grants record whether a capability came from an admin or from bundling.
addColumnIfNotExists("plugins", "last_error", "TEXT");
addColumnIfNotExists("plugins", "pinned_version", "TEXT");
addColumnIfNotExists("plugins", "channel", "TEXT NOT NULL DEFAULT 'stable'");
addColumnIfNotExists(
"plugin_permission_grants",
"source",
@@ -1686,6 +1719,8 @@ const migrateSchema = () => {
// they referenced, which defeats the point of keeping them.
migrateAuditRetention(sqlite);
dropRetiredColumns();
// Runs last so every table and column added above already exists.
createPerformanceIndexes(sqlite);
@@ -1832,6 +1867,94 @@ async function initializeDatabase(): Promise<void> {
* synchronously, which better-sqlite3 allows and no remote driver does, so the
* table is loaded once here before anything asks for it.
*/
function resultRows<T>(result: unknown): T[] {
// node-postgres returns { rows }, mysql2 returns [rows, fields].
if (Array.isArray(result)) return result[0] as T[];
return ((result as { rows?: unknown[] }).rows ?? []) as T[];
}
async function moveRemoteLegacySudoPasswords(remote: {
execute: (query: SQL) => Promise<unknown>;
}): Promise<void> {
let rows: LegacySudoRow[];
try {
rows = resultRows<LegacySudoRow>(
await remote.execute(
sql`SELECT id, terminal_config, sudo_password FROM ssh_data WHERE terminal_config LIKE '%sudoPassword%'`,
),
);
} catch {
// A database created after the column left never had it.
return;
}
for (const move of legacySudoMoves(rows)) {
await remote.execute(
sql`UPDATE ssh_data SET sudo_password = ${move.sudoPassword} WHERE id = ${move.id}`,
);
}
}
/**
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
* longer knows them. Dropped by hand, only where they still exist.
*/
async function dropRemoteRetired(
dialect: Exclude<DatabaseDialect, "sqlite">,
remote: { execute: (query: SQL) => Promise<unknown> },
): Promise<void> {
const tables = Object.keys(REMOTE_RETIRED_COLUMNS);
const schema =
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
let present: Array<{ table_name: string; column_name: string }> = [];
try {
present = resultRows(
await remote.execute(
sql`SELECT table_name AS table_name, column_name AS column_name FROM information_schema.columns WHERE table_schema = ${schema} AND table_name IN (${sql.join(
tables.map((table) => sql`${table}`),
sql`, `,
)})`,
),
);
} catch (error) {
databaseLogger.warn("Could not list retired columns", {
operation: "schema_migration",
error: getErrorMessage(error),
});
return;
}
const existing = new Set(
present.map((row) => `${row.table_name}.${row.column_name}`),
);
for (const [table, columns] of Object.entries(REMOTE_RETIRED_COLUMNS)) {
for (const column of columns) {
if (!existing.has(`${table}.${column}`)) continue;
try {
await remote.execute(
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
);
} catch (error) {
databaseLogger.warn(`Failed to drop ${table}.${column}`, {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
}
try {
await remote.execute(
sql`DELETE FROM settings WHERE ${sql.identifier("key")} IN (${sql.join(
RETIRED_SETTINGS.map((key) => sql`${key}`),
sql`, `,
)})`,
);
} catch (error) {
databaseLogger.warn("Failed to delete retired settings", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
}
async function initializeRemoteDatabase(
dialect: Exclude<DatabaseDialect, "sqlite">,
): Promise<void> {
@@ -1841,7 +1964,26 @@ async function initializeRemoteDatabase(
});
db = await connectRemoteDatabase(dialect);
const remote = db as unknown as { execute: (query: SQL) => Promise<unknown> };
await assertNotPre29Database(async (name) => {
try {
await remote.execute(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
});
await moveRemoteLegacySudoPasswords(remote);
try {
await remote.execute(sql`SELECT oidc_identifier FROM users WHERE 1 = 0`);
const { runSsoLegacyIdentityMigration } =
await import("../../upgrade/sso-legacy-identity-migration.js");
await runSsoLegacyIdentityMigration();
} catch {
// The column is gone: the move ran before it was dropped.
}
await runRemoteMigrations(dialect, db);
await dropRemoteRetired(dialect, remote);
// Imported here rather than at the top: factory.ts imports getDb from this
// module, and a static import would close the cycle at module-load time.
@@ -1957,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(
+178
View File
@@ -0,0 +1,178 @@
/**
* What 26.10.0 removes that 2.9.0 kept so a downgrade to 2.8 lost nothing.
* Every 2.9 boot copied it into plugin settings, and the upgrade guard only
* opens a database 2.9 has seen.
*
* Kept on purpose: columns a move only reads once the owner's data key opens
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
* domain), so a user who never signed in to 2.9 still gets them, and
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
*/
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
ssh_data: [
"allow_session_sharing",
"autostart_key",
"autostart_key_password",
"autostart_password",
"default_path",
"docker_config",
"enable_ai_assistant",
"enable_command_history",
"enable_docker",
"enable_file_manager",
"enable_proxmox",
"enable_proxmox_stats",
"enable_rdp",
"enable_session_logging",
"enable_telnet",
"enable_terminal",
"enable_terminal_toolbar",
"enable_tmux_monitor",
"enable_tunnel",
"enable_vnc",
"enable_web_ui",
"guacamole_config",
"ignore_cert",
"mac_address",
"proxmox_config",
"proxmox_stats_config",
"rdp_ignore_cert",
"rdp_port",
"rdp_security",
"scp_legacy",
"security",
"show_docker_in_sidebar",
"show_file_manager_in_sidebar",
"show_server_stats_in_sidebar",
"show_terminal_in_sidebar",
"show_tunnel_in_sidebar",
"stats_config",
"telnet_port",
"tunnel_connections",
"use_warpgate",
"vnc_port",
"web_ui_config",
"wol_broadcast_address",
],
user_preferences: [
"ai_assistant_enabled",
"ai_read_only_commands",
"rdp_defaults",
],
};
/** Everything SQLite drops; the first part the drizzle migrations drop too. */
export const RETIRED_COLUMNS: Record<string, string[]> = {
users: [
"is_oidc",
"oidc_identifier",
"sso_provider_id",
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
"identifier_path",
"name_path",
"scopes",
"donation_modal_dismissed",
],
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
user_preferences: [
"command_autocomplete",
"terminal_defaults",
"custom_themes",
"confirm_snippet_execution",
"terminal_macros",
...REMOTE_RETIRED_COLUMNS.user_preferences,
],
ssh_data: [
"terminal_config",
"quick_actions",
...REMOTE_RETIRED_COLUMNS.ssh_data,
],
};
/** Settings rows the plugins copied, and the markers of the copies. */
export const RETIRED_SETTINGS = [
"host_defaults",
"analytics_enabled",
"analytics_instance_id",
"terminal_session_timeout_minutes",
"terminal_session_persistence_enabled",
"command_history_enabled",
"touch_input_settings",
"terminal_image_storage_mode",
"terminal_image_local_dir",
"terminal_image_host_path",
"terminal_image_ttl_ms",
"terminal_image_max_count",
"terminal_image_max_storage_bytes",
"session_sharing_globally_enabled",
"guac_enabled",
"guac_url",
"step_ca_url",
"step_ca_fingerprint",
"step_ca_provisioner",
"step_ca_private_endpoint_allowlist",
"acme_ssl_settings",
"global_metrics_interval",
"metrics_history_retention_days",
"tailscale_api_base_url",
"tailscale_api_key",
"ai_globally_enabled",
"ai_private_endpoint_allowlist",
"oidc_silent_login_default",
"host_status_config_migrated",
];
/**
* 2.8 editors kept a host's sudo password inside terminal_config, and 2.9
* still read it from there. Before the column goes it is copied into
* sudo_password, as plaintext, which the field encryption reads as it is
* and encrypts on the next save.
*/
export function legacySudoPassword(terminalConfig: unknown): string | null {
let parsed = terminalConfig;
try {
while (typeof parsed === "string") parsed = JSON.parse(parsed);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const value = (parsed as { sudoPassword?: unknown }).sudoPassword;
return typeof value === "string" && value ? value : null;
}
export interface LegacySudoRow {
id: number;
terminal_config: unknown;
sudo_password: unknown;
}
/** The hosts whose sudo password still only lives in terminal_config. */
export function legacySudoMoves(
rows: LegacySudoRow[],
): Array<{ id: number; sudoPassword: string }> {
return rows.flatMap((row) => {
if (row.sudo_password) return [];
const sudoPassword = legacySudoPassword(row.terminal_config);
return sudoPassword ? [{ id: Number(row.id), sudoPassword }] : [];
});
}
/**
* Rewrites the 2.8 `$oidc.preferred_username` placeholder to
* `$external.username` wherever a host username is saved. Plain SQL that
* runs the same on SQLite, Postgres and MySQL; the drizzle migrations carry
* a copy.
*/
export const LEGACY_USERNAME_PLACEHOLDER_UPDATES = [
"UPDATE ssh_data SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
"UPDATE ssh_credentials SET username = REPLACE(username, '$oidc.preferred_username', '$external.username') WHERE username LIKE '%$oidc.preferred_username%'",
"UPDATE host_defaults SET value = REPLACE(value, '$oidc.preferred_username', '$external.username') WHERE value LIKE '%$oidc.preferred_username%'",
];
+11 -34
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 {
@@ -26,23 +26,7 @@ export const users = mysqlTable("users", {
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
isOidc: boolean("is_oidc").notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: int("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
authorizationUrl: text("authorization_url"),
tokenUrl: text("token_url"),
identifierPath: text("identifier_path"),
namePath: text("name_path"),
scopes: text().default("openid email profile"),
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
});
export const settings = mysqlTable("settings", {
@@ -60,9 +44,8 @@ export const sessions = mysqlTable(
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: int("sso_provider_id"),
// JSON of the identity provider's logout claims, for back-channel logout.
externalSessionRef: text("external_session_ref"),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -196,11 +179,9 @@ export const hosts = mysqlTable(
.notNull()
.default(true),
statusCheckInterval: int("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: boolean("enable_ssh").notNull().default(true),
@@ -377,9 +358,6 @@ export const sshFolders = mysqlTable(
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: int("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
// Manual drag-to-reorder position among sibling folders. Null falls back
// to name sort, same convention as hosts.sortOrder.
sortOrder: int("sort_order"),
@@ -561,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,
@@ -611,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.
@@ -712,7 +690,6 @@ export const userPreferences = mysqlTable("user_preferences", {
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: boolean("command_autocomplete"),
commandPaletteEnabled: boolean("command_palette_enabled"),
showHostTags: boolean("show_host_tags"),
hostTrayOnClick: boolean("host_tray_on_click"),
@@ -720,16 +697,12 @@ export const userPreferences = mysqlTable("user_preferences", {
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
showPinAppRailButton: boolean("show_pin_app_rail_button"),
foldersCollapsed: boolean("folders_collapsed"),
confirmSnippetExecution: boolean("confirm_snippet_execution"),
disableUpdateCheck: boolean("disable_update_check"),
confirmTabClose: boolean("confirm_tab_close"),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
@@ -1029,6 +1002,10 @@ export const plugins = mysqlTable(
autoUpdate: boolean("auto_update")
.notNull()
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
@@ -1080,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(
+11 -34
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 {
@@ -26,23 +26,7 @@ export const users = pgTable("users", {
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
isOidc: boolean("is_oidc").notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: integer("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
authorizationUrl: text("authorization_url"),
tokenUrl: text("token_url"),
identifierPath: text("identifier_path"),
namePath: text("name_path"),
scopes: text().default("openid email profile"),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: boolean("donation_modal_dismissed")
.notNull()
.default(false),
});
export const settings = pgTable("settings", {
@@ -60,9 +44,8 @@ export const sessions = pgTable(
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: integer("sso_provider_id"),
// JSON of the identity provider's logout claims, for back-channel logout.
externalSessionRef: text("external_session_ref"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -196,11 +179,9 @@ export const hosts = pgTable(
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: boolean("enable_ssh").notNull().default(true),
@@ -377,9 +358,6 @@ export const sshFolders = pgTable(
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
// Manual drag-to-reorder position among sibling folders. Null falls back
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
@@ -561,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,
@@ -611,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.
@@ -712,7 +690,6 @@ export const userPreferences = pgTable("user_preferences", {
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: boolean("command_autocomplete"),
commandPaletteEnabled: boolean("command_palette_enabled"),
showHostTags: boolean("show_host_tags"),
hostTrayOnClick: boolean("host_tray_on_click"),
@@ -720,16 +697,12 @@ export const userPreferences = pgTable("user_preferences", {
expandAppRailOnHover: boolean("expand_app_rail_on_hover"),
showPinAppRailButton: boolean("show_pin_app_rail_button"),
foldersCollapsed: boolean("folders_collapsed"),
confirmSnippetExecution: boolean("confirm_snippet_execution"),
disableUpdateCheck: boolean("disable_update_check"),
confirmTabClose: boolean("confirm_tab_close"),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -1029,6 +1002,10 @@ export const plugins = pgTable(
autoUpdate: boolean("auto_update")
.notNull()
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
@@ -1080,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(
+9 -34
View File
@@ -14,25 +14,7 @@ export const users = sqliteTable("users", {
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: integer("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
authorizationUrl: text("authorization_url"),
tokenUrl: text("token_url"),
identifierPath: text("identifier_path"),
namePath: text("name_path"),
scopes: text().default("openid email profile"),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
donationModalDismissed: integer("donation_modal_dismissed", {
mode: "boolean",
})
.notNull()
.default(false),
});
export const settings = sqliteTable("settings", {
@@ -50,9 +32,8 @@ export const sessions = sqliteTable(
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: integer("sso_provider_id"),
// JSON of the identity provider's logout claims, for back-channel logout.
externalSessionRef: text("external_session_ref"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -188,11 +169,9 @@ export const hosts = sqliteTable(
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
terminalConfig: text("terminal_config"),
// SSH connection options core's connect pipeline reads (keepalive,
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
quickActions: text("quick_actions"),
notes: text("notes"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
@@ -369,9 +348,6 @@ export const sshFolders = sqliteTable(
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null",
}),
// Manual drag-to-reorder position among sibling folders. Null falls back
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
@@ -553,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,
@@ -603,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.
@@ -704,7 +680,6 @@ export const userPreferences = sqliteTable("user_preferences", {
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: integer("command_autocomplete", { mode: "boolean" }),
commandPaletteEnabled: integer("command_palette_enabled", { mode: "boolean" }),
showHostTags: integer("show_host_tags", { mode: "boolean" }),
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
@@ -716,16 +691,12 @@ export const userPreferences = sqliteTable("user_preferences", {
mode: "boolean",
}),
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -1027,6 +998,10 @@ export const plugins = sqliteTable(
autoUpdate: integer("auto_update", { mode: "boolean" })
.notNull()
.default(false),
/** Version an admin pinned. Never auto-updated while set. */
pinnedVersion: text("pinned_version"),
/** stable | beta: which registry releases updates offer. */
channel: text("channel").notNull().default("stable"),
manifestJson: text("manifest_json").notNull(),
},
(table) => [index("idx_plugins_registry_id").on(table.registryId)],
@@ -1078,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(
+18
View File
@@ -0,0 +1,18 @@
/**
* 26.10.0 dropped the columns 2.9.0 kept for a downgrade to 2.8, and the
* one-time 2.8 moves that copied them into plugins went with them. A 2.8
* database has to pass through 2.9 first, or that data is lost.
*
* 2.8 already had a plugins table, so the marker is plugin_migrations, which
* 2.9.0 added on every dialect. A 2.8 database has hosts but not that.
*/
export const PRE_29_DATABASE_ERROR =
"This database is from Termix 2.8 or older. Upgrade to Termix 2.9.1 and start it once so it can move your data, then upgrade to this version.";
export async function assertNotPre29Database(
hasTable: (name: string) => boolean | Promise<boolean>,
): Promise<void> {
if ((await hasTable("ssh_data")) && !(await hasTable("plugin_migrations"))) {
throw new Error(PRE_29_DATABASE_ERROR);
}
}
@@ -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> {
@@ -152,6 +152,15 @@ export class CredentialAccessRepository {
return rows[0] ?? null;
}
/** Credentials holding a share this user handed out. */
async listCredentialIdsGrantedBy(userId: string): Promise<number[]> {
const rows = await this.context.drizzle
.selectDistinct({ credentialId: credentialAccess.credentialId })
.from(credentialAccess)
.where(eq(credentialAccess.grantedBy, userId));
return rows.map((row) => row.credentialId);
}
async revoke(accessId: number, credentialId: number): Promise<void> {
await this.context.drizzle
.delete(credentialAccess)
@@ -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 {
+13 -5
View File
@@ -18,6 +18,7 @@ import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { HostDefaultsRepository } from "./host-defaults-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
@@ -48,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 {
@@ -63,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,
@@ -90,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.
@@ -240,6 +241,13 @@ export function createCurrentPluginSettingsRepository(): PluginSettingsRepositor
);
}
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
return new PluginInstallCountRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_install_count_repository_write"),
);
}
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
return new PluginMigrationRepository(
createCurrentRepositoryContext(),
@@ -395,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} / `;
@@ -112,7 +112,6 @@ export class HostFolderRepository {
name: string,
color: string | null | undefined,
icon: string | null | undefined,
credentialId?: number | null,
now = new Date().toISOString(),
): Promise<{ folder: HostFolderRecord; created: boolean }> {
const existing = await this.findFolder(userId, name);
@@ -123,8 +122,6 @@ export class HostFolderRepository {
{
color,
icon,
credentialId:
credentialId === undefined ? existing.credentialId : credentialId,
updatedAt: now,
},
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
@@ -140,7 +137,6 @@ export class HostFolderRepository {
name,
color,
icon,
credentialId: credentialId ?? null,
createdAt: now,
updatedAt: now,
});
@@ -1,5 +1,5 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import { hosts, sshCredentials } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -459,34 +459,6 @@ export class HostResolutionRepository {
return this.decryptOne("ssh_credentials", rows[0], decryptUserId);
}
/**
* Resolve the nearest assigned credential for a folder path, walking up
* through parent folders (e.g. "Switches / Floor1" falls back to
* "Switches" if the child folder has no credential of its own).
*/
async findFolderCredentialId(
userId: string,
folderPath: string,
): Promise<number | null> {
const segments = folderPath.split(" / ").filter(Boolean);
if (segments.length === 0) return null;
const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / "));
const rows = await this.context.drizzle
.select({ name: sshFolders.name, credentialId: sshFolders.credentialId })
.from(sshFolders)
.where(
and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)),
);
const byName = new Map(rows.map((row) => [row.name, row.credentialId]));
for (let i = paths.length - 1; i >= 0; i--) {
const credentialId = byName.get(paths[i]);
if (credentialId) return credentialId;
}
return null;
}
private decryptOne<T extends Record<string, unknown>>(
tableName: "ssh_data" | "ssh_credentials",
record: T | undefined,
@@ -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 {
@@ -0,0 +1,68 @@
import { and, eq } from "drizzle-orm";
import { pluginInstallCounts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
export interface PluginInstallCountInput {
pluginId: string;
count: number;
source: string;
}
/** Popularity numbers for the store, one row per plugin and registry. */
export class PluginInstallCountRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByRegistry(
registryId: string,
): Promise<PluginInstallCountRecord[]> {
return this.context.drizzle
.select()
.from(pluginInstallCounts)
.where(eq(pluginInstallCounts.registryId, registryId));
}
/** Writes only the rows that changed, so an unchanged sync costs no save. */
async upsertMany(
registryId: string,
entries: PluginInstallCountInput[],
now = new Date().toISOString(),
): Promise<number> {
const existing = new Map(
(await this.listByRegistry(registryId)).map((row) => [row.pluginId, row]),
);
let written = 0;
for (const entry of entries) {
const row = existing.get(entry.pluginId);
if (row && row.count === entry.count && row.source === entry.source) {
continue;
}
if (row) {
await this.context.drizzle
.update(pluginInstallCounts)
.set({ count: entry.count, source: entry.source, updatedAt: now })
.where(
and(
eq(pluginInstallCounts.pluginId, entry.pluginId),
eq(pluginInstallCounts.registryId, registryId),
),
);
} else {
await this.context.drizzle.insert(pluginInstallCounts).values({
pluginId: entry.pluginId,
registryId,
count: entry.count,
source: entry.source,
updatedAt: now,
});
}
written += 1;
}
if (written > 0) await this.onWrite?.();
return written;
}
}
@@ -20,6 +20,8 @@ export interface PluginCreateInput {
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: string;
manifestJson: string;
}
@@ -32,6 +34,8 @@ export interface PluginUpdateInput {
state?: string;
lastError?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: string;
manifestJson?: string;
}
@@ -68,6 +72,8 @@ export class PluginRepository {
state: input.state ?? "disabled",
lastError: input.lastError ?? null,
autoUpdate: input.autoUpdate ?? false,
pinnedVersion: input.pinnedVersion ?? null,
channel: input.channel ?? "stable",
manifestJson: input.manifestJson,
installedAt: now,
updatedAt: now,
@@ -101,6 +107,11 @@ export class PluginRepository {
lastError:
input.lastError === undefined ? existing.lastError : input.lastError,
autoUpdate: input.autoUpdate ?? existing.autoUpdate,
pinnedVersion:
input.pinnedVersion === undefined
? existing.pinnedVersion
: input.pinnedVersion,
channel: input.channel ?? existing.channel,
manifestJson: input.manifestJson ?? existing.manifestJson,
updatedAt: now,
},
@@ -178,6 +178,28 @@ export class PluginSettingsRepository {
return affected;
}
/** How many stored values the plugin has in each scope. */
async countByScope(
pluginId: string,
): Promise<Record<PluginSettingsScope, number>> {
const rows = await this.context.drizzle
.select({ scope: pluginSettings.scope })
.from(pluginSettings)
.where(eq(pluginSettings.pluginId, pluginId));
const counts: Record<PluginSettingsScope, number> = {
admin: 0,
user: 0,
host: 0,
secret: 0,
};
for (const row of rows) {
const scope = row.scope as PluginSettingsScope;
if (scope in counts) counts[scope] += 1;
}
return counts;
}
async deleteByPlugin(pluginId: string): Promise<number> {
const result = await this.context.drizzle
.delete(pluginSettings)
@@ -173,6 +173,15 @@ export class RbacAccessRepository {
return rowsAffected(result);
}
/** Hosts holding a share this user handed out. */
async listHostIdsGrantedBy(userId: string): Promise<number[]> {
const rows = await this.context.drizzle
.selectDistinct({ hostId: hostAccess.hostId })
.from(hostAccess)
.where(eq(hostAccess.grantedBy, userId));
return rows.map((row) => row.hostId);
}
/** Grants the departing user handed out now count as the successor's. */
async reassignHostAccessGrantedBy(
fromUserId: string,
+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.
*/
@@ -1,4 +1,4 @@
import { and, asc, eq, inArray, like, sql } from "drizzle-orm";
import { and, asc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm";
import { pluginSettings, users } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import {
@@ -8,11 +8,22 @@ import {
} from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type UserRecord = typeof users.$inferSelect;
/** isExternal: the user has at least one external sign-in identity. */
export type UserRecord = typeof users.$inferSelect & { isExternal: boolean };
export type NewUserRecord = typeof users.$inferInsert;
export type UserUpdate = Partial<Omit<NewUserRecord, "id">>;
export type NewFirstLocalUserRecord = Omit<NewUserRecord, "isAdmin">;
// Qualified by hand: drizzle prints a column in a subquery without its table,
// which would bind users.id to the identity row's own id.
const userColumns = {
...getTableColumns(users),
isExternal:
sql<boolean>`EXISTS (SELECT 1 FROM ${sql.identifier("user_external_identities")} WHERE ${sql.identifier("user_external_identities")}.${sql.identifier("user_id")} = ${sql.identifier("users")}.${sql.identifier("id")})`.mapWith(
(value: unknown) => value === true || Number(value) === 1,
),
};
export class UserRepository {
constructor(
private readonly context: DatabaseContext,
@@ -20,7 +31,7 @@ export class UserRepository {
) {}
async listAll(): Promise<UserRecord[]> {
return this.context.drizzle.select().from(users);
return this.context.drizzle.select(userColumns).from(users);
}
/**
@@ -42,7 +53,7 @@ export class UserRepository {
const [rows, totalResult] = await Promise.all([
this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(where)
// Case-insensitive: a plain sort puts every capitalised name ahead of
@@ -61,7 +72,7 @@ export class UserRepository {
async findById(id: string): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(eq(users.id, id))
.limit(1);
@@ -71,7 +82,7 @@ export class UserRepository {
async findByUsername(username: string): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(eq(users.username, username))
.limit(1);
@@ -79,18 +90,6 @@ export class UserRepository {
return rows[0] ?? null;
}
async findByExternalIdentifier(
oidcIdentifier: string,
): Promise<UserRecord | null> {
const rows = await this.context.drizzle
.select()
.from(users)
.where(eq(users.oidcIdentifier, oidcIdentifier))
.limit(1);
return rows[0] ?? null;
}
async listByIds(ids: string[]): Promise<UserRecord[]> {
const uniqueIds = Array.from(new Set(ids.filter(Boolean)));
if (uniqueIds.length === 0) {
@@ -98,7 +97,7 @@ export class UserRepository {
}
return this.context.drizzle
.select()
.select(userColumns)
.from(users)
.where(inArray(users.id, uniqueIds));
}
@@ -106,7 +105,9 @@ export class UserRepository {
async create(user: NewUserRecord): Promise<UserRecord> {
const rows = await insertReturning(this.context, users, user);
await this.afterWrite();
return rows[0];
return (
(await this.findById(rows[0].id)) ?? { ...rows[0], isExternal: false }
);
}
async createFirstLocalUser(
@@ -118,7 +119,7 @@ export class UserRepository {
}));
await this.afterWrite();
return result;
return { ...result, user: { ...result.user, isExternal: false } };
}
async createFirstSsoUser(
@@ -130,7 +131,7 @@ export class UserRepository {
}));
await this.afterWrite();
return result;
return { ...result, user: { ...result.user, isExternal: true } };
}
/**
@@ -140,13 +141,13 @@ 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(
build: (isFirstUser: boolean) => NewUserRecord,
): Promise<{ user: UserRecord; isFirstUser: boolean }> {
): Promise<{ user: typeof users.$inferSelect; isFirstUser: boolean }> {
if (this.context.dialect === "sqlite") {
/* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect
is checked directly above, and better-sqlite3 rejects an async
@@ -195,7 +196,7 @@ export class UserRepository {
);
await this.afterWrite();
return rows[0] ?? null;
return rows[0] ? this.findById(rows[0].id) : null;
}
async delete(id: string): Promise<boolean> {
@@ -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;
}
@@ -23,6 +23,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -135,6 +136,16 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
return results;
}
/**
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
@@ -710,9 +721,6 @@ export function registerHostBulkRoutes(
sudoPassword: hostData.sudoPassword || null,
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
...importedStatusCheck(hostData as Record<string, unknown>),
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
// A 2.8 export carries these inside terminalConfig.
sshOptions:
sshOptionsForWrite({
@@ -820,6 +828,7 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -975,7 +984,6 @@ export function registerHostBulkRoutes(
: null,
statusCheckEnabled: true,
statusCheckInterval: null,
terminalConfig: null,
sshOptions: null,
forceKeyboardInteractive: "false",
notes: null,
@@ -1026,6 +1034,7 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -12,7 +12,10 @@ import {
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
import { recompute } from "../../hosts/defaults/recompute.js";
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
import {
folderCredentialIds,
setFolderCredentialDefault,
} from "../../hosts/defaults/service.js";
type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -143,10 +146,17 @@ export function registerHostFolderRoutes(
}
try {
const folders =
await createCurrentHostFolderRepository().listFolders(userId);
const [folders, credentials] = await Promise.all([
createCurrentHostFolderRepository().listFolders(userId),
folderCredentialIds(userId),
]);
res.json(folders);
res.json(
folders.map((folder) => ({
...folder,
credentialId: credentials.get(folder.id) ?? null,
})),
);
} catch (err) {
sshLogger.error("Failed to fetch folders", err, {
operation: "fetch_folders",
@@ -238,7 +248,6 @@ export function registerHostFolderRoutes(
name,
color,
icon,
normalizedCredentialId,
);
if (typeof localOnly === "boolean") {
await createCurrentHostFolderRepository().setLocalOnly(
+18 -38
View File
@@ -62,6 +62,7 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
],
} as const;
/** Keys a 2.8 client's terminalConfig may carry that only the owner may set. */
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"sudoPassword",
"agentSocketPath",
@@ -79,6 +80,14 @@ export function containsOwnerPrivateAuthUpdate(
);
}
/**
* An update that names no auth type leaves the stored password and key
* alone. Clearing them would leave a password host with no password.
*/
export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null;
}
const FOLDER_PATH_SEPARATOR = " / ";
/**
@@ -263,18 +272,10 @@ export function stripSensitiveFields(
host: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...host };
const terminalConfigForSudo =
host.terminalConfig &&
typeof host.terminalConfig === "object" &&
!Array.isArray(host.terminalConfig)
? (host.terminalConfig as Record<string, unknown>)
: undefined;
result.hasKey = !!host.key;
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
// 2.8 editors kept the sudo password inside terminal_config.
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasSudoPassword = !!host.sudoPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
@@ -283,11 +284,11 @@ export function stripSensitiveFields(
typeof result.terminalConfig === "object" &&
!Array.isArray(result.terminalConfig)
) {
const terminalConfig = {
...(result.terminalConfig as Record<string, unknown>),
};
delete terminalConfig.sudoPassword;
result.terminalConfig = terminalConfig;
const { sudoPassword: _sudo, ...rest } = result.terminalConfig as Record<
string,
unknown
>;
result.terminalConfig = rest;
}
return result;
}
@@ -295,6 +296,7 @@ export function stripSensitiveFields(
// Connection essentials a connect-level recipient is allowed to see.
const CONNECT_LEVEL_FIELDS = new Set([
"id",
"syncId",
"userId",
"ownerId",
"ownerUsername",
@@ -397,44 +399,22 @@ export function parseSharedSource(
}
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value !== "string" || !value) return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
} catch {
return null;
}
}
/**
* sshOptions, and terminalConfig in the shape 2.8 clients and exports read:
* core's own keys only, the SSH options. Everything else 2.8 kept there now
* belongs to plugins' host settings, which they put back through their
* hostPayloadLegacy. A sudo password 2.8 kept in terminal_config comes out as
* sudoPassword, for the sanitizers to handle.
* hostPayloadLegacy.
*/
export function hostTerminalExport(host: Record<string, unknown>): {
sshOptions: HostSshOptions;
terminalConfig?: Record<string, unknown>;
sudoPassword?: unknown;
} {
const raw = parseJsonObject(host.terminalConfig);
const sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : raw,
);
const sshOptions = parseSshOptions(host.sshOptions);
const terminalConfig: Record<string, unknown> = { ...sshOptions };
const legacySudo = !host.sudoPassword ? raw?.sudoPassword : undefined;
return {
sshOptions,
terminalConfig:
Object.keys(terminalConfig).length > 0 ? terminalConfig : undefined,
...(legacySudo ? { sudoPassword: legacySudo } : {}),
};
}
@@ -10,7 +10,7 @@
* on this path has to decrypt and the async work stays in one place.
*/
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import {
@@ -22,7 +22,7 @@ import {
import {
coerceSettingValue,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
} from "@termix-ssh/plugin-sdk/settings";
import { getPluginRuntime } from "../../plugins/index.js";
import { consume } from "../../plugins/registry.js";
import { sshLogger } from "../../utils/logger.js";
@@ -1,14 +1,11 @@
/**
* How a host update writes terminal_config and ssh_options, kept apart from
* the route so it can be tested on its own.
* How a host update writes ssh_options, kept apart from the route so it can
* be tested on its own.
*/
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
import { parseSshOptions } from "../../hosts/ssh-options.js";
import {
OWNER_PRIVATE_SSH_OPTION_FIELDS,
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
} from "./host-normalizers.js";
import { OWNER_PRIVATE_SSH_OPTION_FIELDS } from "./host-normalizers.js";
export function parseTerminalConfig(
value: unknown,
@@ -27,63 +24,41 @@ export function parseTerminalConfig(
}
/**
* terminal_config is merged into what is stored rather than replaced. The
* 2.8 keys the editor no longer sends (the terminal's look, which the
* ssh-terminal plugin copies into its host settings, and a sudo password 2.8
* editors kept there) stay until 3.0.0 drops the column. A shared editor
* never changes the owner's private keys in either JSON. Returns an error
* for a terminalConfig that cannot be read.
* A 2.8 client's terminalConfig only feeds sshOptions, which the route has
* already taken from it. A shared editor never changes the owner's private
* SSH options. Returns an error for a terminalConfig that cannot be read.
*/
export async function mergeStoredTerminalFields(
export async function mergeStoredSshOptions(
sshDataObj: Record<string, unknown>,
hostData: Record<string, unknown>,
hostId: number,
ownerId: string,
isOwner: boolean,
): Promise<string | null> {
const sentTerminalConfig =
hostData.terminalConfig !== undefined && hostData.terminalConfig !== null;
const incoming = sentTerminalConfig
? parseTerminalConfig(hostData.terminalConfig)
: null;
if (sentTerminalConfig && !incoming) return "Invalid terminal config";
if (
hostData.terminalConfig !== undefined &&
hostData.terminalConfig !== null &&
!parseTerminalConfig(hostData.terminalConfig)
) {
return "Invalid terminal config";
}
delete sshDataObj.terminalConfig;
const sentSudoPassword = isOwner && hostData.sudoPassword !== undefined;
if (!incoming && !sentSudoPassword && sshDataObj.sshOptions === undefined) {
return null;
}
if (isOwner || sshDataObj.sshOptions === undefined) return null;
const stored = await createCurrentHostResolutionRepository().findHostById(
hostId,
ownerId,
);
const storedConfig = parseTerminalConfig(stored?.terminalConfig) ?? {};
if (incoming || (sentSudoPassword && "sudoPassword" in storedConfig)) {
const merged = { ...storedConfig, ...(incoming ?? {}) };
if (!isOwner) {
for (const field of OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS) {
if (field in storedConfig) merged[field] = storedConfig[field];
else delete merged[field];
}
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
// A sudo password saved on its own lives in the encrypted column.
if (sentSudoPassword) delete merged.sudoPassword;
sshDataObj.terminalConfig = JSON.stringify(merged);
}
if (!isOwner && sshDataObj.sshOptions !== undefined) {
const options = parseSshOptions(sshDataObj.sshOptions);
const storedOptions = parseSshOptions(stored?.sshOptions ?? storedConfig);
for (const field of OWNER_PRIVATE_SSH_OPTION_FIELDS) {
if (storedOptions[field] !== undefined) {
options[field] = storedOptions[field];
} else {
delete options[field];
}
}
sshDataObj.sshOptions = JSON.stringify(options);
}
sshDataObj.sshOptions = JSON.stringify(options);
return null;
}
+8 -24
View File
@@ -31,6 +31,7 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalBoolean,
isValidPort,
@@ -98,7 +99,7 @@ import {
listHostProtocols,
} from "../../hosts/protocol-auth/registry.js";
import {
mergeStoredTerminalFields,
mergeStoredSshOptions,
parseTerminalConfig,
} from "./host-terminal-fields.js";
@@ -316,11 +317,6 @@ router.post(
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
@@ -889,11 +885,6 @@ router.put(
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -931,6 +922,8 @@ router.put(
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (keepsStoredSshAuth(effectiveAuthType)) {
// The update says nothing about authentication.
} else if (effectiveAuthType === "password") {
if (password) {
sshDataObj.password = password;
@@ -1081,7 +1074,7 @@ router.put(
}
}
const terminalFieldsError = await mergeStoredTerminalFields(
const terminalFieldsError = await mergeStoredSshOptions(
sshDataObj,
hostData,
Number(hostId),
@@ -1661,11 +1654,7 @@ router.get(
}
const resolved = (await resolveHostCredentials(host, userId)) || host;
let value = resolved[field];
if (!value && field === "sudoPassword") {
value = hostTerminalExport(resolved).sudoPassword || null;
}
const value = resolved[field];
if (!value) {
return res.status(404).json({ error: "No password set" });
@@ -1775,10 +1764,7 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
sudoPassword:
resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
sudoPassword: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
@@ -1911,9 +1897,7 @@ router.get(
!!resolvedHost.overrideCredentialUsername,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword ||
hostTerminalExport(resolvedHost).sudoPassword ||
null,
: resolvedHost.sudoPassword || null,
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
@@ -2,7 +2,7 @@ import {
CORE_KEYBINDING_ACTIONS,
validateKeybindingActionParams,
type PluginKeybindingActionContribution,
} from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/manifest";
export interface DeclaredKeybindingAction extends PluginKeybindingActionContribution {
pluginId: string;
+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 {
@@ -18,7 +18,7 @@ import express, { type Request, type Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { getPluginRouter, isPluginInstalled } from "../../plugins/http.js";
import { getRequestBasePath } from "../../utils/request-origin.js";
import type { PluginLegacyRedirect } from "@termix/plugin-sdk/manifest";
import type { PluginLegacyRedirect } from "@termix-ssh/plugin-sdk/manifest";
const router = express.Router();
File diff suppressed because it is too large. Load diff
+75
View File
@@ -75,6 +75,74 @@ async function canManageHostSharing(
return { allowed: access.hasAccess, isOwner: access.isOwner };
}
/**
* Drops shares handed out by someone who can no longer manage sharing on the
* host, then repeats so shares they passed on further go too.
*/
export async function pruneHostGrants(hostId: number): Promise<number> {
const repository = createCurrentRbacAccessRepository();
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listHostAccess(hostId)) {
if (!grant.grantedBy || grant.grantedBy === ownerId) continue;
if ((await canManageHostSharing(grant.grantedBy, hostId)).allowed) {
continue;
}
await repository.revokeHostAccess(grant.id, hostId);
removed++;
changed = true;
}
}
return removed;
}
/** pruneHostGrants for credential shares. */
export async function pruneCredentialGrants(
credentialId: number,
): Promise<number> {
const repository = createCurrentCredentialAccessRepository();
const owner =
await createCurrentCredentialRepository().findById(credentialId);
if (!owner) return 0;
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listForCredential(credentialId)) {
if (!grant.grantedBy || grant.grantedBy === owner.userId) continue;
if (
(await canManageCredentialSharing(grant.grantedBy, credentialId))
.allowed
) {
continue;
}
await repository.revoke(grant.id, credentialId);
removed++;
changed = true;
}
}
return removed;
}
/** Prunes everything these users shared, after they lost a role. */
export async function pruneGrantsBy(userIds: string[]): Promise<void> {
for (const userId of userIds) {
permissionManager.invalidateUserPermissionCache(userId);
for (const hostId of await createCurrentRbacAccessRepository().listHostIdsGrantedBy(
userId,
)) {
await pruneHostGrants(hostId);
}
for (const credentialId of await createCurrentCredentialAccessRepository().listCredentialIdsGrantedBy(
userId,
)) {
await pruneCredentialGrants(credentialId);
}
}
}
export interface ShareTarget {
type: "user" | "role";
id: string | number;
@@ -300,6 +368,7 @@ router.post(
});
}
await pruneHostGrants(hostId);
databaseLogger.success("Host shared successfully", {
operation: "rbac_host_share_success",
userId,
@@ -780,6 +849,7 @@ router.patch(
hostId,
update,
);
await pruneHostGrants(hostId);
databaseLogger.info("Host access grant updated", {
operation: "rbac_host_access_update",
@@ -863,6 +933,7 @@ router.delete(
accessId,
hostId,
);
await pruneHostGrants(hostId);
databaseLogger.info("Permission revoked", {
operation: "rbac_permission_revoke",
adminId: userId,
@@ -1383,6 +1454,7 @@ router.delete(
for (const userId of deletedUserIds) {
permissionManager.invalidateUserPermissionCache(userId);
}
await pruneGrantsBy(deletedUserIds);
res.json({
success: true,
@@ -1675,6 +1747,7 @@ router.delete(
}
permissionManager.invalidateUserPermissionCache(targetUserId);
await pruneGrantsBy([targetUserId]);
databaseLogger.info("Role removed from user", {
operation: "rbac_role_remove",
adminId: req.userId!,
@@ -1888,6 +1961,7 @@ router.post(
success: true,
});
await pruneCredentialGrants(credentialId);
res.json({ success: true, permissionLevel, expiresAt });
} catch (error) {
databaseLogger.error("Failed to share credential", error, {
@@ -1971,6 +2045,7 @@ router.delete(
return res.status(404).json({ error: "Access grant not found" });
}
await repository.revoke(accessId, credentialId);
await pruneCredentialGrants(credentialId);
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to revoke credential access", error, {
+32 -12
View File
@@ -8,10 +8,11 @@ import {
} from "../repositories/factory.js";
import {
defaultUiPreferences,
sanitizeOnboarding,
sanitizeUiPreferences,
UI_ONBOARDING_VERSION,
type UiPreferences,
} from "../../../types/ui-preferences.js";
import { LEGACY_SEEN, mergeSeen } from "../../../types/onboarding.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -23,8 +24,8 @@ const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* Existing users must never be ambushed by onboarding. A user who never
* finished it and registered more than a day ago predates this feature, so
* they are handed a completed onboarding state. This also repairs rows that an
* earlier PUT wrote with the not-completed default.
* they count as having seen every core step, and the client silently marks
* the plugin steps they have now as seen too (baselinePending).
*/
export function withOnboardingBackfill(
preferences: UiPreferences,
@@ -35,13 +36,21 @@ export function withOnboardingBackfill(
Number.isFinite(registeredMs) &&
Date.now() - registeredMs < NEW_ACCOUNT_WINDOW_MS;
if (isNewAccount || preferences.onboarding.completedAt) return preferences;
const { onboarding } = preferences;
if (
isNewAccount ||
onboarding.completedAt ||
Object.keys(onboarding.seen).length > 0
) {
return preferences;
}
return {
...preferences,
onboarding: {
...preferences.onboarding,
completedVersion: UI_ONBOARDING_VERSION,
...onboarding,
seen: { ...LEGACY_SEEN },
baselinePending: true,
},
};
}
@@ -51,7 +60,7 @@ export function withOnboardingBackfill(
* /ui-preferences:
* get:
* summary: Get the UI complexity preferences for the current user
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state. A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered before this feature existed are returned an already-completed onboarding state so they are never shown the first-run flow.
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state (which onboarding steps they have seen, by version). A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered more than a day ago and never finished onboarding are returned as having seen every core step, so they are never shown the first-run flow.
* tags:
* - UI Preferences
* responses:
@@ -89,7 +98,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /ui-preferences:
* put:
* summary: Update the UI complexity preferences for the current user
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them.
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them. Onboarding seen versions are merged per step and never go down, so two open tabs cannot undo each other.
* tags:
* - UI Preferences
* requestBody:
@@ -164,10 +173,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
// Must come after the body spread, or a raw overrides payload would
// clobber the merge above.
overrides: mergedOverrides,
onboarding: {
...base.onboarding,
...((body.onboarding as Record<string, unknown>) ?? {}),
},
onboarding: mergeOnboarding(base, body.onboarding),
});
await repository.upsert(userId, JSON.stringify(merged));
@@ -182,4 +188,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
});
function mergeOnboarding(base: UiPreferences, patch: unknown) {
if (!patch || typeof patch !== "object") return base.onboarding;
const incoming = sanitizeOnboarding({
...base.onboarding,
...(patch as Record<string, unknown>),
seen: (patch as Record<string, unknown>).seen ?? {},
});
return {
...incoming,
seen: mergeSeen(base.onboarding.seen, incoming.seen),
completedAt: incoming.completedAt ?? base.onboarding.completedAt,
};
}
export default router;
@@ -123,7 +123,6 @@ export function registerUserAdminRoutes(
username: u.username,
is_admin: u.isAdmin,
is_external: isExternalAccount(u),
is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null,
// Management-only details stay admin-eyes-only; regular users hit
// this route to pick sharing targets.
@@ -470,15 +469,6 @@ export function registerUserAdminRoutes(
username,
passwordHash: password_hash,
isAdmin: false,
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
@@ -631,7 +621,7 @@ export function registerUserAdminRoutes(
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (targetUser.isOidc && !targetUser.passwordHash) {
if (targetUser.isExternal && !targetUser.passwordHash) {
return res.status(400).json({
error: "This user authenticates through an external provider",
});
@@ -58,7 +58,7 @@ export function registerUserExternalAccountRoutes(
* /users/link-external-to-password:
* post:
* summary: Merge an external account into a password account
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0. The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0.
* tags:
* - Users
* requestBody:
@@ -86,7 +86,7 @@ export function registerUserExternalAccountRoutes(
*/
const link = async (req: Request, res: Response) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
const externalUserId = req.body?.externalUserId;
const targetUsername = req.body?.targetUsername;
if (
@@ -143,11 +143,6 @@ export function registerUserExternalAccountRoutes(
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier:
externalUser.oidcIdentifier ?? targetUser.oidcIdentifier,
});
await identities.moveIdentities(externalUserId, targetUser.id);
await authManager.revokeAllUserSessions(externalUserId);
@@ -201,7 +196,7 @@ export function registerUserExternalAccountRoutes(
* /users/unlink-external-from-password:
* post:
* summary: Remove external sign-in from a password account
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
* description: Removes every external sign-in identity from an account that also has a password (admin only).
* tags:
* - Users
* requestBody:
@@ -261,10 +256,6 @@ export function registerUserExternalAccountRoutes(
});
}
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
});
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
targetUser.id,
);
@@ -307,7 +298,4 @@ export function registerUserExternalAccountRoutes(
router.post("/link-external-to-password", authenticateJWT, link);
router.post("/unlink-external-from-password", authenticateJWT, unlink);
// 2.8 paths, kept until 3.0.0.
router.post("/link-oidc-to-password", authenticateJWT, link);
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
}
@@ -173,7 +173,7 @@ export function registerUserPasswordResetRoutes(
});
}
if (user.isOidc) {
if (user.isExternal) {
return res.json({
message:
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
@@ -0,0 +1,64 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import type { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { isCookieOriginAllowed } from "../../utils/ws-auth.js";
type UserSocketTicketRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
export function registerUserSocketTicketRoutes(
router: Router,
{ authenticateJWT, authManager }: UserSocketTicketRoutesDeps,
): void {
/**
* @openapi
* /users/ws-ticket:
* post:
* summary: Get a WebSocket ticket
* description: Returns a token that is valid for one minute and only opens plugin WebSockets. The web app sends it as a subprotocol, so sockets work behind a proxy that rewrites the Host header. Only a page on this server can get one.
* tags:
* - Users
* responses:
* 200:
* description: The ticket.
* content:
* application/json:
* schema:
* type: object
* properties:
* ticket:
* type: string
* 401:
* description: Not authenticated, or not a login session.
* 403:
* description: The request came from a page on another site.
* 500:
* description: Failed to issue the ticket.
*/
router.post("/ws-ticket", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
const sessionId = authReq.sessionId;
if (!sessionId || authReq.apiKeyId) {
return res.status(401).json({ error: "A login session is required" });
}
// The cookie rides along from sibling subdomains too, so the ticket only
// goes to a page the browser says is this server.
if (req.cookies?.jwt && !isCookieOriginAllowed(req)) {
return res.status(403).json({ error: "Not allowed from this origin" });
}
try {
const ticket = await authManager.issueSocketTicket(
authReq.userId,
sessionId,
);
res.setHeader("Cache-Control", "no-store");
res.json({ ticket });
} catch (err) {
authLogger.error("Failed to issue a socket ticket", err);
res.status(500).json({ error: "Failed to issue a socket ticket" });
}
});
}
+16 -72
View File
@@ -11,6 +11,7 @@ import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { isDesktopAppOrigin } from "../../utils/cors-config.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
allowsDesktopAutoSession,
@@ -19,13 +20,13 @@ 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";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
@@ -55,6 +56,7 @@ import {
isNativeAppRequest,
syncSharedCredentialsForUserRoles,
} from "../../auth/session-issuer.js";
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
const authManager = AuthManager.getInstance();
@@ -184,15 +186,6 @@ router.post("/create", async (req, res) => {
id,
username,
passwordHash: password_hash,
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
try {
@@ -361,7 +354,7 @@ router.post("/proxy-login", async (req, res) => {
return res.status(403).json({ error: "Proxy user must already exist" });
}
if (
userRecord.isOidc ||
userRecord.isExternal ||
(await createCurrentUserAuthRepository().hasSecondFactor(userRecord.id))
) {
return res.status(409).json({
@@ -600,27 +593,18 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const isDualAuth =
hasPassword && isExternalAccount(user) && !!user.oidcIdentifier;
const showDonationModal = shouldShowDonationModal(
user.registeredAt,
!!user.donationModalDismissed,
);
const isDualAuth = hasPassword && isExternalAccount(user);
res.json({
userId: user.id,
username: user.username,
is_admin: !!user.isAdmin,
is_external: isExternalAccount(user),
// 2.8 name, kept until 3.0.0.
is_oidc: !!user.isOidc,
is_dual_auth: isDualAuth,
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
show_donation_modal: showDonationModal,
linked: await describeDesktopLink(user.id),
});
} catch (err) {
@@ -629,48 +613,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:
@@ -755,7 +697,11 @@ router.get("/setup-required", async (req, res) => {
*/
router.post("/internal/auto-session", async (req, res) => {
try {
if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) {
if (
!allowsDesktopAutoSession() ||
!isLoopbackRequest(req) ||
!isDesktopAppOrigin(req.headers.origin)
) {
authLogger.warn(
"Rejected non-loopback attempt to access auto-session endpoint",
{ source: req.ip },
@@ -944,7 +890,7 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
* /users/external-auto-provision:
* get:
* summary: Get the external account auto-create setting
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too.
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin).
* tags:
* - Users
* responses:
@@ -955,7 +901,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
try {
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"oidc_auto_provision",
EXTERNAL_AUTO_PROVISION_KEY,
false,
),
});
@@ -972,7 +918,7 @@ const getExternalAutoProvision: RequestHandler = async (_req, res) => {
* /users/external-auto-provision:
* patch:
* summary: Set the external account auto-create setting
* description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too.
* description: Enables or disables creating an account on first external sign-in.
* tags:
* - Users
* requestBody:
@@ -1006,7 +952,7 @@ const setExternalAutoProvision: RequestHandler = async (req, res) => {
return res.status(400).json({ error: "Invalid value for enabled" });
}
await createCurrentSettingsRepository().set(
"oidc_auto_provision",
EXTERNAL_AUTO_PROVISION_KEY,
enabled ? "true" : "false",
);
res.json({ enabled });
@@ -1022,9 +968,6 @@ router.patch(
authenticateJWT,
setExternalAutoProvision,
);
// 2.8 paths, kept until 3.0.0.
router.get("/oidc-auto-provision", getExternalAutoProvision);
router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision);
/**
* @openapi
@@ -1317,7 +1260,7 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isOidc) {
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
@@ -1588,6 +1531,7 @@ registerUserExternalAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);