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