fix: bundled plugins path for source runs and backend cleanup

This commit is contained in:
LukeGus committed 2026-10-09 07:37:46 -05:00
1 parent b0a0ee3e00
commit f803bfc140
51 files changed
+147 -163

No files matched your search

+2 -2
View File
@@ -1041,7 +1041,7 @@ function getBackendPaths() {
backendCwd: backendDir, backendCwd: backendDir,
}; };
} }
// fork() does not go through Electron's asar redirector — use the unpacked path. // fork() does not go through Electron's asar redirector, use the unpacked path.
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR // On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants. // app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
const unpackedRoot = getUnpackedAppRoot(appRoot); const unpackedRoot = getUnpackedAppRoot(appRoot);
@@ -1303,7 +1303,7 @@ function createTray() {
try { try {
const { nativeImage } = require("electron"); const { nativeImage } = require("electron");
// Native APIs (Tray, nativeImage) can't load files from inside app.asar — // Native APIs (Tray, nativeImage) can't load files from inside app.asar,
// use the unpacked path so the OS sees a real file. // use the unpacked path so the OS sees a real file.
const publicRoot = isDev const publicRoot = isDev
? path.join(appRoot, "public") ? path.join(appRoot, "public")
+2 -2
View File
@@ -280,12 +280,12 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* remoteVersion: * remoteVersion:
* type: string * type: string
* description: Newest release version on the channel. Present only when the update check succeeds. * description: Newest release version on the channel. Present only when the update check succeeds.
* example: 2.8.0 * example: 26.10.0
* version: * version:
* type: string * type: string
* deprecated: true * deprecated: true
* description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release. * description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release.
* example: 2.8.0 * example: 26.10.0
* latest_release: * latest_release:
* type: object * type: object
* description: GitHub release metadata. Present only when the update check succeeds. * description: GitHub release metadata. Present only when the update check succeeds.
+3 -3
View File
@@ -50,7 +50,7 @@ export function sslOption(
/** /**
* Opens a connection to a client-server engine. * Opens a connection to a client-server engine.
* *
* SQLite is not handled here — it has its own lifecycle in db/index.ts, where * SQLite is not handled here, it has its own lifecycle in db/index.ts, where
* the database is decrypted into memory and serialised back to a file. This * the database is decrypted into memory and serialised back to a file. This
* covers the engines that connect to something already running. * covers the engines that connect to something already running.
* *
@@ -109,7 +109,7 @@ export async function connectRemoteDatabase(
// No `schema` option: it only feeds drizzle's relational query API // No `schema` option: it only feeds drizzle's relational query API
// (`db.query.*`), which nothing here uses. The query builder takes its table // (`db.query.*`), which nothing here uses. The query builder takes its table
// names and value encoders from the table objects the repositories import — // names and value encoders from the table objects the repositories import,
// see the note in schema.pg.ts on why the generated schemas are DDL-only. // see the note in schema.pg.ts on why the generated schemas are DDL-only.
if (dialect === "postgres") { if (dialect === "postgres") {
const { drizzle } = await import("drizzle-orm/node-postgres"); const { drizzle } = await import("drizzle-orm/node-postgres");
@@ -119,7 +119,7 @@ export async function connectRemoteDatabase(
} }
// mysql2 names the pool limit differently and wants no `ssl` key at all when // mysql2 names the pool limit differently and wants no `ssl` key at all when
// TLS is off — passing false is not the same as omitting it. // TLS is off, passing false is not the same as omitting it.
const { drizzle } = await import("drizzle-orm/mysql2"); const { drizzle } = await import("drizzle-orm/mysql2");
return drizzle({ return drizzle({
connection: { connection: {
+1 -1
View File
@@ -42,7 +42,7 @@ export function resolveDatabaseDialect(
* *
* SQLite here is an in-memory database serialised back to an encrypted file, so * SQLite here is an in-memory database serialised back to an encrypted file, so
* every write needs a trigger to flush it. Client-server engines have already * every write needs a trigger to flush it. Client-server engines have already
* durably committed by the time the query returns — there is no file to write * durably committed by the time the query returns, there is no file to write
* and nothing to schedule. * and nothing to schedule.
*/ */
export function needsExplicitPersist(dialect: DatabaseDialect): boolean { export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
+2 -2
View File
@@ -1103,7 +1103,7 @@ const migrateSchema = () => {
// Derive the replacement table from the live definition instead of // Derive the replacement table from the live definition instead of
// restating it here. The table keeps gaining columns (cert_public_key, // restating it here. The table keeps gaining columns (cert_public_key,
// pin, sort_order, sync_id, ...), and a second copy of the column list // pin, sort_order, sync_id, ...), and a second copy of the column list
// falls behind every time one is added — leaving the copy narrower than // falls behind every time one is added, leaving the copy narrower than
// the table, so the INSERT below fails and the constraint stays put. // the table, so the INSERT below fails and the constraint stays put.
const createSql = sqlite const createSql = sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'") .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
@@ -2108,7 +2108,7 @@ export function getSqlite(): Database.Database {
if (dialect !== "sqlite") { if (dialect !== "sqlite") {
throw new Error( throw new Error(
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` + `No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
`synchronous query, which only SQLite offers — give it an async path instead.`, `synchronous query, which only SQLite offers. Give it an async path instead.`,
); );
} }
throw new Error( throw new Error(
+1 -1
View File
@@ -25,7 +25,7 @@ export function migrationsFolder(
* Brings a remote database up to the current schema. * Brings a remote database up to the current schema.
* *
* drizzle's migrator records what it has applied in its own table, so this is * drizzle's migrator records what it has applied in its own table, so this is
* safe to run on every start — including against a database another instance * safe to run on every start, including against a database another instance
* already migrated. * already migrated.
*/ */
export async function runRemoteMigrations( export async function runRemoteMigrations(
+5 -5
View File
@@ -1,10 +1,10 @@
// GENERATED FILE — do not edit. // GENERATED FILE, do not edit.
// //
// Produced from schema.ts by scripts/generate-dialect-schema.cjs. // Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. // Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: mysql. // Target dialect: mysql.
// //
// DDL source for drizzle-kit. NOT imported to run queries — repositories use // DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct. // schema.ts on every dialect. See the generator header for why that is correct.
import { import {
@@ -542,7 +542,7 @@ export const sharedHostSecrets = mysqlTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [ (table) => [
uniqueIndex("idx_shared_host_secrets_scope").on( uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId, table.hostAccessId,
@@ -592,7 +592,7 @@ export const userRoles = mysqlTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
// //
// The unique pair already serves lookups by user, since user_id leads it. // The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve. // Listing a role's members starts from role_id, which it cannot serve.
@@ -1060,7 +1060,7 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
/** /**
* Install counts populated by a background job (GitHub release download * Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the * counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so * install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state. * that job can overwrite counts without touching install state.
*/ */
export const pluginInstallCounts = mysqlTable( export const pluginInstallCounts = mysqlTable(
+5 -5
View File
@@ -1,10 +1,10 @@
// GENERATED FILE — do not edit. // GENERATED FILE, do not edit.
// //
// Produced from schema.ts by scripts/generate-dialect-schema.cjs. // Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`. // Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: postgres. // Target dialect: postgres.
// //
// DDL source for drizzle-kit. NOT imported to run queries — repositories use // DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct. // schema.ts on every dialect. See the generator header for why that is correct.
import { import {
@@ -542,7 +542,7 @@ export const sharedHostSecrets = pgTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [ (table) => [
uniqueIndex("idx_shared_host_secrets_scope").on( uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId, table.hostAccessId,
@@ -592,7 +592,7 @@ export const userRoles = pgTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
// //
// The unique pair already serves lookups by user, since user_id leads it. // The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve. // Listing a role's members starts from role_id, which it cannot serve.
@@ -1060,7 +1060,7 @@ export const pluginRegistries = pgTable("plugin_registries", {
/** /**
* Install counts populated by a background job (GitHub release download * Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the * counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so * install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state. * that job can overwrite counts without touching install state.
*/ */
export const pluginInstallCounts = pgTable( export const pluginInstallCounts = pgTable(
+3 -3
View File
@@ -534,7 +534,7 @@ export const sharedHostSecrets = sqliteTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [ (table) => [
uniqueIndex("idx_shared_host_secrets_scope").on( uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId, table.hostAccessId,
@@ -584,7 +584,7 @@ export const userRoles = sqliteTable(
}, },
// Declared inline in the production DDL as UNIQUE(...), but never here, // Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the // so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on. // SQLite deployment forbids, and the upsert had nothing to conflict on.
// //
// The unique pair already serves lookups by user, since user_id leads it. // The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve. // Listing a role's members starts from role_id, which it cannot serve.
@@ -1058,7 +1058,7 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
/** /**
* Install counts populated by a background job (GitHub release download * Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the * counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so * install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state. * that job can overwrite counts without touching install state.
*/ */
export const pluginInstallCounts = sqliteTable( export const pluginInstallCounts = sqliteTable(
@@ -74,7 +74,7 @@ export class AuditLogRepository {
/** /**
* Cached row count backing the cap check. Static because the factory builds * Cached row count backing the cap check. Static because the factory builds
* a repository per call, so a per-instance count would never survive to be * a repository per call, so a per-instance count would never survive to be
* reused. Null means "unknown, re-read" — which is also how any path that * reused. Null means "unknown, re-read", which is also how any path that
* deletes rows invalidates it. * deletes rows invalidates it.
*/ */
private static cachedCount: number | null = null; private static cachedCount: number | null = null;
@@ -233,13 +233,13 @@ export class AuditLogRepository {
* *
* Both passes used to run inline on every insert: a retention DELETE plus a * Both passes used to run inline on every insert: a retention DELETE plus a
* COUNT over the whole table, thousands of times an hour on a busy install, * COUNT over the whole table, thousands of times an hour on a busy install,
* almost always to find nothing to do — and audit writes sit in the request * almost always to find nothing to do, and audit writes sit in the request
* path of the actions they record. * path of the actions they record.
* *
* They are split by what they cost and what they guarantee. Retention is * They are split by what they cost and what they guarantee. Retention is
* time-based, so nothing is lost by checking it on an interval. The row cap * time-based, so nothing is lost by checking it on an interval. The row cap
* is a disk-space guard that has to react to inserts, so it is still checked * is a disk-space guard that has to react to inserts, so it is still checked
* on the write that crosses it — but against a cached count, so the common * on the write that crosses it, but against a cached count, so the common
* case is an integer compare rather than a COUNT. * case is an integer compare rather than a COUNT.
*/ */
private async pruneIfDue(): Promise<void> { private async pruneIfDue(): Promise<void> {
@@ -2,7 +2,7 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type * as schema from "../db/schema.js"; import type * as schema from "../db/schema.js";
// Re-exported so repositories can keep importing it from here, but defined in // Re-exported so repositories can keep importing it from here, but defined in
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and // db/dialect.ts, a local copy that said "sqlite" survived here for a while and
// typed every context as SQLite-only while the runtime already carried all // typed every context as SQLite-only while the runtime already carried all
// three, which silently made the dialect branches unreachable to the checker. // three, which silently made the dialect branches unreachable to the checker.
export type { DatabaseDialect } from "../db/dialect.js"; export type { DatabaseDialect } from "../db/dialect.js";
@@ -18,7 +18,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
* *
* This is a deliberate approximation, not an accident. The query-builder * This is a deliberate approximation, not an accident. The query-builder
* surface the repositories actually use is the same on all three engines, and * surface the repositories actually use is the same on all three engines, and
* that equivalence is asserted in multi-dialect.test.ts rather than assumed — * that equivalence is asserted in multi-dialect.test.ts rather than assumed,
* identifier quoting, placeholder style and value coercion are all covered * identifier quoting, placeholder style and value coercion are all covered
* there. At runtime this may hold a Postgres or MySQL instance. * there. At runtime this may hold a Postgres or MySQL instance.
* *
@@ -32,7 +32,7 @@ export type PortableDatabase = BetterSQLite3Database<typeof schema>;
* *
* Deliberately drizzle-only: with no raw driver handle here, no repository can * Deliberately drizzle-only: with no raw driver handle here, no repository can
* reach for engine-specific SQL. Retention queries that previously needed * reach for engine-specific SQL. Retention queries that previously needed
* `datetime('now', ?)` compute their cutoff in JS instead — see * `datetime('now', ?)` compute their cutoff in JS instead, see
* ./sql-timestamp.ts. * ./sql-timestamp.ts.
*/ */
export interface DatabaseContext { export interface DatabaseContext {
+5 -5
View File
@@ -49,7 +49,7 @@ import { UserRepository } from "./user-repository.js";
* write. * write.
* *
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither * Both cross-dialect harnesses build a DatabaseContext themselves, so neither
* exercises this function — see tests/database/repositories/factory-context. * exercises this function, see tests/database/repositories/factory-context.
*/ */
export function createCurrentRepositoryContext(): DatabaseContext { export function createCurrentRepositoryContext(): DatabaseContext {
return { return {
@@ -64,7 +64,7 @@ export function createCurrentRepositoryContext(): DatabaseContext {
* Only meaningful for SQLite, where the database lives in memory and has to be * Only meaningful for SQLite, where the database lives in memory and has to be
* serialised back to its encrypted file. On Postgres and MySQL the write is * serialised back to its encrypted file. On Postgres and MySQL the write is
* already durable, so no hook is installed at all rather than one that does * already durable, so no hook is installed at all rather than one that does
* nothing — repositories call it as `this.onWrite?.()`. * nothing, repositories call it as `this.onWrite?.()`.
*/ */
export function createCurrentRepositoryWriteHook( export function createCurrentRepositoryWriteHook(
reason: string, reason: string,
@@ -91,7 +91,7 @@ export function createCurrentRepositoryLazyWriteHook(
} }
/** /**
* Raw driver handle for the few synchronous call sites that cannot await — * Raw driver handle for the few synchronous call sites that cannot await,
* getCurrentSettingValue below, and settings reads during startup. Repositories * getCurrentSettingValue below, and settings reads during startup. Repositories
* must not use this: they take a DatabaseContext, which is drizzle-only. * must not use this: they take a DatabaseContext, which is drizzle-only.
* Porting to another engine means giving these callers an async path first. * Porting to another engine means giving these callers an async path first.
@@ -403,8 +403,8 @@ let refreshTimer: NodeJS.Timeout | null = null;
* *
* The cache is per-process and updated in the process that writes. That is * The cache is per-process and updated in the process that writes. That is
* enough for SQLite, where there is only ever one process. On Postgres and * enough for SQLite, where there is only ever one process. On Postgres and
* MySQL — which exist here precisely so more than one instance can share the * MySQL, which exist here precisely so more than one instance can share the
* data — a setting changed on one replica would otherwise never reach the * data, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read has no way to go back to the database. * others, because the synchronous read has no way to go back to the database.
* *
* Periodic re-priming does not make the value immediately consistent. It bounds * Periodic re-priming does not make the value immediately consistent. It bounds
@@ -31,7 +31,7 @@ export class HostFolderRepository {
now = new Date().toISOString(), now = new Date().toISOString(),
): Promise<RenameFolderResult> { ): Promise<RenameFolderResult> {
// CAST target: every engine spells the text type differently enough to // CAST target: every engine spells the text type differently enough to
// matter here — MySQL has no `text` cast and wants `char`. // matter here, MySQL has no `text` cast and wants `char`.
const textType = this.context.dialect === "mysql" ? "char" : "text"; const textType = this.context.dialect === "mysql" ? "char" : "text";
const oldPrefix = `${oldName} / `; const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `; const newPrefix = `${newName} / `;
@@ -4,18 +4,18 @@ import type { DatabaseDialect } from "../db/dialect.js";
* Reading the outcome of a write without depending on RETURNING. * Reading the outcome of a write without depending on RETURNING.
* *
* SQLite and Postgres can attach `.returning()` to a delete or update and get * SQLite and Postgres can attach `.returning()` to a delete or update and get
* the affected rows back. **MySQL cannot** — it has no RETURNING clause, and * the affected rows back. **MySQL cannot**: it has no RETURNING clause, and
* drizzle's mysql-core does not expose the method at all, so the call is a * drizzle's mysql-core does not expose the method at all, so the call is a
* TypeError rather than a bad query. 175 call sites here read a write's result, * TypeError rather than a bad query. 175 call sites here read a write's result,
* so the difference has to be absorbed somewhere. * so the difference has to be absorbed somewhere.
* *
* The split that matters is what the caller actually needs: * The split that matters is what the caller actually needs:
* *
* - **How many rows changed** — the majority, and none of them need the rows. * - **How many rows changed**: the majority, and none of them need the rows.
* They used to ask for them anyway, via `.returning().length`. Dropping the * They used to ask for them anyway, via `.returning().length`. Dropping the
* `.returning()` and reading the driver's own count is both portable and one * `.returning()` and reading the driver's own count is both portable and one
* less thing for the database to send back. * less thing for the database to send back.
* - **The rows themselves** — cannot be emulated on MySQL without reading * - **The rows themselves**: cannot be emulated on MySQL without reading
* first, which needs a transaction to stay correct under concurrency. Those * first, which needs a transaction to stay correct under concurrency. Those
* call sites are handled individually rather than behind a helper that hides * call sites are handled individually rather than behind a helper that hides
* an extra round trip. * an extra round trip.
@@ -33,7 +33,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
* | node-postgres | `{ rowCount, rows, command }` | * | node-postgres | `{ rowCount, rows, command }` |
* | mysql2 | `[{ affectedRows, insertId }, fields]` | * | mysql2 | `[{ affectedRows, insertId }, fields]` |
* *
* These are the shapes returned when NO `.returning()` is attached — which is * These are the shapes returned when NO `.returning()` is attached, which is
* the portable way to write, since MySQL has no RETURNING clause at all. * the portable way to write, since MySQL has no RETURNING clause at all.
*/ */
interface WriteHeader { interface WriteHeader {
@@ -47,7 +47,7 @@ interface WriteHeader {
const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const; const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const;
/** /**
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so * mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array, so
* "is it an array" cannot distinguish a write header from a returning() result. * "is it an array" cannot distinguish a write header from a returning() result.
* The header is identified by carrying one of the fields above instead. * The header is identified by carrying one of the fields above instead.
*/ */
@@ -70,7 +70,7 @@ function asWriteHeader(result: unknown): WriteHeader | null {
/** /**
* Number of rows a write touched. * Number of rows a write touched.
* *
* Pass the result of the write itself — every driver's header is understood, so * Pass the result of the write itself, every driver's header is understood, so
* the caller neither branches on the dialect nor attaches `.returning()` just to * the caller neither branches on the dialect nor attaches `.returning()` just to
* count what came back. * count what came back.
* *
@@ -97,7 +97,7 @@ export function rowsAffected(result: unknown): number {
* *
* **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and * **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and
* MySQL's `insertId` are connection-level values that survive the statement that * MySQL's `insertId` are connection-level values that survive the statement that
* set them — after a delete, SQLite still reports whatever the last insert * set them, after a delete, SQLite still reports whatever the last insert
* produced. Passing an update or delete result here gets a stale id, not null. * produced. Passing an update or delete result here gets a stale id, not null.
* *
* Returns null when the table has no autoincrement key. * Returns null when the table has no autoincrement key.
@@ -143,7 +143,7 @@ export function supportsReturning(dialect: DatabaseDialect): boolean {
* *
* `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT() * `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT()
* as bigint, which node-postgres hands back as a **string** so that values past * as bigint, which node-postgres hands back as a **string** so that values past
* 2^53 survive — so the annotation is a lie there and comparisons like * 2^53 survive, so the annotation is a lie there and comparisons like
* `count < max` compare a string to a number. * `count < max` compare a string to a number.
*/ */
export function countValue(value: unknown): number { export function countValue(value: unknown): number {
+12 -12
View File
@@ -11,15 +11,15 @@ import {
* Writes that need the affected rows back. * Writes that need the affected rows back.
* *
* `mutation-result.ts` covers the call sites that only wanted a count. These are * `mutation-result.ts` covers the call sites that only wanted a count. These are
* the ones that genuinely read the rows — an updated record to return to the * the ones that genuinely read the rows, an updated record to return to the
* caller, a deleted row's fields to clean up alongside it. * caller, a deleted row's fields to clean up alongside it.
* *
* SQLite and Postgres do this in one statement with RETURNING. MySQL has no * SQLite and Postgres do this in one statement with RETURNING. MySQL has no
* such clause, so the read is a second statement, and the pair has to be atomic: * such clause, so the read is a second statement, and the pair has to be atomic:
* *
* - **insert** — write, then read the row back by its key. * - **insert**: write, then read the row back by its key.
* - **update** — write, then read. Reading first would return the old values. * - **update**: write, then read. Reading first would return the old values.
* - **delete** — read, then write. Reading after would return nothing. * - **delete**: read, then write. Reading after would return nothing.
* *
* Both run in a transaction. Without one, a concurrent write between the two * Both run in a transaction. Without one, a concurrent write between the two
* statements makes the returned rows describe a state that never existed, and * statements makes the returned rows describe a state that never existed, and
@@ -29,7 +29,7 @@ import {
* ## The trap, and why it cannot bite silently * ## The trap, and why it cannot bite silently
* *
* On MySQL the update path re-reads using the same `where`. If the update * On MySQL the update path re-reads using the same `where`. If the update
* changes a column that `where` tests, the read finds nothing — SQLite would * changes a column that `where` tests, the read finds nothing, SQLite would
* have returned the row. Every current caller filters on an id it does not * have returned the row. Every current caller filters on an id it does not
* modify, but that is a convention, not a guarantee, so the mismatch is * modify, but that is a convention, not a guarantee, so the mismatch is
* detected and thrown rather than returned as an empty array. Same for an * detected and thrown rather than returned as an empty array. Same for an
@@ -40,7 +40,7 @@ import {
*/ */
/** /**
* What `.set()` accepts: a column's own type, or a SQL expression in its place — * What `.set()` accepts: a column's own type, or a SQL expression in its place,
* `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here. * `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here.
*/ */
type UpdateValues<T extends SQLiteTable> = { type UpdateValues<T extends SQLiteTable> = {
@@ -68,7 +68,7 @@ export async function updateReturning<T extends SQLiteTable>(
const rows = await tx.select().from(table).where(where); const rows = await tx.select().from(table).where(where);
// The trap this catches: if the update changed a column that `where` tests, // The trap this catches: if the update changed a column that `where` tests,
// the read finds nothing and the caller gets [] — on MySQL only, with no // the read finds nothing and the caller gets [], on MySQL only, with no
// error, where SQLite would have returned the row. Rows changed but none // error, where SQLite would have returned the row. Rows changed but none
// readable back is exactly that case, so make it loud instead. // readable back is exactly that case, so make it loud instead.
if (rows.length === 0 && rowsAffected(written) > 0) { if (rows.length === 0 && rowsAffected(written) > 0) {
@@ -108,10 +108,10 @@ type Keyed = SQLiteTable & { id: SQLiteColumn };
/** /**
* Inserts one row and returns it as stored, including whatever the database * Inserts one row and returns it as stored, including whatever the database
* filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP. * filled in, defaults, an autoincrement id, a CURRENT_TIMESTAMP.
* *
* This is the one case Postgres cannot shortcut either: without RETURNING there * This is the one case Postgres cannot shortcut either: without RETURNING there
* is no id to read back by. Hence the split is genuinely three-way — except * is no id to read back by. Hence the split is genuinely three-way, except
* that sqlite and pg both have RETURNING, so it collapses to two again. * that sqlite and pg both have RETURNING, so it collapses to two again.
* *
* On MySQL the key comes from one of two places: * On MySQL the key comes from one of two places:
@@ -161,7 +161,7 @@ export async function insertReturning<T extends Keyed>(
* back by an explicit condition. * back by an explicit condition.
* *
* `user_preferences` is keyed by `userId` and has no `id` column at all, so * `user_preferences` is keyed by `userId` and has no `id` column at all, so
* there is no insertId to read back by — the caller has to say what identifies * there is no insertId to read back by, the caller has to say what identifies
* the row it just wrote. * the row it just wrote.
*/ */
export async function insertReturningWhere<T extends SQLiteTable>( export async function insertReturningWhere<T extends SQLiteTable>(
@@ -195,13 +195,13 @@ export async function insertReturningWhere<T extends SQLiteTable>(
* *
* The clause has three spellings. SQLite and Postgres take * The clause has three spellings. SQLite and Postgres take
* `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and * `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and
* names no columns** — it uses whichever unique key was violated. drizzle * names no columns**: it uses whichever unique key was violated. drizzle
* follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and * follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and
* calling it is a TypeError rather than a rejected query. * calling it is a TypeError rather than a rejected query.
* *
* The conflict target still has to be passed: it is what SQLite and Postgres * The conflict target still has to be passed: it is what SQLite and Postgres
* need, and stating it keeps the caller honest about which unique constraint it * need, and stating it keeps the caller honest about which unique constraint it
* is relying on — four of those were missing from the schema entirely until the * is relying on, four of those were missing from the schema entirely until the
* cross-dialect tests went looking. * cross-dialect tests went looking.
*/ */
export async function upsert<T extends SQLiteTable>( export async function upsert<T extends SQLiteTable>(
@@ -1,7 +1,7 @@
/** /**
* Synchronous read-through cache for the settings table. * Synchronous read-through cache for the settings table.
* *
* 27 call sites read settings synchronously — during startup, inside request * 27 call sites read settings synchronously, during startup, inside request
* handlers, and from the guacd server bootstrap. On SQLite that works because * handlers, and from the guacd server bootstrap. On SQLite that works because
* better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous * better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous
* query at all, and making all 27 async would push `await` through code paths * query at all, and making all 27 async would push `await` through code paths
@@ -35,7 +35,7 @@ export function primeSettingsCache(
/** /**
* Reads a cached setting. * Reads a cached setting.
* *
* Returns null both for "not set" and "cache not primed yet" — every caller * Returns null both for "not set" and "cache not primed yet", every caller
* already treats a missing setting as "use the default", and startup ordering * already treats a missing setting as "use the default", and startup ordering
* means a read before priming should behave the same way rather than throw. * means a read before priming should behave the same way rather than throw.
*/ */
@@ -141,8 +141,8 @@ export class UserRepository {
* at once would otherwise both see an empty table and both become admin. * at once would otherwise both see an empty table and both become admin.
* *
* The two branches are not a style choice. better-sqlite3 is synchronous and * The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright — "Transaction function * rejects an async transaction callback outright, "Transaction function
* cannot return a promise" — so a single body cannot serve both. It fails * cannot return a promise", so a single body cannot serve both. It fails
* loudly rather than silently skipping the write, which is the one mercy here. * loudly rather than silently skipping the write, which is the one mercy here.
*/ */
private async createCheckingIfFirst( private async createCheckingIfFirst(
@@ -17,7 +17,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
* /credential-sidebar/preferences: * /credential-sidebar/preferences:
* get: * get:
* summary: Get the credential sidebar preferences for the current user * summary: Get the credential sidebar preferences for the current user
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here — credentials never had exploded preference columns on userPreferences — so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT. * description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here, credentials never had exploded preference columns on userPreferences, so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
* tags: * tags:
* - Credential Sidebar * - Credential Sidebar
* responses: * responses:
+8 -8
View File
@@ -9,6 +9,14 @@ const router = express.Router();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
/** /**
* @openapi * @openapi
* /open-tabs: * /open-tabs:
@@ -20,14 +28,6 @@ const authenticateJWT = authManager.createAuthMiddleware();
* 200: * 200:
* description: List of open tabs ordered by tab_order. * description: List of open tabs ordered by tab_order.
*/ */
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => { router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
+5 -2
View File
@@ -17,7 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js"; import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js"; import { parsePluginChannel } from "../../plugins/registry-index.js";
import { import {
pluginDocsUrl, pluginDocsUrl,
@@ -2262,7 +2262,10 @@ router.post(
"/upload", "/upload",
authenticateJWT, authenticateJWT,
requireManagePlugins, requireManagePlugins,
express.raw({ type: "application/octet-stream", limit: "50mb" }), express.raw({
type: "application/octet-stream",
limit: MAX_UPLOAD_BYTES,
}),
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
if (await refuseOnLinkedDesktop(res)) return; if (await refuseOnLinkedDesktop(res)) return;
if (!Buffer.isBuffer(req.body) || req.body.length === 0) { if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
+1 -1
View File
@@ -16,7 +16,7 @@ export function normalizeHostAddress(value: unknown): string {
* A client addresses a host by the numeric row id of the database it is * A client addresses a host by the numeric row id of the database it is
* displaying. When the desktop app delegates a connection to a remote sync * displaying. When the desktop app delegates a connection to a remote sync
* server, the id is resolved against *that* server's table instead, and * server, the id is resolved against *that* server's table instead, and
* autoincrement ids need not line up between the two — they diverge as soon as * autoincrement ids need not line up between the two, they diverge as soon as
* the sides accumulate inserts and deletes in a different order. * the sides accumulate inserts and deletes in a different order.
* *
* The resolved row then supplies the address, the credentials, the jump hosts * The resolved row then supplies the address, the credentials, the jump hosts
+1 -1
View File
@@ -25,7 +25,7 @@ const sshLogger = logger;
* `id` is an autoincrement belonging to whichever database produced the row. * `id` is an autoincrement belonging to whichever database produced the row.
* When the desktop app delegates a connection to a sync server, the two * When the desktop app delegates a connection to a sync server, the two
* sequences have no reason to agree, and resolving the client's id here lands * sequences have no reason to agree, and resolving the client's id here lands
* on whatever host happens to own that number — a different machine, with its * on whatever host happens to own that number, a different machine, with its
* own address, credentials and host key. `syncId` is the same string on both * own address, credentials and host key. `syncId` is the same string on both
* sides, so it names the host the user actually picked. * sides, so it names the host the user actually picked.
* *
@@ -8,10 +8,7 @@
* needs no core change. * needs no core change.
*/ */
import { import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
createCurrentHostProtocolAuthRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../../database/repositories/factory.js";
import { import {
decryptProtocolLogin, decryptProtocolLogin,
type HostProtocolAuthRecord, type HostProtocolAuthRecord,
@@ -605,17 +602,6 @@ export async function resolveRecipientProtocolLogin(
}; };
} }
/** Credential ids a recipient chose for this host, per protocol. */
export async function listRecipientOverrideIds(
hostId: number,
userId: string,
): Promise<Record<string, number>> {
return (await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
hostId,
userId,
)) as Record<string, number>;
}
/** A login in an export or on the sync wire: plaintext, keyed by protocol. */ /** A login in an export or on the sync wire: plaintext, keyed by protocol. */
export interface PortableProtocolLogin { export interface PortableProtocolLogin {
authType: string; authType: string;
+1 -1
View File
@@ -169,7 +169,7 @@ export class SSHConnectionPool {
if (available) { if (available) {
if (!this.isConnectionHealthy(available.client)) { if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available); connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again. // Fall through, may create or wait again.
} else { } else {
const waiter = queue.shift()!; const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key); if (queue.length === 0) this.waiters.delete(key);
-5
View File
@@ -61,8 +61,3 @@ export function startPluginAutoUpdate(): void {
timer = setInterval(run, CHECK_EVERY_MS); timer = setInterval(run, CHECK_EVERY_MS);
timer.unref?.(); timer.unref?.();
} }
export function stopPluginAutoUpdate(): void {
if (timer) clearInterval(timer);
timer = null;
}
+9 -18
View File
@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import {
@@ -43,29 +42,21 @@ export function getUnpackedPluginsDir(): string {
* plugin is part of the install and must not be deletable or shadowable by * plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory. * whatever happens to be in the data directory.
* *
* Resolved from this module's location rather than cwd, because the build * Resolved from this module's location rather than cwd. The build emits to
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside * dist/backend/backend/plugins/ with the bundled plugins beside dist/backend,
* dist/backend. * and a run from source reads the same dist/plugins.
*/ */
export function getBundledPluginsDir(): string { export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR; const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
if (override) return override; if (override) return override;
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
// Built: dist/backend/backend/plugins -> dist/plugins
const candidates = [ // Source (vitest, tsx): src/backend/plugins -> dist/plugins
// Built server: dist/backend/backend/plugins -> dist/plugins const fromSource = path.basename(path.resolve(here, "../..")) === "src";
// Source tree: src/backend/plugins -> plugins return fromSource
// Both are three levels up, which is why there is one entry, not two. ? path.resolve(here, "../../../dist/plugins")
path.resolve(here, "../../../plugins"), : path.resolve(here, "../../../plugins");
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
path.resolve(here, "../../../../plugins"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return candidates[0];
} }
/** The backend entry, from the manifest's own `backend` field. */ /** The backend entry, from the manifest's own `backend` field. */
-4
View File
@@ -371,10 +371,6 @@ function disposeRoute(routeKey: string, pluginId: string, path: string): void {
}); });
} }
export function getRegisteredWsRoutes(): string[] {
return [...routes.keys()];
}
/** The socket paths a plugin serves without core's login check. */ /** The socket paths a plugin serves without core's login check. */
export function getPluginPublicWsRoutes(pluginId: string): string[] { export function getPluginPublicWsRoutes(pluginId: string): string[] {
return [...routes.values()] return [...routes.values()]
-5
View File
@@ -126,11 +126,6 @@ export async function reconcileUser(
return { changed, wire }; return { changed, wire };
} }
/** Forgets a user's snapshot so the next reconcile reads everything. */
export function invalidateSnapshot(userId: string): void {
snapshots.delete(userId);
}
const listeners = new Map<string, Set<Response>>(); const listeners = new Map<string, Set<Response>>();
let debounce: NodeJS.Timeout | null = null; let debounce: NodeJS.Timeout | null = null;
let heartbeat: NodeJS.Timeout | null = null; let heartbeat: NodeJS.Timeout | null = null;
-6
View File
@@ -64,12 +64,6 @@ export function locate(
return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!; return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!;
} }
export function rowSyncId(entity: RegisteredSyncEntity, row: SyncRow): string {
return entity.singleton
? singletonSyncId(entity.type)
: (row.syncId as string);
}
let hashKey: Buffer | null = null; let hashKey: Buffer | null = null;
export async function getHashKey(): Promise<Buffer> { export async function getHashKey(): Promise<Buffer> {
@@ -10,8 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
* *
* The Drizzle schema said so, the repository was written against it, but the * The Drizzle schema said so, the repository was written against it, but the
* runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected * runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected
* `CREATE TABLE IF NOT EXISTS` further down was a no-op — the table already * `CREATE TABLE IF NOT EXISTS` further down was a no-op, the table already
* existed — so every fresh install got the old constraint and every user * existed, so every fresh install got the old constraint and every user
* deletion (including the OIDC account-link cleanup) failed once the account * deletion (including the OIDC account-link cleanup) failed once the account
* had logged in at least once. * had logged in at least once.
*/ */
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a * `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
* number of tables that the primary bootstrap already creates. The probe never * number of tables that the primary bootstrap already creates. The probe never
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one * threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran, and one
* of those unreachable copies had drifted away from the real definition * of those unreachable copies had drifted away from the real definition
* (`sessions` had lost `ON DELETE CASCADE`). * (`sessions` had lost `ON DELETE CASCADE`).
* *
@@ -61,7 +61,7 @@ describe("bootstrap creates the tables the removed probes covered", () => {
}); });
it("creates them on a database old enough to predate them", async () => { it("creates them on a database old enough to predate them", async () => {
// A database with users and hosts but none of the tables above — the // A database with users and hosts but none of the tables above, the
// upgrade path the deleted probes appeared to be protecting. // upgrade path the deleted probes appeared to be protecting.
const seed = new Database(":memory:"); const seed = new Database(":memory:");
seed.exec(` seed.exec(`
@@ -59,7 +59,7 @@ describe("needsExplicitPersist", () => {
/** /**
* schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the * schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the
* generated output is usable rather than merely syntactically valid — the * generated output is usable rather than merely syntactically valid, the
* repository layer's correctness rests on all three behaving the same way. * repository layer's correctness rests on all three behaving the same way.
*/ */
describe("generated schemas", () => { describe("generated schemas", () => {
@@ -133,7 +133,7 @@ describe("generated schemas", () => {
* SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and * SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and
* MySQL only ever get what the migrations declare, which comes from schema.ts. * MySQL only ever get what the migrations declare, which comes from schema.ts.
* Anything listed in one and missing from the other is an index the engines * Anything listed in one and missing from the other is an index the engines
* chosen for scale silently do without — which is how 31 of them went missing. * chosen for scale silently do without, which is how 31 of them went missing.
*/ */
it("declares every performance index in schema.ts", () => { it("declares every performance index in schema.ts", () => {
// Both are the leading column of an existing composite unique index, which // Both are the leading column of an existing composite unique index, which
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* Sharing a host used to hand the owner's SSH authentication to the recipient * Sharing a host used to hand the owner's SSH authentication to the recipient
* unconditionally. 2.6.1 put that behind `ssh_data.share_ssh_auth`, added as * unconditionally. 2.6.1 put that behind `ssh_data.share_ssh_auth`, added as
* `NOT NULL DEFAULT 0` — so every host shared before the upgrade silently * `NOT NULL DEFAULT 0`, so every host shared before the upgrade silently
* stopped supplying credentials, and recipients hit "No valid authentication * stopped supplying credentials, and recipients hit "No valid authentication
* method provided" on hosts that had worked the day before. * method provided" on hosts that had worked the day before.
* *
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** /**
* `ssh_credentials.username` became nullable when key-only credentials landed, * `ssh_credentials.username` became nullable when key-only credentials landed,
* and databases created before that are rebuilt on startup to drop the * and databases created before that are rebuilt on startup to drop the
* constraint — SQLite cannot ALTER a column. * constraint, SQLite cannot ALTER a column.
* *
* The rebuild restated the table's columns as a literal, then copied rows with * The rebuild restated the table's columns as a literal, then copied rows with
* `INSERT INTO temp SELECT <every live column>`. The table has gained columns * `INSERT INTO temp SELECT <every live column>`. The table has gained columns
@@ -17,7 +17,7 @@ describe("rowsAffected", () => {
}); });
it("reads changes from a better-sqlite3 write result", () => { it("reads changes from a better-sqlite3 write result", () => {
// The shape of a write with no .returning() attached — verified against // The shape of a write with no .returning() attached, verified against
// the driver, not assumed. // the driver, not assumed.
expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1); expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1);
expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0); expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0);
@@ -81,7 +81,7 @@ describe("supportsReturning", () => {
expect(supportsReturning("sqlite")).toBe(true); expect(supportsReturning("sqlite")).toBe(true);
expect(supportsReturning("postgres")).toBe(true); expect(supportsReturning("postgres")).toBe(true);
// No RETURNING clause in MySQL, and drizzle's mysql-core does not expose // No RETURNING clause in MySQL, and drizzle's mysql-core does not expose
// the method — call sites that need rows back must read first. // the method, call sites that need rows back must read first.
expect(supportsReturning("mysql")).toBe(false); expect(supportsReturning("mysql")).toBe(false);
}); });
}); });
@@ -11,7 +11,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
* The MySQL path cannot be exercised against a real engine here, and its whole * The MySQL path cannot be exercised against a real engine here, and its whole
* correctness is an ordering property: an update must be read AFTER the write, * correctness is an ordering property: an update must be read AFTER the write,
* a delete BEFORE it. Get either backwards and the rows describe the wrong * a delete BEFORE it. Get either backwards and the rows describe the wrong
* state — silently, with no error anywhere. * state, silently, with no error anywhere.
* *
* So the drizzle handle is stubbed and the order of calls is recorded. * So the drizzle handle is stubbed and the order of calls is recorded.
*/ */
@@ -169,7 +169,7 @@ describe("deleteReturning", () => {
await deleteReturning(context, {} as never, where); await deleteReturning(context, {} as never, where);
// Without this, a concurrent write between them makes the returned rows // Without this, a concurrent write between them makes the returned rows
// describe a state that never existed — and with a pool the second // describe a state that never existed, and with a pool the second
// statement need not even reach the same connection. // statement need not even reach the same connection.
expect(calls[0]).toBe("begin"); expect(calls[0]).toBe("begin");
expect(calls[calls.length - 1]).toBe("commit"); expect(calls[calls.length - 1]).toBe("commit");
@@ -8,8 +8,8 @@ import {
/** /**
* The settings cache lives in one process and is updated by whichever process * The settings cache lives in one process and is updated by whichever process
* wrote the setting. On SQLite that is the only process there is. On Postgres * wrote the setting. On SQLite that is the only process there is. On Postgres
* and MySQL — the reason those exist here is to let several instances share one * and MySQL, the reason those exist here is to let several instances share one
* database — a setting changed on one replica would otherwise never reach the * database, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read cannot go back to the database. * others, because the synchronous read cannot go back to the database.
* *
* Re-priming on a timer does not make settings immediately consistent. It * Re-priming on a timer does not make settings immediately consistent. It
@@ -57,7 +57,7 @@ describe("settings cache refresh", () => {
it("keeps running after a refresh throws", async () => { it("keeps running after a refresh throws", async () => {
// A transient database blip must not stop the loop, or the replica is stuck // A transient database blip must not stop the loop, or the replica is stuck
// on stale settings until it restarts — the exact failure this prevents. // on stale settings until it restarts, the exact failure this prevents.
const refresh = vi const refresh = vi
.fn() .fn()
.mockRejectedValueOnce(new Error("connection reset")) .mockRejectedValueOnce(new Error("connection reset"))
@@ -20,7 +20,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
* *
* Defaults to SQLite, so `npm test` behaves as it always has and needs no * Defaults to SQLite, so `npm test` behaves as it always has and needs no
* server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run * server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run
* the same tests against a real one — see the database-dialects CI job. * the same tests against a real one, see the database-dialects CI job.
*/ */
export function testDialect(env = process.env): DatabaseDialect { export function testDialect(env = process.env): DatabaseDialect {
const value = env.TEST_DIALECT?.trim().toLowerCase(); const value = env.TEST_DIALECT?.trim().toLowerCase();
@@ -125,7 +125,7 @@ export class TestSqliteDatabase {
* Portable write for test setup. * Portable write for test setup.
* *
* Separate from query() because better-sqlite3 refuses `.all()` on a * Separate from query() because better-sqlite3 refuses `.all()` on a
* statement that returns no rows — "This statement does not return data". * statement that returns no rows, "This statement does not return data".
*/ */
async run(statement: SQL): Promise<void> { async run(statement: SQL): Promise<void> {
const context = this.context; const context = this.context;
@@ -207,7 +207,7 @@ async function truncateAll(context: DatabaseContext): Promise<void> {
} }
/** /**
* Splits seed SQL into statements, ignoring semicolons inside string literals — * Splits seed SQL into statements, ignoring semicolons inside string literals,
* JSON payloads in the fixtures contain them. * JSON payloads in the fixtures contain them.
*/ */
function splitStatements(sql: string): string[] { function splitStatements(sql: string): string[] {
@@ -274,7 +274,7 @@ function portableSql(statement: string, dialect: DatabaseDialect): string {
if (dialect !== "mysql") return out; if (dialect !== "mysql") return out;
// Only the column list, before VALUES. A blanket replace also mangles the // Only the column list, before VALUES. A blanket replace also mangles the
// double quotes inside JSON payloads in the values — '{"slots":[]}' became // double quotes inside JSON payloads in the values, '{"slots":[]}' became
// '{`slots`:[]}', which is valid SQL and silently wrong data. // '{`slots`:[]}', which is valid SQL and silently wrong data.
const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out); const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out);
if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`"); if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`");
@@ -354,7 +354,7 @@ async function resyncAutoIncrement(
for (const table of tables) { for (const table of tables) {
// Only tables whose id is generated. A text primary key, like users.id, // Only tables whose id is generated. A text primary key, like users.id,
// has no sequence and no counter to move, and a table keyed on something // has no sequence and no counter to move, and a table keyed on something
// else entirely — host_sidebar_preferences.user_id — has no id at all. // else entirely, host_sidebar_preferences.user_id, has no id at all.
if (context.dialect === "postgres") { if (context.dialect === "postgres") {
// pg_get_serial_sequence() raises 42703 rather than returning null when // pg_get_serial_sequence() raises 42703 rather than returning null when
// the column is missing, so let information_schema decide whether there // the column is missing, so let information_schema decide whether there
@@ -408,7 +408,7 @@ async function resyncAutoIncrement(
* *
* Every test builds a fixture, and each would otherwise re-run the migrator * Every test builds a fixture, and each would otherwise re-run the migrator
* against the same shared database. drizzle's journal makes that a no-op only * against the same shared database. drizzle's journal makes that a no-op only
* when the first run finished — several fixtures racing inside one file hit * when the first run finished, several fixtures racing inside one file hit
* "table already exists" instead. * "table already exists" instead.
*/ */
const migrations = new Map<string, Promise<void>>(); const migrations = new Map<string, Promise<void>>();
@@ -436,7 +436,7 @@ let cachedSqliteSchema: string | null = null;
* The full schema, from the generated SQLite migrations rather than * The full schema, from the generated SQLite migrations rather than
* hand-written DDL in each test file. * hand-written DDL in each test file.
* *
* Tests used to declare a cut-down version of every table they touched — a * Tests used to declare a cut-down version of every table they touched, a
* `users` with five columns where the real one has thirty. That drifts from the * `users` with five columns where the real one has thirty. That drifts from the
* schema silently, and it is the reason the same tests could not be pointed at * schema silently, and it is the reason the same tests could not be pointed at
* another engine. * another engine.
@@ -19,7 +19,7 @@ import {
* *
* The resolved row supplies the address, the credentials, the jump hosts and * The resolved row supplies the address, the credentials, the jump hosts and
* the stored host key, so the session opened on whichever machine owned that * the stored host key, so the session opened on whichever machine owned that
* id on the server — the host list stayed correct the whole time, and nothing * id on the server, the host list stayed correct the whole time, and nothing
* announced the substitution. * announced the substitution.
*/ */
describe("hostAddressMismatch", () => { describe("hostAddressMismatch", () => {
@@ -26,7 +26,7 @@ vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
/** /**
* A numeric host id belongs to whichever database produced it. Resolving the * A numeric host id belongs to whichever database produced it. Resolving the
* desktop app's id against a sync server's table lands on whatever host owns * desktop app's id against a sync server's table lands on whatever host owns
* that number there — a different machine, with its own address, credentials * that number there, a different machine, with its own address, credentials
* and host key. `sync_id` is the same string on both sides. * and host key. `sync_id` is the same string on both sides.
*/ */
describe("resolveHostBySyncId", () => { describe("resolveHostBySyncId", () => {
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getBundledPluginsDir } from "../../plugins/paths.js";
describe("getBundledPluginsDir", () => {
const saved = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
afterEach(() => {
if (saved === undefined) delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
else process.env.TERMIX_BUNDLED_PLUGINS_DIR = saved;
});
it("reads dist/plugins when run from source", () => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
expect(getBundledPluginsDir()).toBe(
path.resolve(process.cwd(), "dist", "plugins"),
);
});
it("honors the override", () => {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/somewhere/else";
expect(getBundledPluginsDir()).toBe("/somewhere/else");
});
});
@@ -123,7 +123,7 @@ describe("forwardAuditEntry", () => {
); );
}); });
it("swallows transport errors — a dead SIEM must not break auditing", async () => { it("swallows transport errors, a dead SIEM must not break auditing", async () => {
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
@@ -140,7 +140,7 @@ describe("forwardAuditEntry", () => {
await forwardAuditEntry(ENTRY, NOW, env); await forwardAuditEntry(ENTRY, NOW, env);
} }
// 5 per-entry warnings, then one suppression notice — not 8. // 5 per-entry warnings, then one suppression notice, not 8.
const perEntry = logs.warn.mock.calls.filter( const perEntry = logs.warn.mock.calls.filter(
(call) => call[0] === "Failed to forward audit entry", (call) => call[0] === "Failed to forward audit entry",
); );
+1 -1
View File
@@ -22,7 +22,7 @@ function readForwardSetting(key: string): string | null {
/** /**
* How many consecutive failures before the forwarder stops complaining on every * How many consecutive failures before the forwarder stops complaining on every
* entry. It keeps trying — this only throttles the log noise, and it reports * entry. It keeps trying, this only throttles the log noise, and it reports
* again once delivery recovers. * again once delivery recovers.
*/ */
const QUIET_AFTER_FAILURES = 5; const QUIET_AFTER_FAILURES = 5;
+2 -2
View File
@@ -6,8 +6,8 @@ import { type Request, type RequestHandler, type Response } from "express";
* *
* The default is 1KB; this is slightly higher because almost every response * The default is 1KB; this is slightly higher because almost every response
* under a few KB here is a small status or preference object where the CPU and * under a few KB here is a small status or preference object where the CPU and
* the extra headers are not worth it. The payloads that matter — host lists, * the extra headers are not worth it. The payloads that matter, host lists,
* audit pages, fleet inventories — are orders of magnitude above this. * audit pages, fleet inventories, are orders of magnitude above this.
*/ */
const MIN_RESPONSE_BYTES = 2048; const MIN_RESPONSE_BYTES = 2048;
@@ -47,7 +47,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{
// Nothing to clean up on an engine this application has never run on. A // Nothing to clean up on an engine this application has never run on. A
// Postgres or MySQL database is created by the drizzle migrations, which have // Postgres or MySQL database is created by the drizzle migrations, which have
// never emitted these legacy columns, so there is nothing to drop — and the // never emitted these legacy columns, so there is nothing to drop, and the
// check itself needs a synchronous PRAGMA that only SQLite offers. // check itself needs a synchronous PRAGMA that only SQLite offers.
if (!needsExplicitPersist(resolveDatabaseDialect())) return result; if (!needsExplicitPersist(resolveDatabaseDialect())) return result;
+1 -1
View File
@@ -201,7 +201,7 @@ class DataCrypto {
migratedFieldsCount: number; migratedFieldsCount: number;
}> { }> {
// Only a database that predates field encryption has plaintext to migrate, // Only a database that predates field encryption has plaintext to migrate,
// and only SQLite deployments can predate it — Postgres and MySQL support // and only SQLite deployments can predate it, Postgres and MySQL support
// arrived after. The store also needs synchronous queries no other driver // arrived after. The store also needs synchronous queries no other driver
// has, so this would throw rather than find nothing to do. // has, so this would throw rather than find nothing to do.
if (!needsExplicitPersist(resolveDatabaseDialect())) { if (!needsExplicitPersist(resolveDatabaseDialect())) {
+1 -1
View File
@@ -14,7 +14,7 @@ export class DataDirMisconfiguredError extends Error {
/** /**
* Directories Termix has shipped or documented as a data location. A deployment * Directories Termix has shipped or documented as a data location. A deployment
* that loses DATA_DIR — an unloaded .env file, an unmounted volume — falls back * that loses DATA_DIR, an unloaded .env file, an unmounted volume, falls back
* to the default and finds an empty directory, which is indistinguishable from a * to the default and finds an empty directory, which is indistinguishable from a
* first run. Checking these tells the two apart. * first run. Checking these tells the two apart.
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Extract a stable, human-readable message from an unknown thrown value. * Extract a stable, human-readable message from an unknown thrown value.
* *
* A thrown value is not guaranteed to be an `Error` — libraries may throw * A thrown value is not guaranteed to be an `Error`, libraries may throw
* strings or plain objects, which carry no useful `.message`. When that * strings or plain objects, which carry no useful `.message`. When that
* happens the caller gets `fallback` instead, so error paths never surface a * happens the caller gets `fallback` instead, so error paths never surface a
* raw `[object Object]` in logs or responses. * raw `[object Object]` in logs or responses.
+1 -1
View File
@@ -295,7 +295,7 @@ class PermissionManager {
* queries instead of one call per host. * queries instead of one call per host.
* *
* canAccessHost costs between one and four queries, so filtering a list with * canAccessHost costs between one and four queries, so filtering a list with
* it is linear in host count — and the status poll does exactly that every * it is linear in host count, and the status poll does exactly that every
* few seconds for the whole fleet. This answers the same question for many * few seconds for the whole fleet. This answers the same question for many
* hosts at once using the same three rules, in the same order: owner, then * hosts at once using the same three rules, in the same order: owner, then
* an unexpired grant, then admin bypass. * an unexpired grant, then admin bypass.
+1 -1
View File
@@ -34,7 +34,7 @@ try {
// The pure-JS fallback in ssh2 for chacha20-poly1305@openssh.com is broken and // The pure-JS fallback in ssh2 for chacha20-poly1305@openssh.com is broken and
// corrupts the transport: the target sshd aborts the KEX with // corrupts the transport: the target sshd aborts the KEX with
// "ssh_dispatch_run_fatal: ... incomplete message [preauth]" and the client times out. // "ssh_dispatch_run_fatal: ... incomplete message [preauth]" and the client times out.
// A working OpenSSL "chacha20" cipher is NOT sufficient here — only the native // A working OpenSSL "chacha20" cipher is NOT sufficient here, only the native
// binding (sshcrypto.node) makes chacha20-poly1305 usable. Keep it disabled otherwise // binding (sshcrypto.node) makes chacha20-poly1305 usable. Keep it disabled otherwise
// so filterCiphers() drops it and the connection negotiates AES-GCM instead. // so filterCiphers() drops it and the connection negotiates AES-GCM instead.
ssh2BindingAvailable = false; ssh2BindingAvailable = false;
+1 -1
View File
@@ -7,7 +7,7 @@ import { SystemCrypto } from "./system-crypto.js";
* Per-user field encryption (DataCrypto/FieldCrypto) derives its key from the * Per-user field encryption (DataCrypto/FieldCrypto) derives its key from the
* user's DEK, which works for host passwords and SSH keys. It does not work for * user's DEK, which works for host passwords and SSH keys. It does not work for
* SSO provider configuration: `sso_providers` has no `userId`, and the OIDC * SSO provider configuration: `sso_providers` has no `userId`, and the OIDC
* client secret and LDAP bind password must be readable during login — before * client secret and LDAP bind password must be readable during login, before
* any user is authenticated, let alone unlocked. * any user is authenticated, let alone unlocked.
* *
* Those secrets were previously stored base64-encoded behind an `encoded:` * Those secrets were previously stored base64-encoded behind an `encoded:`