feat: turn session recording into a plugin (B13)

This commit is contained in:
LukeGus committed 2026-09-24 00:09:48 -05:00
1 parent a276f6898c
commit 0af2c94983
147 files changed
+25915 -2074

No files matched your search

+30 -4
View File
@@ -118,7 +118,11 @@ Plugins own their tables. A plugin declares them with `defineTable()` from
`@termix/plugin-sdk/db` and ships migrations for `sqlite`, `postgres` and
`mysql` under `migrations/<dialect>/NNNN_name.sql`. Table names are prefixed
`p_<id with - as _>_`, which `defineTable` adds. `ctx.kv` is for small
key/value state. Core `schema.ts` ends up holding only core tables.
key/value state, and `ctx.files.dataDir()` (**B13**, needs `files:own`) is a
per-plugin folder under `DATA_DIR`, created on first call, for state too
large for `ctx.kv` (session recordings, uploaded files); a plugin lays out
its own subdirectories underneath it. Core `schema.ts` ends up holding only
core tables.
**One table object, three DDL emitters.** The sqlite-core definitions already
encode correctly on every engine at query time, which is why core's own 44
@@ -608,6 +612,22 @@ the asciicast formatting and the 300 ms batched flush (issue #1049) and hands
the provider one `append` per batch. Service names need a dot, so "tmux" and
"recordings" became `tmux.sessions` and `recordings.writer`.
**B13** is the session-recording plugin, which provides `recordings.writer`.
It adopts `session_recordings`, keeping `userId` a plain column rather than
`refUser()` (a recording is evidence about the host as much as the person,
so it outlives the account) and clearing it on the `user.deleted` event
instead of cascading. `open(meta)` returns null when the host's
`enableSessionRecording` setting is off; otherwise a `RecordingSink` with
`append(chunk)` (one call per already-batched chunk, so it never writes
per-chunk itself), `persist(summary)` and `discard()` for a session that
recorded nothing. `createFinished(input)` is the second half of the
contract, for a caller that already wrote its own recording to disk and only
needs the database row: remote-desktop's guacd recordings use it instead of
importing the old repository directly, and without the plugin enabled a
guacd recording still lands on disk but gets no row. Recording files live
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
A service call runs a permission check for its actor, so a share-link guest,
who has no user, cannot make one. Guest link resolution is therefore
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
@@ -1161,6 +1181,7 @@ deciding, not the mechanism.
| `hosts:read` | low | See the host list it can already see |
| `db:own` | low | Own tables |
| `kv:own` | low | Small key/value state |
| `files:own` | low | Its own files on disk |
| `secrets:own` | low | Its own encrypted secrets |
| `settings:read-core` | low | Read core server settings |
| `ui:surface` | low | Contribute tabs, panels and settings |
@@ -1190,6 +1211,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.files.dataDir` | `files:own` | **B13** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
@@ -1668,9 +1690,13 @@ upgrade on all three engines, and the checks in step 15 are green.
- `src/backend/database/routes/delete-user-data.ts` and the user/host
repositories: explicit deletes of the table. A `refUser`/`refHost` column
cascades on every engine, because the foreign key survives the rename.
Prefer that cascade. A table that cannot carry such a column needs a
core "user deleted" or "host deleted" event on `ctx.events`, added to
the SDK the step 3 way; none exists yet because no plugin needed one.
Prefer that cascade. A table that cannot carry such a column (evidence
that has to outlive the account, like a recording) subscribes to
`ctx.events.on("user.deleted", ...)` or `"host.deleted"` instead, added
to the SDK the step 3 way; **B13** added `user.deleted`
(`TOPICS.userDeleted` in `src/backend/plugins/events.ts`), emitted from
`deleteUserAndRelatedData` right where the old direct repository call
used to sit. `host.deleted` already existed (B7).
- `sync-entities.ts`, if core synced it (step 9).
- Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including
core tests and `src/types`.
+39
View File
@@ -282,6 +282,40 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
desktop toolbars now; the tab bar's share button is gone, so a host with the
terminal toolbar switched off has no share button. A generic tab-menu slot
would bring it back if that matters. Owner: D1, if wanted.
- **B13 (session-recording):** `enable_session_logging` is still a live
`ssh_data` column; only the copy-into-plugin-settings migration shipped
(`session-recording-settings-migration.ts`). The legacy default was `true`;
the plugin's enable switch defaults to `false` like every other plugin's, so
the migration copies every host's value rather than only the ones that
turned it off - a fresh host created after 2.9.0 defaults to recording off,
a real behaviour change from the old column default, made once here rather
than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads
`resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging`
off the resolved SSH host instead of `ctx.settings.getHost`, and
`plugins/remote-desktop/src/backend/routes.ts`'s
`host.enableSessionLogging !== false` check, `HostEditorData.ts`,
`HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample
all still read the column directly. The General tab's checkbox was removed
from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the
data plumbing above was not switched over. Dropping the column in lockstep
(`schema.ts`, `db/index.ts`, a drizzle migration per dialect,
`schema:generate`) needs every one of those switched first. Owner: a
dedicated follow-up step, or D0.
- **B13 (session-recording):** `remote-desktop`'s guacd recordings now insert
through the optional `recordings.writer` service's `createFinished(input)`
(added alongside `open()`/`RecordingSink` for ssh-terminal's incremental
writer) instead of importing `createCurrentSessionRecordingRepository`
directly. Without the session-recording plugin enabled, a guacd recording
is still written to disk but gets no `session_recordings` row and so never
appears in the list - a warning is logged. Owner: none needed unless a
second consumer of `recordings.writer` appears.
- **B13 (session-recording):** `accessId` (the legacy `access_id` column,
referencing the now-core-only `host_access` table) is carried in the
adopted table's shape as a plain unreferenced integer, since the SDK's
`defineTable` has no builder for a foreign key into an arbitrary core
table beyond `refUser()`/`refHost()`. Nothing has ever populated it (true
in core before this step too). Owner: none needed unless a future step
wants to actually use it.
## Manual checks after 2.9.0
@@ -295,3 +329,8 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
window, a user share joined from Active Connections, revoke and "end for
everyone", and a collab room with two members, control hand-off, a guest
link, rotate and end.
- Session recording: an SSH session recorded end to end (playback matches what
happened, including a resize mid-session), a host with recording switched
off records nothing, retention prunes an old recording on schedule, a guacd
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
user anonymizes their recordings instead of removing them.
+14
View File
@@ -74,6 +74,19 @@ export interface PluginKeyValue {
list: () => Promise<string[]>;
}
/**
* A folder on disk this plugin owns, for state too large for ctx.kv (session
* recordings, uploaded files). Requires files:own.
*/
export interface PluginFiles {
/**
* The plugin's own folder under DATA_DIR, created if it does not exist yet.
* Always the same path for this plugin; a plugin lays out its own
* subdirectories underneath it.
*/
dataDir: () => Promise<string>;
}
/**
* The plugin's own tables.
*
@@ -1147,6 +1160,7 @@ export interface PluginContext {
readonly log: PluginLogger;
readonly events: PluginEvents;
readonly kv: PluginKeyValue;
readonly files: PluginFiles;
readonly db: PluginDatabase;
readonly sync: PluginSync;
readonly registry: PluginRegistry;
+1
View File
@@ -60,6 +60,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("hosts:read", "low"),
entry("db:own", "low"),
entry("kv:own", "low"),
entry("files:own", "low"),
entry("secrets:own", "low"),
entry("settings:read-core", "low"),
entry("ui:surface", "low"),
+13
View File
@@ -300,6 +300,12 @@ export function createFakeContext(
list: async () => [...kv.keys()],
},
files: {
// A fake path; a test that needs a real folder uses the OS temp dir
// itself, not this double.
dataDir: async () => `/tmp/${pluginId}`,
},
db: options.db
? {
...options.db,
@@ -798,6 +804,13 @@ export function createMockCtx(
},
},
files: {
dataDir: async () => {
require("files:own");
return ctx.files.dataDir();
},
},
events: {
emit: (topic, payload) => {
if (