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

+17
View File
@@ -93,6 +93,18 @@ export interface PluginDatabase {
client: <T = unknown>() => Promise<T>;
/** Read-only references to the core tables a plugin may point at. */
refs: <T = unknown>() => Promise<T>;
/**
* Flushes writes to disk. Call it after every write: on SQLite the database
* lives in memory and is saved to its encrypted file only when asked, so a
* write without it can be lost on restart. A no-op on Postgres and MySQL.
*/
persist: () => Promise<void>;
/**
* The engine the server runs on. Table objects encode the same on all
* three, but MySQL has no RETURNING, so portable code reads a written row
* back by its key rather than relying on .returning().
*/
readonly dialect: "sqlite" | "postgres" | "mysql";
}
/** A row's shape on the wire, before it is written to a table. */
@@ -124,6 +136,11 @@ export interface SyncEntityRegistration {
readOnlyFields?: readonly string[];
/** One row per user rather than many, keyed on the owner. */
singleton?: boolean;
/**
* Rows this returns false for are left out of sync in both directions, for
* state that belongs to one install (a per-device "last session").
*/
shouldSync?: (row: SyncRow) => boolean;
/**
* Escape hatch for a row whose references are not plain columns, such as ids
* embedded in a JSON blob. Runs instead of `references`, not alongside it.
+24
View File
@@ -111,6 +111,8 @@ export interface PluginTableDefinition {
readonly name: string;
readonly columns: Readonly<Record<string, PluginColumn>>;
readonly indexes: readonly PluginTableIndex[];
/** A legacy core table this definition takes over by rename. See adoptLegacyTable. */
readonly adopts?: string;
}
/** Columns that can never carry an index, because MySQL cannot index them. */
@@ -261,3 +263,25 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
webauthn_credentials: "webauthn",
sso_providers: "sso",
};
/**
* Marks a definition as the new home of a legacy core table.
*
* The migration the CLI writes for it renames the legacy table into the
* plugin's namespace, and creates it first when there is none (a fresh
* install), so both paths end with the same table. Keep the legacy column and
* index names in the definition: the rename carries the old indexes across,
* and reusing their names is what stops the migration from creating a second
* copy of each.
*/
export function adoptLegacyTable(
legacyName: string,
definition: PluginTableDefinition,
): PluginTableDefinition {
if (!Object.prototype.hasOwnProperty.call(LEGACY_TABLE_OWNERS, legacyName)) {
throw new Error(
`"${legacyName}" is not a legacy core table a plugin may adopt. See LEGACY_TABLE_OWNERS.`,
);
}
return { ...definition, adopts: legacyName };
}
+137 -12
View File
@@ -81,6 +81,14 @@ function columnType(
}
}
/** Column types that are TEXT on MySQL when nothing keys them. */
const TEXT_ON_MYSQL: ReadonlySet<PluginColumn["type"]> = new Set([
"text",
"json",
"encryptedText",
"timestamp",
]);
function defaultClause(dialect: SqlDialect, column: PluginColumn): string {
if (column.defaultNow) {
// MySQL rejects a bare DEFAULT CURRENT_TIMESTAMP on a text column; an
@@ -98,7 +106,12 @@ function defaultClause(dialect: SqlDialect, column: PluginColumn): string {
: ` DEFAULT ${value ? "true" : "false"}`;
}
if (typeof value === "number") return ` DEFAULT ${value}`;
return ` DEFAULT '${String(value).replace(/'/g, "''")}'`;
const literal = `'${String(value).replace(/'/g, "''")}'`;
// MySQL refuses a literal default on a TEXT column but takes an expression.
if (dialect === "mysql" && TEXT_ON_MYSQL.has(column.type)) {
return ` DEFAULT (${literal})`;
}
return ` DEFAULT ${literal}`;
}
/** Every column that carries a key, and so must be indexable on MySQL. */
@@ -136,13 +149,11 @@ function indexStatement(
return `CREATE ${unique}INDEX ${guard}${quote(dialect, entry.name)} ON ${quote(dialect, physical)} (${columns});`;
}
/** The CREATE TABLE and CREATE INDEX statements for one definition. */
export function createTableSql(
function createStatements(
dialect: SqlDialect,
pluginId: string,
physical: string,
definition: PluginTableDefinition,
): string[] {
const physical = prefixedTableName(pluginId, definition.name);
): { table: string; indexes: string[] } {
const keyed = keyedColumns(definition);
const lines: string[] = [];
@@ -178,18 +189,132 @@ export function createTableSql(
}
const body = [...lines, ...constraints].join(",\n");
const statements = [
`CREATE TABLE IF NOT EXISTS ${quote(dialect, physical)} (\n${body}\n);`,
];
return {
table: `CREATE TABLE IF NOT EXISTS ${quote(dialect, physical)} (\n${body}\n);`,
indexes: definition.indexes.map((entry) =>
indexStatement(dialect, physical, definition, entry),
),
};
}
for (const entry of definition.indexes) {
statements.push(indexStatement(dialect, physical, definition, entry));
/** The CREATE TABLE and CREATE INDEX statements for one definition. */
export function createTableSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
): string[] {
const physical = prefixedTableName(pluginId, definition.name);
const { table, indexes } = createStatements(dialect, physical, definition);
return [table, ...indexes];
}
/**
* Takes over a legacy core table by renaming it into the plugin's namespace.
*
* A plain SQL migration cannot ask whether the legacy table exists, so it
* creates it first when it does not (a fresh install) and then renames either
* way. Both paths end with the same table and the same rows.
*
* Indexes are created on the legacy name with IF NOT EXISTS before the rename,
* which is why an adopted definition keeps the legacy index names: an index
* that already exists is left alone and follows the table. MySQL has no
* CREATE INDEX IF NOT EXISTS, so there none are emitted. That is safe because
* every legacy table reached MySQL through core's drizzle migrations, which
* already created its indexes.
*/
export function adoptTableSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
): string[] {
const legacy = definition.adopts;
if (!legacy) {
throw new Error(`Table "${definition.name}" does not adopt a legacy table`);
}
const physical = prefixedTableName(pluginId, definition.name);
const { table, indexes } = createStatements(dialect, legacy, definition);
const rename =
dialect === "mysql"
? `RENAME TABLE ${quote(dialect, legacy)} TO ${quote(dialect, physical)};`
: `ALTER TABLE ${quote(dialect, legacy)} RENAME TO ${quote(dialect, physical)};`;
return [table, ...(dialect === "mysql" ? [] : indexes), rename];
}
/**
* 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 quoteChar: 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 (quoteChar) {
current += char;
// '' inside a quoted string is an escaped quote, not a terminator.
if (char === quoteChar) {
if (next === quoteChar) {
current += next;
i++;
} else {
quoteChar = null;
}
}
continue;
}
if (char === "-" && next === "-") {
lineComment = true;
current += char;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
current += char;
continue;
}
if (char === "'" || char === '"' || char === "`") {
quoteChar = 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;
}
/** DROP statement for a definition, for removePluginData. */
/** DROP statement for a definition, for removePluginData. */
export function dropTableSql(
dialect: SqlDialect,
+18
View File
@@ -129,6 +129,12 @@ export interface RailItemContribution {
separatorAfter?: boolean;
/** Hidden without being unregistered, e.g. while a feature is switched off. */
hidden?: boolean;
/**
* A permission the user needs to see the item at all, usually one of the
* plugin's own short names. The rail, the mobile bar, the palette and the
* Navigation toggles all leave it out otherwise.
*/
permission?: string;
/** Place it after this rail id rather than at the end. */
after?: string;
/** Lower sorts first among plugin items. */
@@ -503,6 +509,18 @@ export interface TermixApp extends TermixAppInfo {
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
/**
* Translates a key from the plugin's own locales, for code outside a
* component (a toast from activate). Components use useTranslation().
*/
t: TranslateFn;
/**
* Whether the current user holds a permission, for code outside a
* component. A short name resolves to <id>.<name>, as usePermission does.
* Resolves false before sign-in and when the lookup fails.
*/
hasPermission: (permission: string) => Promise<boolean>;
/** HTTP client for this plugin's /plugin-api/<id>/ routes. */
api: PluginApiClient;
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
+108
View File
@@ -0,0 +1,108 @@
/**
* 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.
*/
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 "./db.js";
import type { PluginTableDefinition } from "./db.js";
import { columnName } from "./ddl.js";
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.
*/
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[],
]),
);
}),
);
}
/**
* A minimal stand-in for a core table, for tests that join against users or
* ssh_data without core's schema. Only what createTestDb's stubs declare.
*/
export function buildRefTable(
name: string,
columns: Record<string, "text" | "integer">,
): unknown {
const built: Record<string, unknown> = {};
for (const [property, type] of Object.entries(columns)) {
built[property] =
type === "integer" ? sqliteInteger(property) : sqliteText(property);
}
return sqliteTable(name, built as never);
}
+269 -25
View File
@@ -25,6 +25,7 @@ import type {
PluginSshHost,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
export interface FakeContextOptions {
pluginId?: string;
@@ -37,6 +38,16 @@ export interface FakeContextOptions {
coreSettings?: Record<string, string>;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
/** A real database behind ctx.db, usually createTestDb().database. */
db?: PluginDatabase;
/** What ctx.http.router returns, e.g. () => express.Router(). */
router?: () => unknown;
/**
* Role permissions the acting user holds, as full ids or this plugin's
* short names. When set, ctx.rbac enforces them and require() answers 403;
* when omitted every check passes, so tests that do not care stay simple.
*/
permissions?: string[];
}
export interface FakeAuthRegistrations {
@@ -71,6 +82,27 @@ export interface FakePluginContext {
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
/** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void;
/** Implementations provided through ctx.services.provide, by service name. */
services: Map<string, object>;
}
const CORE_PERMISSION_GROUPS = new Set([
"hosts",
"snippets",
"credentials",
"admin",
]);
/**
* A short name takes the plugin's prefix. An id already under this plugin or
* a core group is used as given, like src/backend/plugins/rbac.ts does.
*/
function qualifyPermission(pluginId: string, permission: string): string {
const head = permission.split(".")[0];
if (head === pluginId || CORE_PERMISSION_GROUPS.has(head)) return permission;
return `${pluginId}.${permission}`;
}
/** How a settings row is keyed in the doubles. Mirrors the real unique index. */
@@ -114,6 +146,7 @@ export function createFakeContext(
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const services = new Map<string, object>();
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
loginMethods: [],
@@ -122,6 +155,15 @@ export function createFakeContext(
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
const held = options.permissions
? new Set(
options.permissions.map((permission) =>
qualifyPermission(pluginId, permission),
),
)
: null;
const holds = (permission: string) =>
held === null || held.has(qualifyPermission(pluginId, permission));
for (const [key, value] of Object.entries(options.settings ?? {})) {
settings.set(settingsKey("admin", undefined, key), value);
@@ -184,20 +226,38 @@ export function createFakeContext(
list: async () => [...kv.keys()],
},
db: {
// No engine behind this one. A test that needs real SQL should drive the
// runtime rather than the double.
define: async (definition) => {
tables.push(definition);
return undefined as never;
},
client: async () => {
throw new Error("createFakeContext does not implement db.client");
},
refs: async () => {
throw new Error("createFakeContext does not implement db.refs");
},
},
db: options.db
? {
...options.db,
define: async (definition) => {
tables.push(definition);
return options.db!.define(definition);
},
client: () => options.db!.client(),
refs: () => options.db!.refs(),
persist: () => options.db!.persist(),
dialect: options.db.dialect,
}
: {
// No engine behind this one. Pass createTestDb().database as `db`
// for a test that needs real SQL.
define: async (definition) => {
tables.push(definition);
return undefined as never;
},
client: async () => {
throw new Error(
"createFakeContext has no database: pass createTestDb().database as db",
);
},
refs: async () => {
throw new Error(
"createFakeContext has no database: pass createTestDb().database as db",
);
},
persist: async () => {},
dialect: "sqlite",
},
sync: {
registerEntity: (entity) => {
@@ -212,9 +272,17 @@ export function createFakeContext(
},
services: {
provide: () => {},
get: () => {
throw new Error("createFakeContext does not implement services.get");
provide: (service, implementation) => {
services.set(service, implementation);
},
// Only services this same context provided. A test of a consumer
// should provide a stub first.
get: (service) => {
const implementation = services.get(service);
if (!implementation) {
throw new Error(`No service "${service}" was provided in this test`);
}
return implementation as never;
},
},
@@ -229,7 +297,7 @@ export function createFakeContext(
// plugin's own route module against its own express app.
router: (routerOptions) => {
httpRouters.push(routerOptions);
return undefined as never;
return (options.router ? options.router() : undefined) as never;
},
},
@@ -243,11 +311,22 @@ export function createFakeContext(
},
rbac: {
// Passes everything through: a test asserting a permission gate should
// drive PermissionManager, not this double.
has: async () => true,
hasFor: async () => true,
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
// Enforces options.permissions for the acting user, or passes
// everything when none were given. The runtime's own resolution against
// other plugins' namespaces is covered by core's tests, not here.
has: async (permission) => holds(permission),
hasFor: async (_userId, permission) => holds(permission),
require: (permission) =>
((_req, res, next) => {
if (holds(permission)) {
next();
return;
}
res.status(403).json({
error: "Insufficient permissions",
required: qualifyPermission(pluginId, permission),
});
}) as PluginMiddleware,
},
settings: {
@@ -379,6 +458,10 @@ export function createFakeContext(
coreSettings,
sshConnections,
auth,
setActor: (userId) => {
actor = userId;
},
services,
};
}
@@ -397,6 +480,12 @@ export interface MockContextOptions {
actor?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
/** A real database behind ctx.db, usually createTestDb().database. */
db?: PluginDatabase;
/** What ctx.http.router returns, e.g. () => express.Router(). */
router?: () => unknown;
/** Role permissions the acting user holds. See FakeContextOptions. */
permissions?: string[];
}
export interface MockPluginContext extends FakePluginContext {
@@ -427,6 +516,9 @@ export function createMockCtx(
settings: options.settings,
coreSettings: options.coreSettings,
sshClient: options.sshClient,
db: options.db,
router: options.router,
permissions: options.permissions,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -465,6 +557,11 @@ export function createMockCtx(
require("db:own");
return guardedDb.refs();
},
persist: async () => {
require("db:own");
return guardedDb.persist();
},
dialect: guardedDb.dialect,
},
kv: {
@@ -585,6 +682,131 @@ export function createMockCtx(
return { ...base, ctx: gatedCtx, checked };
}
/** A sqlite handle as createTestDb exposes it. Structural, like better-sqlite3's. */
export interface TestSqlite {
exec: (sql: string) => unknown;
prepare: (sql: string) => {
run: (...params: unknown[]) => unknown;
get: (...params: unknown[]) => unknown;
all: (...params: unknown[]) => unknown[];
};
close: () => void;
}
export interface TestDbOptions {
/**
* Runs after the core stub tables exist and before the plugin's migrations,
* e.g. to create the legacy table an adoption migration renames.
*/
before?: (sqlite: TestSqlite) => void | Promise<void>;
/** Skip applying the migrations, to apply them by hand later. */
skipMigrations?: boolean;
}
export interface TestDb {
/** The raw better-sqlite3 handle, for asserting on tables directly. */
sqlite: TestSqlite;
/** The Drizzle handle ctx.db.client() returns. */
drizzle: unknown;
/** Pass this as createMockCtx({ db }). */
database: PluginDatabase;
/** How many times ctx.db.persist() was called. */
readonly persisted: number;
/** The migration ids applied, in order. */
applied: string[];
/** Applies the plugin's sqlite migrations, when skipMigrations was set. */
migrate: () => Promise<string[]>;
close: () => void;
}
/**
* An in-memory SQLite database with a plugin's own migrations applied.
*
* Core's users and ssh_data exist as minimal stubs with foreign keys on, so a
* refUser or refHost column cascades exactly as it does on the server. The
* migrations are read from <pluginDir>/migrations/sqlite and split with the
* same splitter the server's runner uses.
*/
export async function createTestDb(
pluginDir: string,
options: TestDbOptions = {},
): Promise<TestDb> {
const [{ default: Database }, { drizzle }, fs, path, ddl, builder] =
await Promise.all([
import("better-sqlite3" as string),
import("drizzle-orm/better-sqlite3" as string),
import("node:fs"),
import("node:path"),
import("./ddl.js"),
import("./table-builder.js"),
]);
const sqlite = new Database(":memory:") as TestSqlite;
sqlite.exec("PRAGMA foreign_keys = ON");
sqlite.exec(
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')",
);
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
const handle = drizzle(sqlite);
const pluginId = path.basename(pluginDir);
const applied: string[] = [];
let persisted = 0;
await options.before?.(sqlite);
const migrate = async () => {
const dir = path.join(pluginDir, "migrations", "sqlite");
if (!fs.existsSync(dir)) return [];
const files = fs
.readdirSync(dir)
.filter((file: string) => /^\d{4}_[a-z0-9_]+\.sql$/.test(file))
.sort();
const now: string[] = [];
for (const file of files) {
const id = file.replace(/\.sql$/, "");
if (applied.includes(id)) continue;
const source = fs.readFileSync(path.join(dir, file), "utf8");
for (const statement of ddl.splitStatements(source)) {
sqlite.exec(statement);
}
applied.push(id);
now.push(id);
}
return now;
};
if (!options.skipMigrations) await migrate();
const refs = {
users: builder.buildRefTable("users", { id: "text", username: "text" }),
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
};
const database: PluginDatabase = {
define: async (definition) =>
builder.buildTable(pluginId, definition) as never,
client: async () => handle as never,
refs: async () => refs as never,
persist: async () => {
persisted += 1;
},
dialect: "sqlite",
};
return {
sqlite,
drizzle: handle,
database,
get persisted() {
return persisted;
},
applied,
migrate,
close: () => sqlite.close(),
};
}
/**
* Rendering a plugin frontend in a test.
*
@@ -607,6 +829,12 @@ export interface RenderWithAppOptions {
locales?: Record<string, unknown>;
/** Render as an anonymous guest page. */
guest?: boolean;
/** What app.tabs.getLayout returns until the plugin applies another. */
layout?: import("./frontend.js").ShellLayout;
/** Fire app.tabs.onReady after activation, as the shell does after login. */
ready?: boolean;
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
api?: import("./frontend.js").PluginApiClient;
}
/** A call a plugin made on the shell, recorded instead of performed. */
@@ -619,7 +847,12 @@ export interface RenderedPluginApp {
app: TermixApp;
/** Everything this plugin registered, by kind. */
registered: {
railItems: () => Array<{ id: string; hidden?: boolean }>;
railItems: () => Array<{
id: string;
hidden?: boolean;
/** The full permission id the item is gated on, if any. */
permission?: string;
}>;
tabs: () => string[];
panels: () => string[];
hostActions: () => Array<{ id: string; tabType?: string }>;
@@ -629,8 +862,19 @@ export interface RenderedPluginApp {
slot: (slotId: string) => string[];
actions: () => string[];
};
/** Shell calls made by the plugin's code. */
/** Shell calls made by the plugin's code, applyLayout included. */
shellCalls: ShellCall[];
/**
* Tabs the last app.tabs.applyLayout opened, under the shell's own restore
* rules: a tab whose host is gone is skipped, a tab no running plugin
* registered is kept.
*/
openedTabs: () => Array<{ type: string; hostId?: number; label: string }>;
/**
* Renders an opened tab the way the shell would, which for a tab no running
* plugin registered is the "needs the plugin" placeholder.
*/
renderOpenedTab: (index: number) => HTMLElement;
renderTab: (type: string, props?: Record<string, unknown>) => HTMLElement;
renderPanel: (id: string, props?: Record<string, unknown>) => HTMLElement;
renderDashboardCard: (id: string) => HTMLElement;