mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35: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,
|
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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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())) {
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:`
|
||||||
|
|||||||
Reference in new issue
Block a user