mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: rebuild workspaces as the reference plugin
This commit is contained in:
1 parent
d774df8b41
commit
f217d8db89
79 files changed
+32995
-2184
No files matched your search
@@ -2839,36 +2839,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM user_workspaces LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
kind TEXT NOT NULL DEFAULT 'manual',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create user_workspaces table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- workspaces end ---
|
||||
|
||||
// --- sync begin ---
|
||||
// Stable per-row identity used to match rows across two independently-
|
||||
// seeded databases (the embedded desktop backend and a connected remote
|
||||
|
||||
@@ -152,11 +152,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
||||
table: "user_open_tabs",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_user_workspaces_user_id",
|
||||
table: "user_workspaces",
|
||||
columns: "user_id",
|
||||
},
|
||||
{
|
||||
name: "idx_homepage_items_user_id",
|
||||
table: "homepage_items",
|
||||
|
||||
@@ -1780,36 +1780,7 @@ export const fleetInventory = mysqlTable(
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
export const userWorkspaces = mysqlTable(
|
||||
"user_workspaces",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// "manual" | "last_session" - exactly one last_session row per user.
|
||||
kind: text("kind").notNull().default("manual"),
|
||||
isDefault: boolean("is_default")
|
||||
.notNull()
|
||||
.default(false),
|
||||
// JSON-encoded WorkspacePayload: tabs, splitMode, paneTabIds, rowSizes, rowColSizes
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
},
|
||||
(table) => [index("idx_user_workspaces_user_id").on(table.userId)],
|
||||
);
|
||||
// --- workspaces end ---
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
|
||||
@@ -1781,36 +1781,7 @@ export const fleetInventory = pgTable(
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
export const userWorkspaces = pgTable(
|
||||
"user_workspaces",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// "manual" | "last_session" - exactly one last_session row per user.
|
||||
kind: text("kind").notNull().default("manual"),
|
||||
isDefault: boolean("is_default")
|
||||
.notNull()
|
||||
.default(false),
|
||||
// JSON-encoded WorkspacePayload: tabs, splitMode, paneTabIds, rowSizes, rowColSizes
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
},
|
||||
(table) => [index("idx_user_workspaces_user_id").on(table.userId)],
|
||||
);
|
||||
// --- workspaces end ---
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
|
||||
@@ -1779,36 +1779,7 @@ export const fleetInventory = sqliteTable(
|
||||
);
|
||||
// --- fleets end ---
|
||||
|
||||
// --- workspaces begin ---
|
||||
export const userWorkspaces = sqliteTable(
|
||||
"user_workspaces",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
// "manual" | "last_session" - exactly one last_session row per user.
|
||||
kind: text("kind").notNull().default("manual"),
|
||||
isDefault: integer("is_default", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
// JSON-encoded WorkspacePayload: tabs, splitMode, paneTabIds, rowSizes, rowColSizes
|
||||
payload: text("payload").notNull().default("{}"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
},
|
||||
(table) => [index("idx_user_workspaces_user_id").on(table.userId)],
|
||||
);
|
||||
// --- workspaces end ---
|
||||
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
|
||||
@@ -69,7 +69,6 @@ import { UserPreferenceRepository } from "./user-preference-repository.js";
|
||||
import { UserRepository } from "./user-repository.js";
|
||||
import { VaultProfileRepository } from "./vault-profile-repository.js";
|
||||
import { VaultTokenRepository } from "./vault-token-repository.js";
|
||||
import { WorkspaceRepository } from "./workspace-repository.js";
|
||||
|
||||
/**
|
||||
* The context every repository runs against.
|
||||
@@ -631,13 +630,6 @@ export function createCurrentVaultTokenRepository(): VaultTokenRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentWorkspaceRepository(): WorkspaceRepository {
|
||||
return new WorkspaceRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("workspace_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the settings cache. Must run during startup on engines without a
|
||||
* synchronous read, before anything calls getCurrentSettingValue.
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { userWorkspaces } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type WorkspaceRecord = typeof userWorkspaces.$inferSelect;
|
||||
|
||||
export interface WorkspaceCreateInput {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdateInput {
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export class WorkspaceRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUser(userId: string): Promise<WorkspaceRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(eq(userWorkspaces.userId, userId));
|
||||
}
|
||||
|
||||
async findById(userId: string, id: number): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findLastSession(userId: string): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.kind, "last_session"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findDefault(userId: string): Promise<WorkspaceRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(userWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.isDefault, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async upsertLastSession(
|
||||
userId: string,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const existing = await this.findLastSession(userId);
|
||||
|
||||
if (!existing) {
|
||||
const [created] = await insertReturning(this.context, userWorkspaces, {
|
||||
userId,
|
||||
name: "Last Session",
|
||||
color: null,
|
||||
icon: null,
|
||||
kind: "last_session",
|
||||
isDefault: false,
|
||||
payload,
|
||||
syncId: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ payload, updatedAt: now },
|
||||
and(
|
||||
eq(userWorkspaces.id, existing.id),
|
||||
eq(userWorkspaces.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
input: WorkspaceCreateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord> {
|
||||
const [created] = await insertReturning(this.context, userWorkspaces, {
|
||||
userId,
|
||||
name: input.name,
|
||||
color: input.color ?? null,
|
||||
icon: input.icon ?? null,
|
||||
kind: "manual",
|
||||
isDefault: false,
|
||||
payload: input.payload,
|
||||
syncId: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
id: number,
|
||||
input: WorkspaceUpdateInput,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{
|
||||
name: input.name ?? existing.name,
|
||||
color: input.color === undefined ? existing.color : input.color,
|
||||
icon: input.icon === undefined ? existing.icon : input.icon,
|
||||
updatedAt: now,
|
||||
},
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async updateContent(
|
||||
userId: string,
|
||||
id: number,
|
||||
payload: string,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ payload, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async setDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
await this.context.drizzle
|
||||
.update(userWorkspaces)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(userWorkspaces.userId, userId),
|
||||
eq(userWorkspaces.isDefault, true),
|
||||
ne(userWorkspaces.id, id),
|
||||
),
|
||||
);
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ isDefault: true, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async unsetDefault(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<WorkspaceRecord | null> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return null;
|
||||
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
userWorkspaces,
|
||||
{ isDefault: false, updatedAt: now },
|
||||
and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async touchLastUsed(
|
||||
userId: string,
|
||||
id: number,
|
||||
now = new Date().toISOString(),
|
||||
): Promise<void> {
|
||||
const result = await this.context.drizzle
|
||||
.update(userWorkspaces)
|
||||
.set({ lastUsedAt: now })
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
}
|
||||
|
||||
async delete(userId: string, id: number): Promise<boolean> {
|
||||
const existing = await this.findById(userId, id);
|
||||
if (!existing || existing.kind !== "manual") return false;
|
||||
|
||||
const result = await this.context.drizzle
|
||||
.delete(userWorkspaces)
|
||||
.where(and(eq(userWorkspaces.id, id), eq(userWorkspaces.userId, userId)));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const owned = await this.listByUser(userId);
|
||||
const result = await this.context.drizzle
|
||||
.delete(userWorkspaces)
|
||||
.where(eq(userWorkspaces.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
return owned.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
type SyncReferenceEntity,
|
||||
} from "./sync-references.js";
|
||||
import { timestampAtOrAfter } from "../sync-timestamp.js";
|
||||
import {
|
||||
insertReturningWhere,
|
||||
updateReturning,
|
||||
} from "../repositories/returning.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
|
||||
// Primed at import so every consumer of this module - the routes below, the
|
||||
@@ -58,9 +62,24 @@ function entityConfig(entityType: SyncEntityType) {
|
||||
table: entity.table as SyncTable,
|
||||
readOnlyFields: entity.readOnlyFields,
|
||||
singleton: entity.singleton === true,
|
||||
shouldSync: (row: Record<string, unknown>) => isSyncedRow(entityType, row),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row takes part in sync. An entity can leave some of its rows out
|
||||
* (a per-install "last session"): they are not pulled, and a push, update or
|
||||
* delete aimed at one is refused.
|
||||
*/
|
||||
export function isSyncedRow(
|
||||
entityType: string,
|
||||
row: Record<string, unknown>,
|
||||
): boolean {
|
||||
const entity = getEntity(entityType);
|
||||
if (!entity?.shouldSync) return true;
|
||||
return entity.shouldSync(row) !== false;
|
||||
}
|
||||
|
||||
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
|
||||
|
||||
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||
@@ -259,7 +278,7 @@ router.get(
|
||||
: null;
|
||||
|
||||
try {
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
const { table, singleton, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
const conditions = [eq(table.userId, userId)];
|
||||
if (since && "updatedAt" in table) {
|
||||
@@ -273,8 +292,10 @@ router.get(
|
||||
.from(table as typeof hosts)
|
||||
.where(and(...conditions));
|
||||
|
||||
const synced = (rows as Record<string, unknown>[]).filter(shouldSync);
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
synced.map(async (row) => {
|
||||
const result = await serializeSyncReferences(
|
||||
entityType,
|
||||
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||
@@ -331,12 +352,20 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const { table } = entityConfig(entityType);
|
||||
const { table, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
|
||||
await context.drizzle
|
||||
.delete(table as typeof hosts)
|
||||
.where(locateSyncRow(entityType, userId, syncId));
|
||||
const [target] = (await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(locateRow)
|
||||
.limit(1)) as Record<string, unknown>[];
|
||||
if (target && !shouldSync(target)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
await context.drizzle.delete(table as typeof hosts).where(locateRow);
|
||||
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
@@ -397,10 +426,13 @@ router.post(
|
||||
try {
|
||||
// singleton is still needed below: those tables have no sync_id column
|
||||
// for the insert to populate.
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
const { table, singleton, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
if (!shouldSync(payload)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
const existingRows = await context.drizzle
|
||||
.select()
|
||||
@@ -408,6 +440,9 @@ router.post(
|
||||
.where(locateRow)
|
||||
.limit(1);
|
||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||
if (existing && !shouldSync(existing)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
const resolvedPayload = await deserializeSyncReferences(
|
||||
entityType,
|
||||
@@ -433,27 +468,30 @@ router.post(
|
||||
userId,
|
||||
);
|
||||
|
||||
// Through the returning helpers, because MySQL has no RETURNING and a
|
||||
// bare .returning() throws there.
|
||||
let resultRow: Record<string, unknown>;
|
||||
if (existing) {
|
||||
const updatedRows = await context.drizzle
|
||||
.update(table as typeof hosts)
|
||||
.set(encryptedPayload)
|
||||
.where(locateRow)
|
||||
.returning();
|
||||
const updatedRows = await updateReturning(
|
||||
context,
|
||||
table as typeof hosts,
|
||||
encryptedPayload,
|
||||
locateRow,
|
||||
);
|
||||
resultRow = updatedRows[0] as Record<string, unknown>;
|
||||
} else {
|
||||
const insertedRows = await context.drizzle
|
||||
.insert(table as typeof hosts)
|
||||
.values(
|
||||
(singleton
|
||||
? { ...encryptedPayload, userId }
|
||||
: {
|
||||
...encryptedPayload,
|
||||
userId,
|
||||
syncId,
|
||||
}) as typeof hosts.$inferInsert,
|
||||
)
|
||||
.returning();
|
||||
const insertedRows = await insertReturningWhere(
|
||||
context,
|
||||
table as typeof hosts,
|
||||
(singleton
|
||||
? { ...encryptedPayload, userId }
|
||||
: {
|
||||
...encryptedPayload,
|
||||
userId,
|
||||
syncId,
|
||||
}) as typeof hosts.$inferInsert,
|
||||
locateRow,
|
||||
);
|
||||
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
||||
interface ActorStore {
|
||||
userId: string;
|
||||
/** Where the identity came from, for the audit line. */
|
||||
source: "request" | "asUser";
|
||||
source: "request" | "asUser" | "service";
|
||||
}
|
||||
|
||||
const storage = new AsyncLocalStorage<ActorStore>();
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
import {
|
||||
needsExplicitPersist,
|
||||
resolveDatabaseDialect,
|
||||
} from "../database/db/dialect.js";
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
|
||||
export type { PluginModule };
|
||||
@@ -330,6 +334,18 @@ export function createPluginContext(
|
||||
define: (definition) => dbDefine(definition) as never,
|
||||
client: () => dbClient() as never,
|
||||
refs: () => dbRefs() as never,
|
||||
// Gated but not audited: it follows every write, and the write itself
|
||||
// went through a client() call that already left an audit line.
|
||||
persist: async () => {
|
||||
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
||||
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
||||
const { DatabaseSaveTrigger } =
|
||||
await import("../utils/database-save-trigger.js");
|
||||
await DatabaseSaveTrigger.forceSave(`plugin_${pluginId}_write`);
|
||||
},
|
||||
get dialect() {
|
||||
return resolveDatabaseDialect();
|
||||
},
|
||||
},
|
||||
|
||||
sync: {
|
||||
|
||||
@@ -19,8 +19,11 @@ import { sql } from "drizzle-orm";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import type { DatabaseDialect } from "../database/db/dialect.js";
|
||||
import { tablePrefix, LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
|
||||
export { LEGACY_TABLE_OWNERS };
|
||||
// The splitter is shared with createTestDb in the SDK, so tests apply a
|
||||
// migration exactly the way this runner does.
|
||||
export { LEGACY_TABLE_OWNERS, splitStatements };
|
||||
|
||||
/** Dialect directory names, matching DatabaseDialect exactly. */
|
||||
const DIALECTS: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"];
|
||||
@@ -94,79 +97,6 @@ export function readMigrations(
|
||||
return migrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a migration file into statements.
|
||||
*
|
||||
* Deliberately simple: statements end at a semicolon that is not inside a
|
||||
* string literal or a comment. A plugin needing more than that should put the
|
||||
* logic in its own code, not in DDL.
|
||||
*/
|
||||
export function splitStatements(source: string): string[] {
|
||||
const statements: string[] = [];
|
||||
let current = "";
|
||||
let quote: string | null = null;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
const next = source[i + 1];
|
||||
|
||||
if (lineComment) {
|
||||
current += char;
|
||||
if (char === "\n") lineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockComment) {
|
||||
current += char;
|
||||
if (char === "*" && next === "/") {
|
||||
current += next;
|
||||
i++;
|
||||
blockComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += char;
|
||||
// '' inside a quoted string is an escaped quote, not a terminator.
|
||||
if (char === quote) {
|
||||
if (next === quote) {
|
||||
current += next;
|
||||
i++;
|
||||
} else {
|
||||
quote = null;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "-" && next === "-") {
|
||||
lineComment = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
blockComment = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === ";") {
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current.trim()) statements.push(current.trim());
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables a migration may create or alter.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
|
||||
|
||||
export interface ServiceRegistration {
|
||||
@@ -386,9 +387,14 @@ async function invokeGuarded(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await (
|
||||
implementation as (...callArgs: unknown[]) => unknown
|
||||
).apply(registration.implementation, args);
|
||||
// Runs as the user the permission was checked for, so the provider reads
|
||||
// the same user from ctx.currentActor() and cannot be told another one.
|
||||
const result = await runAsActor(userId, "service", () =>
|
||||
(implementation as (...callArgs: unknown[]) => unknown).apply(
|
||||
registration.implementation,
|
||||
args,
|
||||
),
|
||||
);
|
||||
|
||||
await context.audit({
|
||||
userId,
|
||||
|
||||
@@ -1,101 +1,15 @@
|
||||
/**
|
||||
* Turns a plugin's table definition into the Drizzle table it is queried
|
||||
* through.
|
||||
*
|
||||
* One table object for every dialect. The sqlite-core definitions already
|
||||
* encode correctly on each engine at query time, so the repositories use them
|
||||
* everywhere; only DDL genuinely differs, and that lives in the SDK's ddl
|
||||
* entry so the CLI and the server share one mapping.
|
||||
* through. The implementation lives in the SDK so createTestDb builds exactly
|
||||
* the table the server does; the DDL emitters are there too, because
|
||||
* termix-plugin writes the same SQL this server applies. Re-exported here so
|
||||
* core keeps one import site.
|
||||
*/
|
||||
|
||||
import {
|
||||
sqliteTable,
|
||||
text as sqliteText,
|
||||
integer as sqliteInteger,
|
||||
index as sqliteIndex,
|
||||
uniqueIndex as sqliteUniqueIndex,
|
||||
type AnySQLiteColumn,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { prefixedTableName } from "@termix/plugin-sdk/db";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import { columnName } from "@termix/plugin-sdk/ddl";
|
||||
|
||||
type ColumnChain = {
|
||||
notNull: () => unknown;
|
||||
primaryKey: () => unknown;
|
||||
unique: () => unknown;
|
||||
default: (value: unknown) => unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the queryable table object.
|
||||
*
|
||||
* Always sqlite-core, on every dialect. drizzle's three table classes share no
|
||||
* base class, and the query-builder surface a plugin uses is identical across
|
||||
* them - the same deliberate approximation repositories/database-context.ts
|
||||
* documents for core.
|
||||
*/
|
||||
export function buildTable(
|
||||
pluginId: string,
|
||||
definition: PluginTableDefinition,
|
||||
): unknown {
|
||||
const physical = prefixedTableName(pluginId, definition.name);
|
||||
|
||||
const columns: Record<string, unknown> = {};
|
||||
for (const [property, column] of Object.entries(definition.columns)) {
|
||||
const name = columnName(property, column);
|
||||
let built: unknown;
|
||||
|
||||
switch (column.type) {
|
||||
case "id":
|
||||
built = sqliteInteger(name).primaryKey({ autoIncrement: true });
|
||||
break;
|
||||
case "integer":
|
||||
case "bigint":
|
||||
case "refHost":
|
||||
built = sqliteInteger(name);
|
||||
break;
|
||||
case "boolean":
|
||||
built = sqliteInteger(name, { mode: "boolean" });
|
||||
break;
|
||||
default:
|
||||
built = sqliteText(name);
|
||||
break;
|
||||
}
|
||||
|
||||
if (column.type !== "id") {
|
||||
if (column.primaryKey) built = (built as ColumnChain).primaryKey();
|
||||
if (column.notNull) built = (built as ColumnChain).notNull();
|
||||
if (column.unique) built = (built as ColumnChain).unique();
|
||||
if (column.defaultNow) {
|
||||
built = (built as ColumnChain).default(sql`CURRENT_TIMESTAMP`);
|
||||
} else if (column.defaultValue !== undefined) {
|
||||
built = (built as ColumnChain).default(column.defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
columns[property] = built;
|
||||
}
|
||||
|
||||
return sqliteTable(physical, columns as never, (table) =>
|
||||
definition.indexes.map((entry) => {
|
||||
const make = entry.unique ? sqliteUniqueIndex : sqliteIndex;
|
||||
const target = table as unknown as Record<string, AnySQLiteColumn>;
|
||||
return make(entry.name).on(
|
||||
...(entry.columns.map((c) => target[c]) as [
|
||||
AnySQLiteColumn,
|
||||
...AnySQLiteColumn[],
|
||||
]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The DDL emitters live in the SDK, because termix-plugin writes the same SQL
|
||||
// this server applies. Re-exported here so core keeps one import site.
|
||||
export { buildTable } from "@termix/plugin-sdk/table-builder";
|
||||
export {
|
||||
createTableSql,
|
||||
adoptTableSql,
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
keyedColumns,
|
||||
|
||||
+6
-14
@@ -306,20 +306,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||
// so disabling any of them stops its WS/HTTP server. See
|
||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||
// AI, Proxmox, Remote Desktop, Fleets, Automations, Network Topology and
|
||||
// Workspaces are also absent for the same reason: each plugin registers
|
||||
// its router with its own dispatcher in database.ts on activate (/ai,
|
||||
// /proxmox, /guacamole, /fleets, /automations, /network-topology,
|
||||
// /workspaces), so disabling any of them makes its routes 404 instead of
|
||||
// leaving a dead import here. Web Endpoint works the same way, except its
|
||||
// dispatcher is mounted by the tunnel service below rather than by
|
||||
// database.ts, since its route is served on port 30003. Automations'
|
||||
// scheduler (due schedules,
|
||||
// dwell rechecks, docker-event polling, history pruning) also starts
|
||||
// from its own activate() rather than here. See plugins/ai,
|
||||
// plugins/proxmox, plugins/remote-desktop, plugins/fleets,
|
||||
// plugins/automations, plugins/network-topology, plugins/workspaces and
|
||||
// plugins/web-endpoint.
|
||||
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
|
||||
// Network Topology, Workspaces, Web Endpoint) is absent for the same
|
||||
// reason: each one serves its routes under /plugin-api/<id>/ through
|
||||
// ctx.http on activate, so disabling it answers 503 instead of leaving a
|
||||
// dead import here. Automations' scheduler also starts from its own
|
||||
// activate() rather than here.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
await import("./hosts/file-manager/index.js");
|
||||
await import("./hosts/tmux/index.js");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import syncRouter, {
|
||||
isSyncedRow,
|
||||
isValidEntityType,
|
||||
stripWritePayload,
|
||||
} from "../../../database/routes/sync.js";
|
||||
import { registerEntity } from "../../../plugins/sync-registry.js";
|
||||
|
||||
describe("sync route order", () => {
|
||||
it("registers POST /tombstones before the POST /:entityType wildcard", () => {
|
||||
@@ -95,3 +97,25 @@ describe("stripWritePayload", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncedRow", () => {
|
||||
let dispose: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
dispose = null;
|
||||
});
|
||||
|
||||
it("syncs every row of an entity that does not filter", () => {
|
||||
expect(isSyncedRow("hosts", { id: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves out the rows an entity's shouldSync refuses", () => {
|
||||
dispose = registerEntity("demo", {
|
||||
type: "demoLayouts",
|
||||
table: {},
|
||||
shouldSync: (row) => row.kind !== "local",
|
||||
});
|
||||
expect(isSyncedRow("demoLayouts", { kind: "shared" })).toBe(true);
|
||||
expect(isSyncedRow("demoLayouts", { kind: "local" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,15 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({ marker: "db-handle" }),
|
||||
}));
|
||||
|
||||
const saves: string[] = [];
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
forceSave: async (reason: string) => {
|
||||
saves.push(reason);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
users: { marker: "users" },
|
||||
hosts: { marker: "hosts" },
|
||||
@@ -90,6 +99,39 @@ beforeEach(() => {
|
||||
resetSyncRegistry();
|
||||
invalidatePluginPermissionCache();
|
||||
delete process.env.PLUGIN_MAX_KV_KEYS;
|
||||
delete process.env.DATABASE_DIALECT;
|
||||
saves.length = 0;
|
||||
});
|
||||
|
||||
describe("ctx.db.persist", () => {
|
||||
it("flushes SQLite to disk under the plugin's name", async () => {
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual(["plugin_demo_write"]);
|
||||
expect(ctx.db.dialect).toBe("sqlite");
|
||||
});
|
||||
|
||||
it("does nothing on an engine that is already durable", async () => {
|
||||
process.env.DATABASE_DIALECT = "postgres";
|
||||
grants.set("demo", ["db:own"]);
|
||||
const { ctx } = contextFor("demo", ["db:own"]);
|
||||
|
||||
await ctx.db.persist();
|
||||
|
||||
expect(saves).toEqual([]);
|
||||
expect(ctx.db.dialect).toBe("postgres");
|
||||
});
|
||||
|
||||
it("refuses without db:own", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(ctx.db.persist()).rejects.toThrow(/db:own/);
|
||||
expect(saves).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.db capability", () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The SDK's test helpers, against a fixture plugin in a temp directory.
|
||||
*
|
||||
* Plugin suites rely on createTestDb applying migrations exactly as the
|
||||
* server does and on createMockCtx enforcing permissions, so a regression in
|
||||
* either would pass every plugin's tests while proving nothing.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
refUser,
|
||||
text,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { adoptTableSql } from "@termix/plugin-sdk/ddl";
|
||||
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
|
||||
|
||||
let root: string;
|
||||
let pluginDir: string;
|
||||
|
||||
const notes = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable("fleets", { id: id(), userId: refUser(), name: text() }),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sdk-testing-"));
|
||||
// The directory name is the plugin id, as it is in plugins/.
|
||||
pluginDir = path.join(root, "fleets");
|
||||
fs.mkdirSync(path.join(pluginDir, "migrations", "sqlite"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "migrations", "sqlite", "0001_adopt.sql"),
|
||||
adoptTableSql("sqlite", "fleets", notes).join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createTestDb", () => {
|
||||
it("applies the plugin's migrations after the before hook", async () => {
|
||||
const db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE fleets (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, name TEXT)",
|
||||
);
|
||||
sqlite.exec("INSERT INTO fleets (user_id, name) VALUES ('u1', 'kept')");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt"]);
|
||||
expect(db.sqlite.prepare("SELECT name FROM p_fleets_fleets").all()).toEqual(
|
||||
[{ name: "kept" }],
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("hands back a database a plugin can query through define and client", async () => {
|
||||
const db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
|
||||
const table = (await db.database.define(notes)) as never;
|
||||
const drizzle = (await db.database.client()) as {
|
||||
insert: (t: never) => { values: (v: object) => Promise<unknown> };
|
||||
select: () => { from: (t: never) => Promise<unknown[]> };
|
||||
};
|
||||
await drizzle.insert(table).values({ userId: "u1", name: "n" });
|
||||
await db.database.persist();
|
||||
|
||||
expect(await drizzle.select().from(table)).toEqual([
|
||||
{ id: 1, userId: "u1", name: "n" },
|
||||
]);
|
||||
expect(db.persisted).toBe(1);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMockCtx permissions", () => {
|
||||
const respond = () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
it("passes everything when no permissions are given", async () => {
|
||||
const { ctx } = createMockCtx({ pluginId: "demo" });
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves short names and refuses what the user does not hold", async () => {
|
||||
const { ctx } = createMockCtx({
|
||||
pluginId: "demo",
|
||||
permissions: ["demo.use", "hosts.view"],
|
||||
});
|
||||
|
||||
expect(await ctx.rbac.has("use")).toBe(true);
|
||||
expect(await ctx.rbac.has("hosts.view")).toBe(true);
|
||||
expect(await ctx.rbac.has("manage")).toBe(false);
|
||||
|
||||
const res = respond();
|
||||
let passed = false;
|
||||
ctx.rbac.require("manage")({} as never, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
expect(passed).toBe(false);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toMatchObject({ required: "demo.manage" });
|
||||
});
|
||||
|
||||
it("uses the router factory and follows setActor", () => {
|
||||
const router = {};
|
||||
const mock = createMockCtx({
|
||||
pluginId: "demo",
|
||||
capabilities: ["network:serve"],
|
||||
router: () => router,
|
||||
});
|
||||
expect(mock.ctx.http.router()).toBe(router);
|
||||
|
||||
mock.setActor("u9");
|
||||
expect(mock.ctx.currentActor()).toBe("u9");
|
||||
});
|
||||
});
|
||||
@@ -166,6 +166,16 @@ describe("service registry", () => {
|
||||
expect(audits[0].success).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the provider as the user the permission was checked for", async () => {
|
||||
const { getActor } = await import("../../plugins/actor.js");
|
||||
register({
|
||||
implementation: { hello: async () => getActor() ?? "nobody" },
|
||||
});
|
||||
const { handle } = handleFor(true, "user-7");
|
||||
|
||||
await expect(handle.hello("x")).resolves.toBe("user-7");
|
||||
});
|
||||
|
||||
it("denies without the permission and never reaches the provider", async () => {
|
||||
const hello = vi.fn(async () => "should not run");
|
||||
register({ implementation: { hello } });
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
refHost,
|
||||
prefixedTableName,
|
||||
tablePrefix,
|
||||
adoptLegacyTable,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
import { splitStatements } from "@termix/plugin-sdk/ddl";
|
||||
import {
|
||||
adoptTableSql,
|
||||
createTableSql,
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
@@ -253,3 +256,56 @@ describe("buildTable", () => {
|
||||
expect(Object.keys(getTableColumns(table))).toContain("userId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adoptTableSql", () => {
|
||||
const adopted = adoptLegacyTable(
|
||||
"fleets",
|
||||
defineTable(
|
||||
"fleets",
|
||||
{ id: id(), name: text().notNull().default("x") },
|
||||
{ indexes: [{ name: "idx_fleets_name", columns: ["id"] }] },
|
||||
),
|
||||
);
|
||||
|
||||
it("creates the legacy table when missing, indexes it, then renames it", () => {
|
||||
const sqlite = adoptTableSql("sqlite", "fleets", adopted);
|
||||
expect(sqlite[0]).toMatch(/^CREATE TABLE IF NOT EXISTS "fleets" \(/);
|
||||
expect(sqlite[1]).toBe(
|
||||
'CREATE INDEX IF NOT EXISTS "idx_fleets_name" ON "fleets" ("id");',
|
||||
);
|
||||
expect(sqlite[2]).toBe('ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";');
|
||||
|
||||
expect(adoptTableSql("postgres", "fleets", adopted).at(-1)).toBe(
|
||||
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits no indexes on MySQL, which has no IF NOT EXISTS for them", () => {
|
||||
const mysql = adoptTableSql("mysql", "fleets", adopted);
|
||||
expect(mysql).toHaveLength(2);
|
||||
expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;");
|
||||
});
|
||||
|
||||
it("writes a TEXT default as an expression on MySQL", () => {
|
||||
expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain(
|
||||
"`name` text NOT NULL DEFAULT ('x')",
|
||||
);
|
||||
expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain(
|
||||
"\"name\" text NOT NULL DEFAULT 'x'",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a definition that adopts nothing, and a table no plugin may adopt", () => {
|
||||
expect(() => adoptTableSql("sqlite", "demo", definition)).toThrow(
|
||||
/does not adopt/,
|
||||
);
|
||||
expect(() => adoptLegacyTable("users", definition)).toThrow(
|
||||
/not a legacy core table/,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits into statements the runner applies one at a time", () => {
|
||||
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
|
||||
expect(splitStatements(joined)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -455,24 +455,6 @@ export type WorkspacePayload = {
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkspaceKind = "manual" | "last_session";
|
||||
|
||||
export type Workspace = {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
kind: WorkspaceKind;
|
||||
isDefault: boolean;
|
||||
payload: WorkspacePayload;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastUsedAt: string | null;
|
||||
tabCount: number;
|
||||
};
|
||||
|
||||
export type Snippet = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -50,6 +50,12 @@ function load(): Promise<CachedPermissions> {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/** The same answer usePermissions().has gives, for code outside a component. */
|
||||
export async function hasPermission(permission: string): Promise<boolean> {
|
||||
const { permissions, isAdmin } = await load();
|
||||
return matchesPermission(permissions, isAdmin, permission);
|
||||
}
|
||||
|
||||
export function notifyPermissionsChanged(): void {
|
||||
cache = null;
|
||||
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
|
||||
|
||||
@@ -43,6 +43,8 @@ import { shell, tabsApi } from "./shell-bridge";
|
||||
import { withPluginScope } from "./scope";
|
||||
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { hasPermission } from "@/hooks/use-permissions";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export interface PluginAppHandle {
|
||||
app: TermixApp;
|
||||
@@ -118,6 +120,9 @@ export function createPluginApp(
|
||||
after: item.after,
|
||||
order: item.order,
|
||||
pluginId,
|
||||
permission: item.permission
|
||||
? resolvePluginPermission(pluginId, item.permission)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -339,6 +344,20 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
t: ((key: string, options?: Record<string, unknown> | string) =>
|
||||
i18n.t(
|
||||
pluginKey(pluginId, key),
|
||||
options as Record<string, unknown>,
|
||||
)) as TermixApp["t"],
|
||||
hasPermission: async (permission) => {
|
||||
try {
|
||||
return await hasPermission(
|
||||
resolvePluginPermission(pluginId, permission),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
api: pluginHostBridge.getApi(pluginId),
|
||||
wsUrl: (path, options) =>
|
||||
pluginWsUrl(pluginId, path, options as never) as ReturnType<
|
||||
|
||||
@@ -56,6 +56,15 @@ export function resolvePluginPermission(
|
||||
|
||||
const apiClients = new Map<string, ReturnType<typeof createPluginApi>>();
|
||||
|
||||
/** Test seam: renderWithApp's `api` option stands in for the real client. */
|
||||
export function setPluginApiForTesting(
|
||||
pluginId: string,
|
||||
client: ReturnType<typeof createPluginApi> | null,
|
||||
): void {
|
||||
if (client) apiClients.set(pluginId, client);
|
||||
else apiClients.delete(pluginId);
|
||||
}
|
||||
|
||||
function getApi(pluginId: string) {
|
||||
let client = apiClients.get(pluginId);
|
||||
if (!client) {
|
||||
|
||||
@@ -58,6 +58,9 @@ export { TOTPDialog, type MFAPromptMode } from "@/ssh/dialogs/TOTPDialog";
|
||||
export { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog";
|
||||
export { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog";
|
||||
|
||||
// Design tokens: the colour swatches folders, workspaces and tags pick from.
|
||||
export { FOLDER_COLORS } from "@/lib/theme";
|
||||
|
||||
// Shell context
|
||||
export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
||||
|
||||
|
||||
@@ -44,9 +44,18 @@ import { getSlotContributions, listActions } from "@/shell/action-registry";
|
||||
import { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
||||
import type { Host, Tab } from "@/types/ui-types";
|
||||
import { createPluginApp } from "./app";
|
||||
import { installPluginHostBridge } from "./bridge";
|
||||
import { installPluginHostBridge, setPluginApiForTesting } from "./bridge";
|
||||
import { setPluginSummaries, setFrontendState } from "./plugin-store";
|
||||
import { setShellCallbacks, setShellHosts } from "./shell-bridge";
|
||||
import {
|
||||
notifyShellReady,
|
||||
resetShellBridge,
|
||||
setShellCallbacks,
|
||||
setShellHosts,
|
||||
setShellLayoutProvider,
|
||||
} from "./shell-bridge";
|
||||
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
|
||||
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
|
||||
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||
|
||||
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
|
||||
const record =
|
||||
@@ -78,7 +87,7 @@ function wrap(element: ReactElement): HTMLElement {
|
||||
).container;
|
||||
}
|
||||
|
||||
function missing(kind: string, id: string): never {
|
||||
function missing(kind: string, id: string | number): never {
|
||||
throw new Error(`renderWithApp: nothing registered ${kind} "${id}"`);
|
||||
}
|
||||
|
||||
@@ -101,12 +110,45 @@ export async function renderPlugin(
|
||||
} as PluginManifest;
|
||||
|
||||
installPluginHostBridge();
|
||||
resetShellBridge();
|
||||
setPermissionsForTesting(options.permissions ?? [], options.isAdmin);
|
||||
setShellHosts((options.hosts ?? []) as unknown as Host[]);
|
||||
const hosts = (options.hosts ?? []) as unknown as Host[];
|
||||
setShellHosts(hosts);
|
||||
const shellCalls: ShellCall[] = [];
|
||||
const shell = recordingShell(shellCalls);
|
||||
setShellCallbacks(shell);
|
||||
|
||||
// Stands in for AppShell's layout: applying one runs the shell's own
|
||||
// restore rules and records which tabs it would open.
|
||||
let layout = options.layout ?? null;
|
||||
const opened: Array<{ type: string; hostId?: number; label: string }> = [];
|
||||
setShellLayoutProvider({
|
||||
getLayout: () => layout,
|
||||
applyLayout: async (next, applyOptions) => {
|
||||
shellCalls.push({ method: "applyLayout", args: [next, applyOptions] });
|
||||
layout = next;
|
||||
opened.length = 0;
|
||||
const skipped: string[] = [];
|
||||
const tabs = (next.tabs ?? []) as WorkspaceTabSnapshot[];
|
||||
for (const snapshot of tabs) {
|
||||
const target = resolveLayoutTabTarget(snapshot, hosts);
|
||||
if (target.kind === "skip") {
|
||||
skipped.push(snapshot.hostNameSnapshot || snapshot.label);
|
||||
continue;
|
||||
}
|
||||
opened.push({
|
||||
type: snapshot.type,
|
||||
hostId:
|
||||
"host" in target && target.host
|
||||
? Number(target.host.id)
|
||||
: undefined,
|
||||
label: snapshot.customLabel ?? snapshot.label,
|
||||
});
|
||||
}
|
||||
return { skipped };
|
||||
},
|
||||
});
|
||||
|
||||
const summary: PluginSummary = {
|
||||
id: pluginId,
|
||||
name: manifest.name,
|
||||
@@ -128,11 +170,16 @@ export async function renderPlugin(
|
||||
);
|
||||
}
|
||||
|
||||
setPluginApiForTesting(
|
||||
pluginId,
|
||||
(options.api as Parameters<typeof setPluginApiForTesting>[1]) ?? null,
|
||||
);
|
||||
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
|
||||
guest: options.guest,
|
||||
});
|
||||
await plugin.activate(handle.app);
|
||||
setFrontendState(pluginId, "active");
|
||||
if (options.ready) notifyShellReady();
|
||||
|
||||
const mine = <T extends { pluginId?: string }>(items: T[]) =>
|
||||
items.filter((item) => item.pluginId === pluginId);
|
||||
@@ -154,6 +201,7 @@ export async function renderPlugin(
|
||||
mine(listRegisteredRailItems()).map((item) => ({
|
||||
id: item.id,
|
||||
hidden: item.hidden,
|
||||
permission: item.permission,
|
||||
})),
|
||||
tabs: () => mine(listTabTypes()).map((def) => def.id),
|
||||
panels: () => mine(listPanels()).map((panel) => panel.id),
|
||||
@@ -195,6 +243,28 @@ export async function renderPlugin(
|
||||
);
|
||||
},
|
||||
|
||||
openedTabs: () => opened.map((tab) => ({ ...tab })),
|
||||
|
||||
renderOpenedTab(index) {
|
||||
const tab = opened[index] ?? missing("an opened tab at index", index);
|
||||
const def = getTabType(tab.type);
|
||||
if (!def) {
|
||||
// What tabUtils renders for a tab no running plugin registered.
|
||||
return wrap(<PluginViewPlaceholder kind="tab" viewId={tab.type} />);
|
||||
}
|
||||
const Component = def.component;
|
||||
return wrap(
|
||||
<Component
|
||||
tab={tabRecord(tab.type)}
|
||||
label={tab.label}
|
||||
isVisible
|
||||
isFocusedPane
|
||||
handleRef={null}
|
||||
shell={shell}
|
||||
/>,
|
||||
);
|
||||
},
|
||||
|
||||
renderPanel(id, props = {}) {
|
||||
const panel = getPanel(id) ?? missing("a panel", id);
|
||||
const Component = panel.component;
|
||||
@@ -260,6 +330,7 @@ export async function renderPlugin(
|
||||
} finally {
|
||||
handle.dispose();
|
||||
setFrontendState(pluginId, "inactive");
|
||||
setPluginApiForTesting(pluginId, null);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,8 +45,34 @@ const CORE_SINGLETON = new Set([
|
||||
"ssh-tools",
|
||||
]);
|
||||
|
||||
/** Core tab types that are never part of a saved arrangement. */
|
||||
const CORE_UNSAVED = new Set([
|
||||
"dashboard",
|
||||
"local-terminal",
|
||||
"host-manager",
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"split-screen",
|
||||
"sftp",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A tab type no running plugin has registered: its plugin is disabled,
|
||||
* failed, still loading or not installed. The shell shows a placeholder for
|
||||
* it, and a layout keeps it so nothing is lost when the plugin comes back.
|
||||
*/
|
||||
export function isUnregisteredPluginTabType(type: string): boolean {
|
||||
return (
|
||||
!CORE_UNSAVED.has(type) &&
|
||||
!CORE_CAPTURABLE.has(type) &&
|
||||
type !== "serial" &&
|
||||
!getTabType(type)
|
||||
);
|
||||
}
|
||||
|
||||
export function isCapturableTabType(type: string): boolean {
|
||||
if (CORE_CAPTURABLE.has(type)) return true;
|
||||
if (isUnregisteredPluginTabType(type)) return true;
|
||||
const def = getTabType(type);
|
||||
return !!def && def.inLayouts !== false;
|
||||
}
|
||||
@@ -142,6 +168,12 @@ export function resolveLayoutTabTarget(
|
||||
return { kind: "singleton", host };
|
||||
}
|
||||
|
||||
// Reopened as a placeholder rather than dropped, so applying a workspace
|
||||
// while a plugin is off does not quietly lose its tabs.
|
||||
if (!host && isUnregisteredPluginTabType(snapshot.type)) {
|
||||
return { kind: "singleton" };
|
||||
}
|
||||
|
||||
return host ? { kind: "host", host } : { kind: "skip" };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, visibleRailItems } from "./rail-items";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
@@ -52,12 +52,13 @@ type RailItem =
|
||||
| { kind: "separator" };
|
||||
|
||||
function buildRailButtons(
|
||||
items: RailItemDef[],
|
||||
splitMode: SplitMode,
|
||||
t: (key: string) => string,
|
||||
hidden: Set<string>,
|
||||
): RailItem[] {
|
||||
const all: RailItem[] = [];
|
||||
for (const item of visibleRailItems()) {
|
||||
for (const item of items) {
|
||||
const Icon = item.icon;
|
||||
if (item.kind === "tab") {
|
||||
all.push({
|
||||
@@ -192,8 +193,8 @@ export function AppRail({
|
||||
};
|
||||
}, [menuPos]);
|
||||
|
||||
// Plugins add and remove rail items at runtime.
|
||||
useRailItems();
|
||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||
const railItems = useRailItems();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
@@ -246,7 +247,12 @@ export function AppRail({
|
||||
...hiddenTabs,
|
||||
...(isRemoteSyncConnected ? [] : ["termix-id"]),
|
||||
]);
|
||||
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
|
||||
const railButtons = buildRailButtons(
|
||||
railItems,
|
||||
splitMode,
|
||||
t,
|
||||
effectiveHiddenTabs,
|
||||
);
|
||||
const setRailPinned = (nextPinned: boolean) => {
|
||||
setPinned(nextPinned);
|
||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
Presentation,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
@@ -69,6 +70,8 @@ export interface RailItemDef {
|
||||
order?: number;
|
||||
/** Set for items a plugin registered. */
|
||||
pluginId?: string;
|
||||
/** Role permission the user needs to see it at all, as a full id. */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
export const RAIL_ITEMS: RailItemDef[] = [
|
||||
@@ -250,13 +253,37 @@ function railItemsSnapshot(): RailItemDef[] {
|
||||
return railSnapshot;
|
||||
}
|
||||
|
||||
/** visibleRailItems() as a hook, re-rendering when plugins change it. */
|
||||
/**
|
||||
* Drops items gated on a permission the user lacks. Until permissions load,
|
||||
* gated items stay hidden rather than flashing in and out.
|
||||
*/
|
||||
export function permittedRailItems(
|
||||
items: RailItemDef[],
|
||||
permissions: { has: (permission: string) => boolean; loaded: boolean },
|
||||
): RailItemDef[] {
|
||||
if (!items.some((item) => item.permission)) return items;
|
||||
return items.filter(
|
||||
(item) =>
|
||||
!item.permission ||
|
||||
(permissions.loaded && permissions.has(item.permission)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* visibleRailItems() as a hook, re-rendering when plugins change it, without
|
||||
* the items the user's permissions hide.
|
||||
*/
|
||||
export function useRailItems(): RailItemDef[] {
|
||||
return useSyncExternalStore(
|
||||
const items = useSyncExternalStore(
|
||||
registeredRailItems.subscribe,
|
||||
railItemsSnapshot,
|
||||
railItemsSnapshot,
|
||||
);
|
||||
const { has, loaded } = usePermissions();
|
||||
return useMemo(
|
||||
() => permittedRailItems(items, { has, loaded }),
|
||||
[items, has, loaded],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -358,11 +358,49 @@ describe("plugin tab types in layouts", () => {
|
||||
expect(snapshots.map((s) => s.type)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("leaves out a plugin tab type nobody registered", () => {
|
||||
it("keeps a plugin tab type nobody registered, so a disabled plugin loses nothing", () => {
|
||||
const { snapshots } = buildWorkspaceTabSnapshots([
|
||||
makeTab({ id: "x", type: "some-plugin-tab" }),
|
||||
makeTab({ id: "x", type: "some-plugin-tab", data: { a: 1 } }),
|
||||
makeTab({ id: "d", type: "dashboard" }),
|
||||
makeTab({ id: "h", type: "host-manager" }),
|
||||
]);
|
||||
expect(snapshots).toEqual([]);
|
||||
expect(snapshots.map((s) => s.type)).toEqual(["some-plugin-tab"]);
|
||||
expect(snapshotData(snapshots[0])).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("reopens a hostless tab of a missing plugin as a placeholder", () => {
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{ slotId: "s", type: "some-plugin-tab", label: "Gone" },
|
||||
[],
|
||||
),
|
||||
).toEqual({ kind: "singleton" });
|
||||
});
|
||||
|
||||
it("reopens a missing plugin's host tab while its host exists", () => {
|
||||
const host = makeHost({ syncId: "sync-1" });
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{
|
||||
slotId: "s",
|
||||
type: "some-plugin-tab",
|
||||
label: "Gone",
|
||||
hostSyncId: "sync-1",
|
||||
},
|
||||
[host],
|
||||
),
|
||||
).toEqual({ kind: "host", host });
|
||||
expect(
|
||||
resolveWorkspaceTabTarget(
|
||||
{
|
||||
slotId: "s",
|
||||
type: "some-plugin-tab",
|
||||
label: "Gone",
|
||||
hostSyncId: "sync-deleted",
|
||||
},
|
||||
[host],
|
||||
),
|
||||
).toEqual({ kind: "skip" });
|
||||
});
|
||||
|
||||
it("carries a tab's plugin data and reads the older fleetId field", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Boxes } from "lucide-react";
|
||||
import {
|
||||
hideableRailIds,
|
||||
permittedRailItems,
|
||||
promotableIds,
|
||||
RAIL_ITEMS,
|
||||
RAIL_UTILITY_ITEMS,
|
||||
@@ -230,3 +231,39 @@ describe("railItemLabel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permittedRailItems", () => {
|
||||
const items = [
|
||||
{ id: "open", icon: Boxes, labelKey: "nav.hosts" },
|
||||
{
|
||||
id: "gated",
|
||||
icon: Boxes,
|
||||
labelKey: "nav.hosts",
|
||||
permission: "demo.use",
|
||||
},
|
||||
];
|
||||
|
||||
it("hides an item gated on a permission the user lacks", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: true,
|
||||
has: () => false,
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
|
||||
it("shows it once the user holds the permission", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: true,
|
||||
has: (permission) => permission === "demo.use",
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open", "gated"]);
|
||||
});
|
||||
|
||||
it("keeps gated items hidden until permissions have loaded", () => {
|
||||
const ids = permittedRailItems(items, {
|
||||
loaded: false,
|
||||
has: () => true,
|
||||
}).map((item) => item.id);
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user