mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move snippets backend and tables into a plugin (B2)
This commit is contained in:
1 parent
fdedca39fd
commit
e07221eaf3
51 files changed
+28426
-3553
No files matched your search
@@ -190,6 +190,14 @@ a push, update or tombstone aimed at one is refused with 400. It exists for
|
||||
state that belongs to one install, such as the workspaces plugin's "Last
|
||||
Session" row, which would otherwise leave a user with one from each side.
|
||||
|
||||
**Deletes need a tombstone.** Registering an entity does not make a delete
|
||||
propagate on its own: a pull reads `sync_tombstones` for the entity's wire
|
||||
name, so a plugin route that deletes a synced row calls
|
||||
`ctx.sync.recordTombstone(userId, entityType, syncId)` right after, the way
|
||||
core's own delete routes call `SyncTombstoneRepository.record`. **B2** added
|
||||
this method to the SDK; the snippets plugin's folder and snippet deletes are
|
||||
its first callers.
|
||||
|
||||
### 6. HTTP and WebSockets
|
||||
|
||||
Plugin routes live under `/plugin-api/<id>/` and sockets under
|
||||
@@ -418,6 +426,14 @@ Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay`
|
||||
and `session.remoteDisplay`. Cross-plugin frontend calls go through actions:
|
||||
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
|
||||
|
||||
**B2** added a live-terminal-session surface, for a plugin that needs to push
|
||||
resolved text into an open SSH session rather than just fill a slot with a
|
||||
button: ssh-terminal registers `terminal.listSessions()` (open sessions with
|
||||
their host info), `terminal.sendToActive(text, { run })` and
|
||||
`terminal.sendToSession(sessionId, text, { run })`, backed by a small registry
|
||||
its terminal tab wrapper populates from the core terminal ref it already
|
||||
holds. The snippets plugin's run/paste flow is the first caller.
|
||||
|
||||
#### View ownership
|
||||
|
||||
Who owns a tab, panel or card comes from manifests, so it is known even for a
|
||||
@@ -507,9 +523,14 @@ not care about the gates, `createTestDb(pluginDir)` for a real database, and
|
||||
`renderWithApp(plugin, options)` for frontend tests.
|
||||
|
||||
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
|
||||
`users` and `ssh_data` tables and foreign keys on, runs `before` (seed a legacy
|
||||
table there to test an adoption), then applies the plugin's own
|
||||
`migrations/sqlite` with the runner's splitter. Pass its `database` to
|
||||
`users`, `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs
|
||||
`before` (seed a legacy table there to test an adoption), then applies the
|
||||
plugin's own `migrations/sqlite` with the runner's splitter. `ctx.db.refs()`
|
||||
against this database returns the same four, built by `buildRefTable`, which
|
||||
maps each declared property to its real snake_case column (`displayName` to
|
||||
`display_name`) the same way `buildTable` does, so a repository written
|
||||
against the real schema's property names works unchanged in a test. Pass its
|
||||
`database` to
|
||||
`createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and
|
||||
`persisted` counts `persist()` calls. The mock also takes `router` (for example
|
||||
`() => express.Router()`, so routes are served for real), `permissions`
|
||||
@@ -1017,10 +1038,15 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
`ctx.ssh` was pulled forward from B so plugin transports go through core's
|
||||
connect pipeline instead of importing ssh2 helpers from core:
|
||||
|
||||
- `connect(hostOrId, { purpose, pool? })` returns `{ client, dispose }`.
|
||||
- `connect(hostOrId, { purpose, pool? })` returns `{ client, host, dispose }`.
|
||||
`host` is the host core resolved (ip, port, username, name and the rest),
|
||||
for a plugin that only had a numeric id and needs the details, such as
|
||||
substituting them into a command. **B2** added this field.
|
||||
- `withConnection(host, { pool, purpose }, fn)` borrows a pooled connection.
|
||||
- `jumpChain(jumpHosts, { forHost? })` returns a client at the end of a jump
|
||||
chain, for forwarding to something that is not SSH.
|
||||
chain, for forwarding to something that is not SSH. `host` on the result is
|
||||
`forHost` when given, else a placeholder built from the last hop's id, since
|
||||
a chain has no single resolved host of its own.
|
||||
- `prepare(host, { client, purpose })`, `openTransport`,
|
||||
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
|
||||
transport with its own prompt flow (docker's console, host metrics).
|
||||
@@ -1115,8 +1141,11 @@ architecture did.
|
||||
tables, and the `p_<id>_` prefix, the CLI check and the runner's check all say
|
||||
a plugin may only touch its own. None of that is enforced by the engine: a
|
||||
plugin holding that handle can write any table in the database, and `ctx.db.refs`
|
||||
hands it `users` and `ssh_data` outright. The prefix, the capability, the audit
|
||||
line, lint and review are the contract. The engine is not.
|
||||
hands it `users`, `ssh_data`, `roles` and `user_roles` outright (the last two
|
||||
added in **B2**, for a plugin that shares its own rows with a role and needs
|
||||
to resolve a caller's role membership and display names). The prefix, the
|
||||
capability, the audit line, lint and review are the contract. The engine is
|
||||
not.
|
||||
|
||||
So the honest boundary is: capabilities stop accidental and casual overreach,
|
||||
and make every privileged call auditable. What stops malicious code is
|
||||
@@ -1482,8 +1511,8 @@ Then, with the app running:
|
||||
|
||||
## Legacy core imports: the debt D1 removes
|
||||
|
||||
The bundled plugins predate the SDK, apart from workspaces, which A9 rebuilt
|
||||
as the reference conversion and which imports nothing from core. They still reach core by relative
|
||||
The bundled plugins predate the SDK, apart from workspaces (A9) and snippets
|
||||
(B2), which import nothing from core. The others still reach core by relative
|
||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
||||
@@ -1510,7 +1539,7 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 105 files | D1 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 107 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 67 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||
|
||||
|
||||
@@ -81,10 +81,11 @@ export interface PluginKeyValue {
|
||||
* to query through `db`. Both require db:own.
|
||||
*
|
||||
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
|
||||
* users and ssh_data read-only so a plugin can join against them without being
|
||||
* able to write them. In-process code could reach around all of this - see
|
||||
* "What this protects, and what it does not" in ARCHITECTURE.md. The
|
||||
* capability, the prefix, lint and review are the contract, not a sandbox.
|
||||
* users, ssh_data, roles and user_roles read-only so a plugin can join against
|
||||
* them without being able to write them. In-process code could reach around
|
||||
* all of this - see "What this protects, and what it does not" in
|
||||
* ARCHITECTURE.md. The capability, the prefix, lint and review are the
|
||||
* contract, not a sandbox.
|
||||
*/
|
||||
export interface PluginDatabase {
|
||||
/** Registers a definition and returns its queryable table object. */
|
||||
@@ -158,6 +159,17 @@ export interface SyncEntityRegistration {
|
||||
/** Adds an entity to remote sync between a desktop backend and a server. */
|
||||
export interface PluginSync {
|
||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
||||
/**
|
||||
* Records that a row was deleted, so a pull on the other side of remote
|
||||
* sync removes it too. Call it with the row's syncId right after deleting
|
||||
* it, for a registered entity whose rows can be deleted (a no-op silently
|
||||
* loses deletes across devices otherwise). A falsy syncId is ignored.
|
||||
*/
|
||||
recordTombstone: (
|
||||
userId: string,
|
||||
entityType: string,
|
||||
syncId: string | null | undefined,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginRegistry {
|
||||
@@ -481,6 +493,8 @@ export interface PluginSshConnection<Client = unknown> {
|
||||
/** An ssh2 Client, already "ready". */
|
||||
client: Client;
|
||||
jumpClient: Client | null;
|
||||
/** The host as core resolved it: ip, port, username, name and the rest. */
|
||||
host: PluginSshHost;
|
||||
/** Ends the connection and its jump chain. Also runs on deactivate. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -92,8 +92,11 @@ export function buildTable(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A minimal stand-in for a core table, for tests that join against users,
|
||||
* ssh_data, roles or user_roles without core's schema. Only what
|
||||
* createTestDb's stubs declare. The property name is camelCased and mapped
|
||||
* to its snake_case column the same way buildTable does, so e.g. a stub's
|
||||
* "displayName" property reads the real "display_name" SQL column.
|
||||
*/
|
||||
export function buildRefTable(
|
||||
name: string,
|
||||
@@ -101,8 +104,11 @@ export function buildRefTable(
|
||||
): unknown {
|
||||
const built: Record<string, unknown> = {};
|
||||
for (const [property, type] of Object.entries(columns)) {
|
||||
const columnSqlName = columnName(property);
|
||||
built[property] =
|
||||
type === "integer" ? sqliteInteger(property) : sqliteText(property);
|
||||
type === "integer"
|
||||
? sqliteInteger(columnSqlName)
|
||||
: sqliteText(columnSqlName);
|
||||
}
|
||||
return sqliteTable(name, built as never);
|
||||
}
|
||||
@@ -70,6 +70,8 @@ export interface FakePluginContext {
|
||||
tables: PluginTableDefinition[];
|
||||
/** Sync entities registered through ctx.sync.registerEntity, in order. */
|
||||
syncEntities: SyncEntityRegistration[];
|
||||
/** Tombstones recorded through ctx.sync.recordTombstone, in order. */
|
||||
tombstones: Array<{ userId: string; entityType: string; syncId: string }>;
|
||||
/** WebSocket routes registered through ctx.ws, in order. */
|
||||
wsRoutes: Array<{ path: string; raw: boolean }>;
|
||||
/** Router options passed to ctx.http.router, in order. */
|
||||
@@ -137,6 +139,11 @@ export function createFakeContext(
|
||||
const kv = new Map<string, unknown>();
|
||||
const tables: PluginTableDefinition[] = [];
|
||||
const syncEntities: SyncEntityRegistration[] = [];
|
||||
const tombstones: Array<{
|
||||
userId: string;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
}> = [];
|
||||
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
||||
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
||||
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
@@ -263,6 +270,10 @@ export function createFakeContext(
|
||||
registerEntity: (entity) => {
|
||||
syncEntities.push(entity);
|
||||
},
|
||||
recordTombstone: async (userId, entityType, syncId) => {
|
||||
if (!syncId) return;
|
||||
tombstones.push({ userId, entityType, syncId });
|
||||
},
|
||||
},
|
||||
|
||||
registry: {
|
||||
@@ -380,6 +391,9 @@ export function createFakeContext(
|
||||
return {
|
||||
client: sshClient as never,
|
||||
jumpClient: null,
|
||||
host: (typeof host === "number"
|
||||
? { id: host, ip: "", port: 22, username: "" }
|
||||
: host) as never,
|
||||
dispose: () => {},
|
||||
};
|
||||
},
|
||||
@@ -390,6 +404,7 @@ export function createFakeContext(
|
||||
jumpChain: async () => ({
|
||||
client: sshClient as never,
|
||||
jumpClient: null,
|
||||
host: { id: 0, ip: "", port: 22, username: "" } as never,
|
||||
dispose: () => {},
|
||||
}),
|
||||
poolKey: (pool, host) =>
|
||||
@@ -452,6 +467,7 @@ export function createFakeContext(
|
||||
kv,
|
||||
tables,
|
||||
syncEntities,
|
||||
tombstones,
|
||||
wsRoutes,
|
||||
httpRouters,
|
||||
settings,
|
||||
@@ -722,10 +738,11 @@ export interface TestDb {
|
||||
/**
|
||||
* 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.
|
||||
* Core's users, ssh_data, roles and user_roles exist as minimal stubs with
|
||||
* foreign keys on, so a refUser or refHost column cascades exactly as it
|
||||
* does on the server, and ctx.db.refs() has something real to join against.
|
||||
* 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,
|
||||
@@ -747,6 +764,12 @@ export async function createTestDb(
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')",
|
||||
);
|
||||
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '')",
|
||||
);
|
||||
sqlite.exec(
|
||||
"CREATE TABLE user_roles (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE)",
|
||||
);
|
||||
|
||||
const handle = drizzle(sqlite);
|
||||
const pluginId = path.basename(pluginDir);
|
||||
@@ -781,6 +804,16 @@ export async function createTestDb(
|
||||
const refs = {
|
||||
users: builder.buildRefTable("users", { id: "text", username: "text" }),
|
||||
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
|
||||
roles: builder.buildRefTable("roles", {
|
||||
id: "integer",
|
||||
name: "text",
|
||||
displayName: "text",
|
||||
}),
|
||||
userRoles: builder.buildRefTable("user_roles", {
|
||||
id: "integer",
|
||||
userId: "text",
|
||||
roleId: "integer",
|
||||
}),
|
||||
};
|
||||
|
||||
const database: PluginDatabase = {
|
||||
|
||||
Reference in new issue
Block a user