feat: rebuild workspaces as the reference plugin

This commit is contained in:
LukeGus committed 2026-09-23 07:04:03 -05:00
1 parent d774df8b41
commit f217d8db89
79 files changed
+32995 -2184

No files matched your search

-30
View File
@@ -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",
+1 -30
View File
@@ -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
+1 -30
View File
@@ -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
+1 -30
View File
@@ -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?.();
}
}
+62 -24
View File
@@ -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>;
}
+1 -1
View File
@@ -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>();
+16
View File
@@ -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: {
+4 -74
View File
@@ -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.
*
+9 -3
View File
@@ -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,
+6 -92
View File
@@ -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
View File
@@ -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");
+25 -1
View File
@@ -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);
});
});
+42
View File
@@ -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);
});
});
-18
View File
@@ -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;
+6
View File
@@ -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));
+19
View File
@@ -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<
+9
View File
@@ -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) {
+3
View File
@@ -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";
+75 -4
View File
@@ -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);
}
},
};
+32
View File
@@ -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" };
}
+11 -5
View File
@@ -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));
+30 -3
View File
@@ -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],
);
}
/**
+41 -3
View File
@@ -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", () => {
+37
View File
@@ -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"]);
});
});