mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: give plugins their own tables, migrations and sync entities
This commit is contained in:
1 parent
5796186a1c
commit
c496a093ca
47 files changed
+32595
-290
No files matched your search
@@ -741,6 +741,16 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
migration_id TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (plugin_id, migration_id),
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -2440,29 +2450,20 @@ const migrateSchema = () => {
|
||||
// --- proxmox-node-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_rules LIMIT 1").get();
|
||||
} catch {
|
||||
// alert_rules, alert_rule_channels and alert_firings were only ever created
|
||||
// here, never declared in schema.ts, so they never existed on Postgres or
|
||||
// MySQL at all. Automations replaced them; drop the SQLite leftovers.
|
||||
for (const table of [
|
||||
"alert_firings",
|
||||
"alert_rule_channels",
|
||||
"alert_rules",
|
||||
]) {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_id INTEGER REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
threshold_value REAL,
|
||||
threshold_duration_seconds INTEGER,
|
||||
cooldown_minutes INTEGER NOT NULL DEFAULT 15,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_rules table", {
|
||||
sqlite.exec(`DROP TABLE IF EXISTS ${table};`);
|
||||
} catch (dropError) {
|
||||
databaseLogger.warn(`Failed to drop legacy ${table} table`, {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
error: dropError,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2489,54 +2490,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_rule_channels LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_rule_channels table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_firings LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_firings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TEXT,
|
||||
value REAL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
acknowledged INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_firings_user_fired
|
||||
ON alert_firings (user_id, fired_at DESC);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_firings table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- alerts end ---
|
||||
|
||||
// --- automations begin ---
|
||||
|
||||
@@ -2252,4 +2252,33 @@ export const pluginStorage = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
* The checksum is what makes an already-applied migration immutable: editing
|
||||
* one that has run blocks that plugin rather than silently leaving two
|
||||
* databases with different shapes. Cascades with the plugin so removing its
|
||||
* data leaves no ledger behind.
|
||||
*/
|
||||
export const pluginMigrations = mysqlTable(
|
||||
"plugin_migrations",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
pluginId: varchar("plugin_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
migrationId: varchar("migration_id", { length: 255 }).notNull(),
|
||||
checksum: text("checksum").notNull(),
|
||||
appliedAt: text("applied_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_migrations_plugin_migration").on(
|
||||
table.pluginId,
|
||||
table.migrationId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- plugins end ---
|
||||
@@ -2253,4 +2253,33 @@ export const pluginStorage = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
* The checksum is what makes an already-applied migration immutable: editing
|
||||
* one that has run blocks that plugin rather than silently leaving two
|
||||
* databases with different shapes. Cascades with the plugin so removing its
|
||||
* data leaves no ledger behind.
|
||||
*/
|
||||
export const pluginMigrations = pgTable(
|
||||
"plugin_migrations",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
pluginId: varchar("plugin_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
migrationId: varchar("migration_id", { length: 255 }).notNull(),
|
||||
checksum: text("checksum").notNull(),
|
||||
appliedAt: text("applied_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_migrations_plugin_migration").on(
|
||||
table.pluginId,
|
||||
table.migrationId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- plugins end ---
|
||||
@@ -2251,4 +2251,33 @@ export const pluginStorage = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have been applied.
|
||||
*
|
||||
* The checksum is what makes an already-applied migration immutable: editing
|
||||
* one that has run blocks that plugin rather than silently leaving two
|
||||
* databases with different shapes. Cascades with the plugin so removing its
|
||||
* data leaves no ledger behind.
|
||||
*/
|
||||
export const pluginMigrations = sqliteTable(
|
||||
"plugin_migrations",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
pluginId: text("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
migrationId: text("migration_id").notNull(),
|
||||
checksum: text("checksum").notNull(),
|
||||
appliedAt: text("applied_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_plugin_migrations_plugin_migration").on(
|
||||
table.pluginId,
|
||||
table.migrationId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- plugins end ---
|
||||
@@ -34,6 +34,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
|
||||
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
|
||||
@@ -393,6 +394,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
|
||||
return new PluginMigrationRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("plugin_migration_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
|
||||
return new PluginRegistryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { pluginMigrations } from "../db/schema.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type PluginMigrationRecord = typeof pluginMigrations.$inferSelect;
|
||||
|
||||
/**
|
||||
* Which of a plugin's migrations have run.
|
||||
*
|
||||
* The checksum is the reason this is a table rather than a file marker: it
|
||||
* makes an applied migration immutable, so an edited one is caught instead of
|
||||
* leaving two installs with quietly different schemas.
|
||||
*/
|
||||
export class PluginMigrationRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByPlugin(pluginId: string): Promise<PluginMigrationRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginId, pluginId));
|
||||
}
|
||||
|
||||
async record(
|
||||
pluginId: string,
|
||||
migrationId: string,
|
||||
checksum: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
await insertReturning(this.context, pluginMigrations, {
|
||||
pluginId,
|
||||
migrationId,
|
||||
checksum,
|
||||
appliedAt: now,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteByPlugin(pluginId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginId, pluginId));
|
||||
|
||||
const affected = rowsAffected(result);
|
||||
if (affected > 0) await this.afterWrite();
|
||||
return affected;
|
||||
}
|
||||
|
||||
async has(pluginId: string, migrationId: string): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginMigrations.pluginId, pluginId),
|
||||
eq(pluginMigrations.migrationId, migrationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,16 @@ export class PluginStorageRepository {
|
||||
return rows.map((row) => row.key);
|
||||
}
|
||||
|
||||
/** How many keys this plugin holds, for the per-plugin key cap. */
|
||||
async countKeys(pluginId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(pluginStorage)
|
||||
.where(eq(pluginStorage.pluginId, pluginId));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async set(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
|
||||
@@ -5,17 +5,16 @@ import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
|
||||
|
||||
export type SyncEntityType =
|
||||
| "hosts"
|
||||
| "sshCredentials"
|
||||
| "sshFolders"
|
||||
| "snippets"
|
||||
| "snippetFolders"
|
||||
| "vaultProfiles"
|
||||
| "dashboardServiceLinks"
|
||||
| "homepageItems"
|
||||
| "userPreferences"
|
||||
| "networkTopology";
|
||||
/**
|
||||
* The wire name of a synced entity.
|
||||
*
|
||||
* A string rather than a union: the set is whatever core and the installed
|
||||
* plugins have registered, so it cannot be known at compile time. The column
|
||||
* was already free-form text with no enum constraint, so nothing in the
|
||||
* database changes. sync.ts validates a name through the registry before it
|
||||
* reaches a query.
|
||||
*/
|
||||
export type SyncEntityType = string;
|
||||
|
||||
export class SyncTombstoneRepository {
|
||||
constructor(
|
||||
|
||||
@@ -461,4 +461,71 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/data:
|
||||
* delete:
|
||||
* summary: Delete everything a plugin stores
|
||||
* description: Drops the plugin's own tables and clears its key/value state, capability grants and migration ledger. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What was removed.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 500:
|
||||
* description: Failed to remove the plugin's data.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id/data",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const record = await createCurrentPluginRepository().findById(pluginId);
|
||||
if (!record) {
|
||||
res.status(404).json({ error: "No such plugin" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Stopped first: dropping a table out from under a running plugin turns
|
||||
// its next query into an error rather than a clean shutdown.
|
||||
const { loader } = getPluginRuntime();
|
||||
if (loader.get(pluginId)?.state === "active") {
|
||||
await loader.deactivate(pluginId);
|
||||
}
|
||||
|
||||
const { removePluginData } = await import("../../plugins/data.js");
|
||||
const removed = await removePluginData(pluginId);
|
||||
|
||||
databaseLogger.warn(`Removed all data for plugin ${pluginId}`, {
|
||||
operation: "plugin_remove_data",
|
||||
pluginId,
|
||||
userId,
|
||||
tables: removed.tables.length,
|
||||
kvKeys: removed.kvKeys,
|
||||
});
|
||||
|
||||
res.json({ id: pluginId, removed });
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
`Failed to remove data for plugin ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_remove_data" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to remove the plugin's data" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Core's own sync entities, registered the same way a plugin registers one.
|
||||
*
|
||||
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
|
||||
* the tombstone repository, a REFERENCES map in sync-references.ts and a
|
||||
* frozen array in electron/. Four lists that had to agree and had no test
|
||||
* saying they did - which is how networkTopology came to be missing from one
|
||||
* of them. Now there is one registration per entity and the rest is derived.
|
||||
*
|
||||
* The order values reproduce the dependency order the Electron array encoded:
|
||||
* anything referenced by another entity syncs first, and networkTopology
|
||||
* syncs last because it holds host ids inside a JSON blob.
|
||||
*/
|
||||
|
||||
import {
|
||||
hosts,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
snippets,
|
||||
snippetFolders,
|
||||
vaultProfiles,
|
||||
dashboardServiceLinks,
|
||||
homepageItems,
|
||||
userPreferences,
|
||||
networkTopology,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
CORE_OWNER,
|
||||
registerEntity,
|
||||
listEntities,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { mapTopologyHostIds } from "./sync-references.js";
|
||||
|
||||
const CREDENTIAL_REFERENCE = {
|
||||
field: "credentialId",
|
||||
syncField: "credentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
} as const;
|
||||
|
||||
const HOST_REFERENCES = [
|
||||
CREDENTIAL_REFERENCE,
|
||||
{
|
||||
field: "rdpCredentialId",
|
||||
syncField: "rdpCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vncCredentialId",
|
||||
syncField: "vncCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "telnetCredentialId",
|
||||
syncField: "telnetCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vaultProfileId",
|
||||
syncField: "vaultProfileSyncId",
|
||||
entityType: "vaultProfiles",
|
||||
},
|
||||
{
|
||||
field: "parentHostId",
|
||||
syncField: "parentHostSyncId",
|
||||
entityType: "hosts",
|
||||
},
|
||||
] as const;
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Registers core's entities. Idempotent, because the sync routes and the
|
||||
* Electron entity endpoint both need the registry primed and neither can
|
||||
* assume it ran first.
|
||||
*/
|
||||
export function registerCoreSyncEntities(): void {
|
||||
if (registered && listEntities().length > 0) return;
|
||||
registered = true;
|
||||
|
||||
// 10, 20, 30... so a plugin can slot an entity between two core ones
|
||||
// without every order value having to be renumbered.
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "sshCredentials",
|
||||
table: sshCredentials,
|
||||
order: 10,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "vaultProfiles",
|
||||
table: vaultProfiles,
|
||||
order: 20,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "sshFolders",
|
||||
table: sshFolders,
|
||||
order: 30,
|
||||
references: [CREDENTIAL_REFERENCE],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "snippetFolders",
|
||||
table: snippetFolders,
|
||||
order: 40,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "hosts",
|
||||
table: hosts,
|
||||
order: 50,
|
||||
// connectionOrigin says how this device reached the host, which is never
|
||||
// true of the other side.
|
||||
readOnlyFields: ["connectionOrigin"],
|
||||
references: HOST_REFERENCES,
|
||||
encryptedFields: ["ssh_data"],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "snippets",
|
||||
table: snippets,
|
||||
order: 60,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "dashboardServiceLinks",
|
||||
table: dashboardServiceLinks,
|
||||
order: 70,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "homepageItems",
|
||||
table: homepageItems,
|
||||
order: 80,
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "userPreferences",
|
||||
table: userPreferences,
|
||||
order: 90,
|
||||
singleton: true,
|
||||
readOnlyFields: ["storageMode"],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "networkTopology",
|
||||
table: networkTopology,
|
||||
order: 100,
|
||||
singleton: true,
|
||||
// Host ids live inside the topology JSON rather than in a column, so the
|
||||
// generic reference machinery cannot reach them.
|
||||
serialize: async (row, resolveSyncId) => ({
|
||||
...row,
|
||||
topology: await mapTopologyHostIds(
|
||||
row.topology as string | null | undefined,
|
||||
async (id) => {
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId)) return null;
|
||||
return resolveSyncId("hosts", numericId);
|
||||
},
|
||||
),
|
||||
}),
|
||||
deserialize: async (row, resolveId) => ({
|
||||
...row,
|
||||
topology: await mapTopologyHostIds(
|
||||
row.topology as string | null | undefined,
|
||||
async (syncId) => {
|
||||
const id = await resolveId("hosts", syncId);
|
||||
return id === null ? null : String(id);
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The tables a reference may point at, derived from the registrations. */
|
||||
export const ENCRYPTED_ENTITY_TABLES: Readonly<Record<string, string>> = {
|
||||
hosts: "ssh_data",
|
||||
sshCredentials: "ssh_credentials",
|
||||
};
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||
import { getEntity } from "../../plugins/sync-registry.js";
|
||||
|
||||
export type SyncReferenceEntity = "hosts" | "sshCredentials" | "vaultProfiles";
|
||||
/**
|
||||
* An entity another entity's rows may point at.
|
||||
*
|
||||
* A plain string rather than a union: the set is whatever is registered, and a
|
||||
* plugin can both register an entity and reference one. sync.ts resolves the
|
||||
* name through the registry, so an unknown one is an error there rather than a
|
||||
* silent fallthrough - which is what the old three-way if/else did, routing
|
||||
* anything that was not hosts or sshCredentials to vaultProfiles.
|
||||
*/
|
||||
export type SyncReferenceEntity = string;
|
||||
|
||||
interface TopologyNode {
|
||||
data?: { id?: string; [key: string]: unknown };
|
||||
@@ -25,7 +35,7 @@ interface Topology {
|
||||
* leave group ids untouched. Nodes/edges that fail to resolve (host deleted,
|
||||
* or not synced to this side yet) are dropped rather than left dangling.
|
||||
*/
|
||||
async function mapTopologyHostIds(
|
||||
export async function mapTopologyHostIds(
|
||||
topologyJson: string | null | undefined,
|
||||
mapId: (id: string) => Promise<string | null>,
|
||||
): Promise<string | null> {
|
||||
@@ -81,46 +91,16 @@ interface SyncReference {
|
||||
entityType: SyncReferenceEntity;
|
||||
}
|
||||
|
||||
const CREDENTIAL_REFERENCE: SyncReference = {
|
||||
field: "credentialId",
|
||||
syncField: "credentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
};
|
||||
|
||||
const HOST_REFERENCES: SyncReference[] = [
|
||||
CREDENTIAL_REFERENCE,
|
||||
{
|
||||
field: "rdpCredentialId",
|
||||
syncField: "rdpCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vncCredentialId",
|
||||
syncField: "vncCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "telnetCredentialId",
|
||||
syncField: "telnetCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vaultProfileId",
|
||||
syncField: "vaultProfileSyncId",
|
||||
entityType: "vaultProfiles",
|
||||
},
|
||||
{
|
||||
field: "parentHostId",
|
||||
syncField: "parentHostSyncId",
|
||||
entityType: "hosts",
|
||||
},
|
||||
];
|
||||
|
||||
export function orderSyncRows(
|
||||
entityType: SyncEntityType,
|
||||
rows: Record<string, unknown>[],
|
||||
): Record<string, unknown>[] {
|
||||
if (entityType !== "hosts") return rows;
|
||||
// Only an entity that references itself needs its rows ordered among
|
||||
// themselves. Today that is hosts, through parentHostId.
|
||||
const selfReference = referencesFor(entityType).find(
|
||||
(reference) => reference.entityType === entityType,
|
||||
);
|
||||
if (!selfReference) return rows;
|
||||
|
||||
const bySyncId = new Map(
|
||||
rows
|
||||
@@ -136,7 +116,7 @@ export function orderSyncRows(
|
||||
if (visiting.has(row)) return;
|
||||
visiting.add(row);
|
||||
|
||||
const parentSyncId = row.parentHostSyncId;
|
||||
const parentSyncId = row[selfReference.syncField];
|
||||
if (typeof parentSyncId === "string") {
|
||||
const parent = bySyncId.get(parentSyncId);
|
||||
if (parent) visit(parent);
|
||||
@@ -151,10 +131,9 @@ export function orderSyncRows(
|
||||
return ordered;
|
||||
}
|
||||
|
||||
const REFERENCES: Partial<Record<SyncEntityType, SyncReference[]>> = {
|
||||
hosts: HOST_REFERENCES,
|
||||
sshFolders: [CREDENTIAL_REFERENCE],
|
||||
};
|
||||
function referencesFor(entityType: SyncEntityType): readonly SyncReference[] {
|
||||
return (getEntity(entityType)?.references ?? []) as readonly SyncReference[];
|
||||
}
|
||||
|
||||
export async function serializeSyncReferences(
|
||||
entityType: SyncEntityType,
|
||||
@@ -166,19 +145,10 @@ export async function serializeSyncReferences(
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serialized = { ...row };
|
||||
|
||||
if (entityType === "networkTopology") {
|
||||
serialized.topology = await mapTopologyHostIds(
|
||||
serialized.topology as string | null | undefined,
|
||||
async (id) => {
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId)) return null;
|
||||
return resolveSyncId("hosts", numericId);
|
||||
},
|
||||
);
|
||||
return serialized;
|
||||
}
|
||||
const custom = getEntity(entityType)?.serialize;
|
||||
if (custom) return custom(serialized, resolveSyncId);
|
||||
|
||||
for (const reference of REFERENCES[entityType] ?? []) {
|
||||
for (const reference of referencesFor(entityType)) {
|
||||
const id = serialized[reference.field];
|
||||
serialized[reference.syncField] =
|
||||
typeof id === "number"
|
||||
@@ -199,18 +169,10 @@ export async function deserializeSyncReferences(
|
||||
): Promise<Record<string, unknown>> {
|
||||
const deserialized = { ...row };
|
||||
|
||||
if (entityType === "networkTopology") {
|
||||
deserialized.topology = await mapTopologyHostIds(
|
||||
deserialized.topology as string | null | undefined,
|
||||
async (syncId) => {
|
||||
const id = await resolveId("hosts", syncId);
|
||||
return id === null ? null : String(id);
|
||||
},
|
||||
);
|
||||
return deserialized;
|
||||
}
|
||||
const custom = getEntity(entityType)?.deserialize;
|
||||
if (custom) return custom(deserialized, resolveId);
|
||||
|
||||
for (const reference of REFERENCES[entityType] ?? []) {
|
||||
for (const reference of referencesFor(entityType)) {
|
||||
const syncId = deserialized[reference.syncField];
|
||||
delete deserialized[reference.syncField];
|
||||
delete deserialized[reference.field];
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { and, eq, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
hosts,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
snippets,
|
||||
snippetFolders,
|
||||
vaultProfiles,
|
||||
dashboardServiceLinks,
|
||||
homepageItems,
|
||||
userPreferences,
|
||||
networkTopology,
|
||||
} from "../db/schema.js";
|
||||
import { hosts } from "../db/schema.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
@@ -22,6 +11,15 @@ import {
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||
import {
|
||||
getEntity,
|
||||
hasEntity,
|
||||
listEntities,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import {
|
||||
ENCRYPTED_ENTITY_TABLES,
|
||||
registerCoreSyncEntities,
|
||||
} from "./sync-entities.js";
|
||||
import {
|
||||
deserializeSyncReferences,
|
||||
orderSyncRows,
|
||||
@@ -31,66 +29,44 @@ import {
|
||||
import { timestampAtOrAfter } from "../sync-timestamp.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
|
||||
// Primed at import so every consumer of this module - the routes below, the
|
||||
// reference resolvers, the Electron entity-types endpoint - sees the same
|
||||
// registry regardless of which one runs first.
|
||||
registerCoreSyncEntities();
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
// Encrypted tables need DataCrypto to translate between the wire payload
|
||||
// (plaintext) and the stored row (encrypted). Everything else is stored
|
||||
// and synced as-is.
|
||||
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
|
||||
hosts: "ssh_data",
|
||||
sshCredentials: "ssh_credentials",
|
||||
};
|
||||
/**
|
||||
* What a sync entity looks like once it is registered.
|
||||
*
|
||||
* The table was a hand-maintained union of ten concrete drizzle types, which a
|
||||
* plugin-provided table could never join. It is the same deliberate
|
||||
* approximation repositories/database-context.ts documents: the query-builder
|
||||
* surface used here is identical across the engines and across tables, and
|
||||
* every use already casts to reach .syncId and .updatedAt.
|
||||
*/
|
||||
type SyncTable = typeof hosts;
|
||||
|
||||
interface EntityConfig {
|
||||
table:
|
||||
| typeof hosts
|
||||
| typeof sshCredentials
|
||||
| typeof sshFolders
|
||||
| typeof snippets
|
||||
| typeof snippetFolders
|
||||
| typeof vaultProfiles
|
||||
| typeof dashboardServiceLinks
|
||||
| typeof homepageItems
|
||||
| typeof userPreferences
|
||||
| typeof networkTopology;
|
||||
// Fields that only make sense on the device that created the row, or
|
||||
// that are managed elsewhere and must never be overwritten by a sync
|
||||
// payload from the other side.
|
||||
readOnlyFields: string[];
|
||||
singleton?: boolean;
|
||||
function entityConfig(entityType: SyncEntityType) {
|
||||
const entity = getEntity(entityType);
|
||||
if (!entity) {
|
||||
throw new Error(`Unknown sync entity "${entityType}"`);
|
||||
}
|
||||
return {
|
||||
table: entity.table as SyncTable,
|
||||
readOnlyFields: entity.readOnlyFields,
|
||||
singleton: entity.singleton === true,
|
||||
};
|
||||
}
|
||||
|
||||
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
|
||||
hosts: {
|
||||
table: hosts,
|
||||
readOnlyFields: ["connectionOrigin"],
|
||||
},
|
||||
sshCredentials: { table: sshCredentials, readOnlyFields: [] },
|
||||
sshFolders: { table: sshFolders, readOnlyFields: [] },
|
||||
snippets: { table: snippets, readOnlyFields: [] },
|
||||
snippetFolders: { table: snippetFolders, readOnlyFields: [] },
|
||||
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
|
||||
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
|
||||
homepageItems: { table: homepageItems, readOnlyFields: [] },
|
||||
userPreferences: {
|
||||
table: userPreferences,
|
||||
readOnlyFields: ["storageMode"],
|
||||
singleton: true,
|
||||
},
|
||||
networkTopology: {
|
||||
table: networkTopology,
|
||||
readOnlyFields: [],
|
||||
singleton: true,
|
||||
},
|
||||
};
|
||||
|
||||
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
||||
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
|
||||
|
||||
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
|
||||
if (typeof value !== "string") return false;
|
||||
registerCoreSyncEntities();
|
||||
return hasEntity(value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +82,7 @@ export function locateSyncRow(
|
||||
userId: string,
|
||||
syncId: string,
|
||||
): SQL {
|
||||
const { table, singleton } = ENTITY_CONFIG[entityType];
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
|
||||
if (singleton) {
|
||||
return eq(table.userId, userId);
|
||||
@@ -118,34 +94,25 @@ export function locateSyncRow(
|
||||
)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* The syncId of a referenced row.
|
||||
*
|
||||
* Generic over the registry rather than an if/else over three known entities.
|
||||
* The old chain ended in an unguarded else that returned vaultProfiles, so a
|
||||
* fourth reference target would have silently resolved against the wrong
|
||||
* table; an unregistered entity is an error here instead.
|
||||
*/
|
||||
async function findReferenceSyncId(
|
||||
context: RepositoryContext,
|
||||
entityType: SyncReferenceEntity,
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
if (entityType === "hosts") {
|
||||
const [row] = await context.drizzle
|
||||
.select({ syncId: hosts.syncId })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, id), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
|
||||
if (entityType === "sshCredentials") {
|
||||
const [row] = await context.drizzle
|
||||
.select({ syncId: sshCredentials.syncId })
|
||||
.from(sshCredentials)
|
||||
.where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
|
||||
const { table } = entityConfig(entityType as SyncEntityType);
|
||||
const [row] = await context.drizzle
|
||||
.select({ syncId: vaultProfiles.syncId })
|
||||
.from(vaultProfiles)
|
||||
.where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
|
||||
.select({ syncId: table.syncId })
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
@@ -156,35 +123,11 @@ async function findReferenceId(
|
||||
syncId: string,
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
if (entityType === "hosts") {
|
||||
const [row] = await context.drizzle
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.syncId, syncId), eq(hosts.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
if (entityType === "sshCredentials") {
|
||||
const [row] = await context.drizzle
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.syncId, syncId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
const { table } = entityConfig(entityType as SyncEntityType);
|
||||
const [row] = await context.drizzle
|
||||
.select({ id: vaultProfiles.id })
|
||||
.from(vaultProfiles)
|
||||
.where(
|
||||
and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
|
||||
)
|
||||
.select({ id: table.id })
|
||||
.from(table)
|
||||
.where(and(eq(table.syncId, syncId), eq(table.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
@@ -230,7 +173,7 @@ export function stripWritePayload(
|
||||
entityType: SyncEntityType,
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const { readOnlyFields } = ENTITY_CONFIG[entityType];
|
||||
const { readOnlyFields } = entityConfig(entityType);
|
||||
const clean = { ...payload };
|
||||
delete clean.id;
|
||||
delete clean.userId;
|
||||
@@ -239,6 +182,42 @@ export function stripWritePayload(
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/entity-types:
|
||||
* get:
|
||||
* summary: List the entity types this server syncs, in dependency order
|
||||
* description: The desktop app syncs the entities in the order returned here, because an entity referenced by another has to exist on the far side first. A server that predates this endpoint returns 404 and the client falls back to its built-in list.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The ordered entity types.
|
||||
* 500:
|
||||
* description: Failed to list entity types.
|
||||
*/
|
||||
router.get(
|
||||
"/entity-types",
|
||||
authenticateJWT,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
registerCoreSyncEntities();
|
||||
return res.json({
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
order: entity.order,
|
||||
singleton: entity.singleton === true,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list sync entity types", error, {
|
||||
operation: "sync_entity_types",
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to list entity types" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/{entityType}:
|
||||
@@ -280,7 +259,7 @@ router.get(
|
||||
: null;
|
||||
|
||||
try {
|
||||
const { table, singleton } = ENTITY_CONFIG[entityType];
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
const conditions = [eq(table.userId, userId)];
|
||||
if (since && "updatedAt" in table) {
|
||||
@@ -352,7 +331,7 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const { table } = ENTITY_CONFIG[entityType];
|
||||
const { table } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
await context.drizzle
|
||||
@@ -418,7 +397,7 @@ router.post(
|
||||
try {
|
||||
// singleton is still needed below: those tables have no sync_id column
|
||||
// for the insert to populate.
|
||||
const { table, singleton } = ENTITY_CONFIG[entityType];
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
|
||||
Reference in new issue
Block a user