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

+6
View File
@@ -66,6 +66,9 @@ WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
# The plugin SDK is a workspace package core imports at runtime, so npm ci
# has to see it to link it and install its dependencies.
COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY .npmrc ./
COPY vendor ./vendor
@@ -106,6 +109,9 @@ COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
# Bundled first-party plugins (ssh-terminal). Resolved as dist/plugins by
# getBundledPluginsDir(); without this the terminal never starts.
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
# Target of node_modules/@termix/plugin-sdk, which core and the plugins import.
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
COPY --chown=node:node --from=opkssh-downloader /opkssh /app/opkssh-bundled
COPY --chown=node:node package.json ./
# Schema for Postgres and MySQL. Unused by the default SQLite deployment, which
@@ -0,0 +1,5 @@
-- user_workspaces moved to the workspaces plugin, whose first migration renames it
-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace,
-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1790135163718,
"tag": "0030_sweet_warlock",
"breakpoints": true
},
{
"idx": 31,
"version": "5",
"when": 1790164291881,
"tag": "0031_nebulous_diamondback",
"breakpoints": true
}
]
}
@@ -0,0 +1,5 @@
-- user_workspaces moved to the workspaces plugin, whose first migration renames it
-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace,
-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -225,6 +225,13 @@
"when": 1790135163099,
"tag": "0031_nifty_sentry",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1790164291317,
"tag": "0032_nifty_hedge_knight",
"breakpoints": true
}
]
}
@@ -0,0 +1,5 @@
-- user_workspaces moved to the workspaces plugin, whose first migration renames it
-- to p_workspaces_workspaces. Dropping it here would lose every saved workspace,
-- so this only stops drizzle tracking it. SELECT 1 because MySQL rejects an
-- empty query.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -197,6 +197,13 @@
"when": 1790135162496,
"tag": "0027_luxuriant_cargill",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1790164290763,
"tag": "0028_public_miracleman",
"breakpoints": true
}
]
}
+415 -42
View File
@@ -5,7 +5,9 @@ anyone writing a plugin and the specification the runtime in
`src/backend/plugins/` implements.
Status: the redesign lands across several steps. Everything marked **A1** is
built today. Later markers name the step that delivers the rest.
built today. Later markers name the step that delivers the rest. Phase B
converts one feature per step by following
[Converting a feature into a plugin](#converting-a-feature-into-a-plugin).
---
@@ -24,17 +26,18 @@ This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
dependencies. Entry points:
| Entry | Contents |
| --------------------------------- | ---------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/testing` | Test helpers, incl. `createFakeContext()` |
| Entry | Contents |
| ---------------------------------- | ---------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
@@ -135,11 +138,38 @@ plugin failed and core keeps booting.
**Adoption** is how a feature keeps its rows when it moves out of core. The
legacy table is renamed into the plugin's namespace
(`fleets` -> `p_fleets_fleets`) rather than copied, so nothing is duplicated
and no row is migrated one at a time. `LEGACY_TABLE_OWNERS` maps each legacy
table to the one plugin allowed to adopt it, enforced by the CLI at build time
and by the runner at activation, because a plugin installed from a tarball
never ran the CLI.
(`user_workspaces` -> `p_workspaces_workspaces`) rather than copied, so nothing
is duplicated and no row is migrated one at a time. `LEGACY_TABLE_OWNERS` maps
each legacy table to the one plugin allowed to adopt it, enforced by
`adoptLegacyTable`, by the CLI at build time and by the runner at activation,
because a plugin installed from a tarball never ran the CLI.
A plugin marks a definition with `adoptLegacyTable("user_workspaces",
defineTable(...))` and `termix-plugin migrations` writes the adoption for all
three dialects. A plain SQL file cannot ask whether the legacy table exists, so
the migration creates it when it is missing (a fresh install), creates the
definition's indexes on it with `IF NOT EXISTS`, and then renames it. Both
paths end with the same table. Two rules follow:
- **Keep the legacy column and index names** in the definition. The rename
carries the old indexes across, and a matching name is what makes
`IF NOT EXISTS` skip them instead of adding a second copy.
- **MySQL gets no index statements** in an adoption, because it has no
`CREATE INDEX IF NOT EXISTS`. That is safe for every legacy table core ever
shipped to MySQL, since core's drizzle migrations already created its indexes.
A MySQL `TEXT` column cannot take a literal default, so the emitter writes one
as an expression (`DEFAULT ('{}')`), the way it already did for
`CURRENT_TIMESTAMP`.
**Write through `ctx.db.client()`, then call `ctx.db.persist()`.** On SQLite the
database lives in memory and reaches its encrypted file only when something
asks, which core's repositories do after every write. A plugin write that skips
`persist()` can be lost on restart. It is a no-op on Postgres and MySQL, needs
`db:own`, and is not audited, because it follows a `client()` call that was.
`ctx.db.dialect` says which engine is running. The table object encodes the
same on all three, but MySQL has no `RETURNING`, so portable code reads a new
row back by a key it chose (a sync id) rather than relying on `.returning()`.
**Anything DDL-only in `db/index.ts` is SQLite-only.** `alert_rules`,
`alert_rule_channels` and `alert_firings` were created only there and never
@@ -155,6 +185,11 @@ existing tombstones and rows on the far side match on those strings. The
Electron client asks the server for the ordered list and falls back to its
frozen array for a server that predates the endpoint.
`shouldSync(row)` leaves rows out in both directions: they are not pulled, and
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.
### 6. HTTP and WebSockets
Plugin routes live under `/plugin-api/<id>/` and sockets under
@@ -350,25 +385,26 @@ when the plugin deactivates. Every registered component is wrapped in the
plugin's scope (so the hooks know which plugin they belong to), an error
boundary and Suspense.
| Member | What it does |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
| Member | What it does |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
| `registerPaletteEntry` | A global or per-host command palette entry |
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
@@ -391,6 +427,11 @@ needs the <name> plugin" (`PluginViewPlaceholder`) and is never dropped from
the saved layout. Tab restore waits for the first plugin pass, so a restored
tab does not race its plugin.
The same holds when a saved layout is applied (a workspace): a tab type no
running plugin has registered is reopened as that placeholder rather than
skipped, and it stays in the next snapshot (`isUnregisteredPluginTabType` in
`src/ui/shell/shell-layout.ts`). Only a tab whose host was deleted is skipped.
#### The `ui` entry
`@termix/plugin-sdk/ui` is public API, implemented by
@@ -401,7 +442,8 @@ select, select2, separator, switch, textarea, tooltip, section-card,
metric-card, charts, the card grid, `ConnectionScreen` and the connection
status helpers, `SnippetVariablesDialog`, `FullScreenAppWrapper`, the
connection log context, `TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot` and `ComponentSlot`. Publishing its
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot` and `FOLDER_COLORS` (the
colour swatches folders and workspaces pick from). Publishing its
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
#### Strings
@@ -426,6 +468,12 @@ and frontend actions and slots. Dependencies are declared in the manifest:
in dependency order. A plugin must keep working when an optional dependency is
missing.
A service call runs as the user its permission was checked for, so the
provider reads that user from `ctx.currentActor()` and takes no user id from
the caller. The workspaces plugin provides `workspaces.saved` (`list()`), which
the AI assistant's `list_workspaces` tool reaches through an optional entry in
its `requires`, answering `unavailable: true` while workspaces is off.
### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
@@ -455,8 +503,26 @@ capability, registry, service, secret and shutdown suites.
`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces
capabilities the way the runtime does, `createFakeContext()` for tests that do
not care about the gates, and `renderWithApp(plugin, options)` for frontend
tests. `renderWithApp` activates the plugin against core's real registries,
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
`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`
(enforced by `ctx.rbac`, answering 403 like core; omit it to pass everything),
and returns `setActor(userId)` for a test middleware and `services` for what the
plugin provided. `plugins/workspaces/tests/backend/helpers.ts` is the worked
example.
`renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`),
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
`app.tabs.onReady`). `app.tabs.applyLayout` is recorded in `shellCalls` and runs
the shell's real restore rules, `openedTabs()` lists what it opened, and
`renderOpenedTab(i)` renders one the way the shell would, placeholder included. `renderWithApp` activates the plugin against core's real registries,
records what it registered and the shell calls it made, renders any tab,
panel, card, host editor section, settings component or slot, and
`deactivate()` disposes it all. Every bundled plugin has a
@@ -930,6 +996,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
@@ -1101,9 +1168,315 @@ The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
---
## Converting a feature into a plugin
The checklist every Phase B step follows. A9 ran it on workspaces, so
`plugins/workspaces/` is the worked example for each step: read its source and
tests next to this list. Steps are ordered so data is never dropped before it
has moved. Commands run from the repo root unless they start with `cd`.
A conversion is done when the plugin imports nothing from `src/` or `@/`, core
imports nothing of the feature, every piece of existing data survives an
upgrade on all three engines, and the checks in step 15 are green.
### 1. Scaffold
1. Create `plugins/<id>/` with the layout in [Where plugins live](#13-where-plugins-live).
The directory name is the manifest `id`. If A2 already moved the feature
there, keep the directory and work through the rest in place.
2. `package.json` is `@termix-plugin/<id>`, private, with the four scripts
(`build`, `test`, `typecheck`, `validate`) calling `termix-plugin`, and
`@termix/plugin-sdk` as a dev dependency. `vitest.config.ts` is the one-line
preset.
3. `tsconfig.json` extends `@termix/plugin-sdk/tsconfig.plugin.json` and has
**no `paths`** into `../../src`. A path alias is how core imports sneak back.
4. Run `npm install` so the workspace is linked.
### 2. Manifest and capabilities
1. Fill in the manifest (see the [reference](#manifest-v2-reference)). Declare
every view the frontend registers in `contributes.panels`, `tabs` or
`dashboardCards`, or `app` refuses to register it.
2. Pick the **smallest** set of capabilities from the
[catalog](#capability-catalog). Choose by the ctx members the code calls,
not by what the feature feels like:
| The code calls | Declare |
| ---------------------------------------- | -------------------------------- |
| `ctx.db.*` | `db:own` |
| `ctx.kv.*` | `kv:own` |
| `ctx.http.router`, `ctx.ws.*` | `network:serve` |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` |
| `ctx.auth.*` | `auth:provide` |
| `ctx.settings.readCore` | `settings:read-core` |
| `ctx.events.emit` outside `plugin.<id>.` | `events:core` |
| anything registered through `app` | `ui:surface` |
Workspaces needs `db:own`, `network:serve` and `ui:surface`, nothing else.
3. `cd plugins/<id> && npx termix-plugin validate` must print `ok`.
### 3. Backend: replace every core import with the SDK
1. List what the backend reaches in core:
`grep -rn "src/backend\|src/types" plugins/<id>/src`. Each hit gets replaced.
A plugin never imports `../../../../src/...`.
2. The usual replacements:
| Core import | SDK replacement |
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `AuthManager` middleware, `req.userId` | nothing: core authenticates `ctx.http` routes; read the user with `ctx.currentActor()` |
| `requirePermission(...)` | `ctx.rbac.require("<short name>")` |
| `databaseLogger`, `*Logger` | `ctx.log` |
| `createCurrent*Repository()` and `schema.ts` tables | the plugin's own table through `ctx.db` (step 4) |
| `insertReturning` / `.returning()` | insert, then select by a key you set (a sync id) |
| `DatabaseSaveTrigger`, a repository write hook | `await ctx.db.persist()` after every write |
| ssh2 connection code, the pool, `resolveHostById` | `ctx.ssh.connect` / `withConnection` / `jumpChain` |
| `getCurrentSettingValue` | `ctx.settings.get` for its own keys (step 5), `readCore` for core's |
| another plugin's source | `ctx.services.get(...)` (step 10) |
3. Everything the backend creates lives inside `activate(ctx)`: the table
(`ctx.db.define`), the repository, the router, timers
(`ctx.disposables.add`). Nothing at module scope. `deactivate` is usually
empty.
4. **When the SDK does not have it, add it to the SDK.** Never work around a
gap with a core import, and never add to
`src/backend/auth/legacy-providers.ts`. A new ctx member is:
1. the type and a doc comment in `packages/plugin-sdk/src/backend.ts`;
2. the implementation in `src/backend/plugins/ctx.ts`, wrapped in `guarded()`
when it is privileged, so it checks the capability and writes an audit
line; a new capability is one entry in `src/capabilities.ts` plus its
i18n title and consequence under `plugins.capabilities.<id>` in
`src/ui/locales/en.json`;
3. the same member in `createFakeContext` and a gated one in `createMockCtx`
(`packages/plugin-sdk/src/testing.ts`);
4. a core test in `src/backend/tests/plugins/` (allowed and refused);
5. a row in [The ctx surface](#the-ctx-surface) and a line in the right
section of this document.
Then `npm run build:sdk`, because core and the CLI read the SDK's `dist`.
A9 added `ctx.db.persist`, `ctx.db.dialect`, sync `shouldSync`,
`createTestDb`, `app.t`, `app.hasPermission` and the rail `permission` this
way.
### 4. Tables: adopt them, then remove them from core
1. Write `src/backend/tables.ts`. Wrap each definition that replaces a core
table in `adoptLegacyTable("<legacy name>", defineTable(...))` and export
`tables`. Keep the legacy column names (camelCase properties become
snake_case columns) and **the legacy index names**. The table must be listed
under this plugin in `LEGACY_TABLE_OWNERS` (`packages/plugin-sdk/src/db.ts`).
Use `refUser()` / `refHost()` for owner columns: they cascade on delete.
2. `cd plugins/<id> && npx termix-plugin migrations adopt_<table>` writes
`migrations/{sqlite,postgres,mysql}/0001_adopt_<table>.sql` and
`migrations/snapshot.json`. Read all three files. Never edit one after it
has shipped; add a new migration instead.
3. Remove the table from core, in this order:
- `src/backend/database/db/schema.ts`: the table block.
- `src/backend/database/db/index.ts`: the `CREATE TABLE` bootstrap block and
any `ALTER` for it. Some of these files use CRLF line endings; keep them.
- `src/backend/database/db/performance-indexes.ts`: its entries. The adopted
definition's indexes replace them.
- `src/backend/database/repositories/factory.ts` and the repository file.
- `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.
- `sync-entities.ts`, if core synced it (step 9).
- Anything else `grep -rn "<table_name>\|<tableConst>" src` finds, including
core tests and `src/types`.
4. `npm run schema:generate` (the pg and mysql schema variants), then
`npm run schema:migrations`. drizzle-kit writes a `DROP TABLE` for each
removed table in `drizzle/{sqlite,postgres,mysql}/`. **Replace every one of
those statements with a comment and `SELECT 1;`** (MySQL refuses an empty
query) and keep the new `meta` snapshots. The drop would run at boot, before
the plugin's adoption, and delete every row.
5. Why the order is safe: core boots first, runs its drizzle migrations (the
legacy table is still there, untouched), then the plugin's migration renames
it. A plugin that is disabled at upgrade time simply leaves the legacy table
alone until it is enabled.
6. Tests (step 13) prove the adoption with the legacy table present and absent.
### 5. Global settings keys become plugin settings
1. Declare each key in `contributes.settings.admin` (install-wide) or `.user`,
keeping the key name the frontend already sends where possible, and give it
a `default`. Secrets are `type: "secret"`.
2. Write a boot migration in `src/backend/utils/crypto-migration/`, modelled on
`tailscale-settings-migration.ts`: read the old row from `settings` (or the
per-user source), write it to `plugin_settings` for the plugin **only when
no row exists there yet**, then leave the old row in place for one release.
Call it from the boot sequence in `starter.ts` after the plugins table exists.
3. Test it in `src/backend/tests/utils/`: values move, secrets stay encrypted,
and running it twice changes nothing.
4. Switch the plugin to `ctx.settings.get` / `getUser`, delete the core
settings route and UI for those keys, and remove the keys from
`CORE_SETTINGS_ALLOWLIST` if they were there.
### 6. `ssh_data` host columns become host-scope settings
Follow [Moving a host column into a plugin](#moving-a-host-column-into-a-plugin)
exactly: declare `contributes.settings.host`, copy with an idempotent migration
tested by running it twice, switch the plugin to `ctx.settings.getHost` and
delete the hardcoded editor tab, and only then drop the column in lockstep
across `schema.ts`, `db/index.ts`, `schema:generate` and a drizzle migration per
dialect. Copy and drop can ship in different steps; a column core still reads
is not ready to drop.
### 7. Permissions
1. Declare `contributes.permissions` with `titleKey` and `descriptionKey` in the
plugin's own locales. **Keep existing ids**: core registers `<id>.<name>`, so
pick the short name that makes the id equal the old core one (`ai` + `use` is
`ai.use`) and no role needs migrating.
2. Remove those entries from `PERMISSION_CATALOG` and `SYSTEM_ROLE_DEFAULTS` in
`src/backend/utils/permission-catalog.ts`, and move their i18n.
3. `defaultRoles` are `admin` and/or `user`, applied once.
4. Gate every route. `router.use(ctx.rbac.require("<name>"))` before the routes
is the simplest way to make sure none is missed; workspaces declares `use`
and gates the whole router on it.
5. Gate the frontend too: `permission` on the rail item, `usePermission` in
components, `await app.hasPermission(...)` before background calls (the
workspaces autosave), so a user without it collects no 403s.
### 8. HTTP, WebSockets and nginx
1. Routes go on `ctx.http.router()` (served at `/plugin-api/<id>/`) and sockets
on `ctx.ws.route` / `ctx.ws.upgrade` (`/plugin-ws/<id>/<path>`). Route paths
are relative to the mount point.
2. Update every route's OpenAPI JSDoc path to `/plugin-api/<id>/...`.
3. Delete the feature's own port and server from core: its import in
`src/backend/starter.ts`, its `listen`, and its `location` blocks in **both**
`docker/nginx.conf` and `docker/nginx-https.conf`. `/plugin-api/` and
`/plugin-ws/` are already proxied.
4. Point the frontend at the plugin client (step 11); old paths such as
`/workspaces` stop existing.
### 9. Sync entities
If core synced the data, or it is personal configuration that should follow a
user between the desktop app and a server, register it in `activate`:
`ctx.sync.registerEntity({ type, table, order, ... })`. Keep an existing wire
name exactly and remove core's registration from `sync-entities.ts`. Leave
`electron/remote-sync-entities.cjs` alone: it is the fallback for servers
older than the entity-types endpoint, which still sync the core entity. Give
an entity that references others a higher `order` than they have. Use `shouldSync` for rows that belong to one
install (workspaces leaves out its `last_session` row).
### 10. Cross-plugin and core callers
1. `grep -rn` the old repository or module across `src/` and `plugins/`. A core
caller means the feature is not fully out yet: move the logic behind a core
extension point or into the plugin.
2. Another plugin calling it gets a service: declare it in `provides`
(`<id>.<noun>`, a version and a permission), `ctx.services.provide` it in
`activate`, and have the caller declare an optional `requires` entry plus
`optionalDependencies`. The caller must work when it is absent (catch and
degrade). Workspaces provides `workspaces.saved` to the AI plugin.
### 11. Frontend
1. List core imports:
`grep -rn "from \"@/\|src/ui" plugins/<id>/src/frontend`. Replace each:
| Core import | SDK replacement |
| ----------------------------------------------- | ----------------------------------------------------------- |
| `@/components/*` | `@termix/plugin-sdk/ui` |
| `authApi`, `handleApiError` from `@/main-axios` | `app.api` or `usePluginApi()`, paths relative to the plugin |
| `react-i18next`, `i18next.t` | `useTranslation()` in components, `app.t` elsewhere |
| `usePermissions` | `usePermission` / `app.hasPermission` |
| shell modules (`workspaceUtils`, tab state) | `app.tabs`, `useTabs` |
| `@/types/*` | the plugin's own `types.ts` |
| small helpers (`getErrorMessage`) | a few lines in the plugin |
Something the plugin genuinely needs from the shell's component library goes
into `src/ui/plugin-host/sdk-ui.ts` (a contract change: list it under
[The `ui` entry](#the-ui-entry)).
2. Register everything in `activate(app)`: rail item (with `permission` when
there is one), panel, tabs, host editor sections, actions and slot
contributions. Use `app.onDispose` for timers.
3. Remove the shell's references: imports from the feature, hardcoded ids and
switch cases, and the matching entries in
`scripts/shell-plugin-id-allowlist.json`. `node scripts/check-shell-plugin-ids.cjs`
must pass.
4. The rail item appears in Appearance > Sidebar > Navigation on its own
because it is registered; confirm it there.
5. No hardcoded text (a relative time is a translated string), no em dashes,
square containers.
### 12. Locales
1. Move every key the plugin uses from `src/ui/locales/en.json` into
`plugins/<id>/locales/en.json`, keeping the key paths so the code changes
only its namespace. Keys still used by core stay in core; a plugin may keep
using core's `common.*`, which is the fallback.
2. Move the same keys, per language, from `src/ui/locales/translated/<xx_YY>.json`
into `plugins/<id>/locales/translated/<xx_YY>.json`, so no translation is lost.
Crowdin already picks up `plugins/*/locales/en.json` (`crowdin.yml`).
3. Manifest keys (`titleKey`, `labelKey`) are plugin-relative.
4. Only `en.json` is edited by hand afterwards.
### 13. Tests
1. Move the feature's tests to `plugins/<id>/tests/backend` and
`tests/frontend`. **No plugin test imports `src/`**, and no core test imports
`plugins/`. Delete core tests of code that left core.
2. The minimum a conversion ships:
- routes against a real database: `createTestDb(pluginDir)` +
`createMockCtx({ db, router: () => express.Router(), permissions })` +
`activate`, served on an ephemeral port and called with `fetch`
(`plugins/workspaces/tests/backend/helpers.ts`);
- permission denial on every route;
- the adoption with the legacy table present (rows and indexes survive) and
absent (fresh table), using `createTestDb`'s `before` hook with the legacy
DDL copied from `db/index.ts`;
- activate failing closed without each capability;
- a frontend `activate.test.tsx` on `renderWithApp`, plus the panel or tab
rendering against a stubbed `api`.
3. Every settings or host-column migration gets its run-twice test in core.
4. `cd plugins/<id> && npx vitest run` while working; `npm run test:plugins` at
the end.
### 14. Boundary allowlist
`node scripts/check-plugin-boundaries.cjs --write`, then
`git diff scripts/plugin-boundary-allowlist.json`: the plugin's entries are
gone and **no other line was added**. Update the counts in the table under
[Legacy core imports](#legacy-core-imports-the-debt-d1-removes).
### 15. Build, test and check by hand
```bash
npm run build:sdk
(cd plugins/<id> && npx termix-plugin validate && npx termix-plugin migrations --check)
npm run build:backend
npm run type-check
npm run lint
npm run test
npm run test:plugins
```
Then, with the app running:
1. Boot on an existing SQLite database from before the conversion: the data is
there, the `p_<id>_` tables exist and the legacy tables are gone.
2. Use every feature path once: create, edit, delete, anything background.
3. Remove the plugin's permission from a role: the rail item disappears and
the API answers 403.
4. Disable, enable and disable the plugin in admin without a restart: its
views show the "needs the plugin" placeholder while off and come back after.
5. On Postgres or MySQL if available: `npm run verify:dialect`, then boot.
6. Update this document for anything the step added to the contract.
---
## Legacy core imports: the debt D1 removes
The twelve bundled plugins predate the SDK. They still reach core by relative
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
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
@@ -1130,8 +1503,8 @@ 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 | 109 files | D1 |
| A plugin importing core by relative path | Warning | 70 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 106 files | D1 |
| A plugin importing core by relative path | Warning | 68 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
A warning does not fail a build, so the counts are held by
@@ -81,6 +81,7 @@ function snapshotTable(definition) {
return {
columns: definition.columns,
indexes: definition.indexes,
...(definition.adopts ? { adopts: definition.adopts } : {}),
};
}
@@ -155,7 +156,21 @@ export async function migrations({ cwd, args }) {
return;
}
const { createTableSql, addColumnSql } = await loadEmitter();
const { LEGACY_TABLE_OWNERS } = await import(
new URL("../../dist/db.js", import.meta.url).href
);
for (const definition of definitions) {
if (
definition.adopts &&
LEGACY_TABLE_OWNERS[definition.adopts] !== pluginId
) {
throw new Error(
`${pluginId}: table ${definition.name} adopts "${definition.adopts}", which belongs to the "${LEGACY_TABLE_OWNERS[definition.adopts]}" plugin`,
);
}
}
const { createTableSql, adoptTableSql, addColumnSql } = await loadEmitter();
const previous = readSnapshot(cwd);
const { created, added, manual } = diff(previous, definitions);
@@ -184,7 +199,13 @@ export async function migrations({ cwd, args }) {
for (const dialect of DIALECTS) {
const statements = [];
for (const definition of created) {
statements.push(...createTableSql(dialect, pluginId, definition));
// An adopted table renames the legacy one into place, creating it first
// on a fresh install, so existing rows are kept rather than copied.
statements.push(
...(definition.adopts
? adoptTableSql(dialect, pluginId, definition)
: createTableSql(dialect, pluginId, definition)),
);
}
for (const entry of added) {
statements.push(
+12
View File
@@ -20,6 +20,10 @@
"types": "./dist/ddl.d.ts",
"default": "./dist/ddl.js"
},
"./table-builder": {
"types": "./dist/table-builder.d.ts",
"default": "./dist/table-builder.js"
},
"./frontend": {
"types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js"
@@ -65,5 +69,13 @@
"esbuild": "^0.28.1",
"semver": "^7.6.3",
"tar": "^7.5.22"
},
"peerDependencies": {
"drizzle-orm": "*"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
}
}
}
+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;
+10
View File
@@ -29,6 +29,16 @@
"dependencies": {
"automations": "^1.0.0"
},
"optionalDependencies": {
"workspaces": "^1.0.0"
},
"requires": [
{
"service": "workspaces.saved",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
{
"key": "api-key",
+3
View File
@@ -1,11 +1,14 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginServices } from "./services.js";
import { startAiService, stopAiService } from "./routes.js";
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
setPluginServices(ctx.services);
ctx.disposables.add(() => setPluginServices(null));
startAiService(ctx.http.router());
// Offered by reference, not copied: this hands over a resolver, so the key
+30
View File
@@ -0,0 +1,30 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginServices(services: PluginServices | null): void {
current = services;
}
/** Another plugin's workspaces, as ctx.services.get("workspaces.saved") returns them. */
interface SavedWorkspaces {
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
}
/**
* The user's saved workspaces, or null when the workspaces plugin is off or
* the user may not use it. Optional: the assistant works without it.
*/
export async function listSavedWorkspaces(
userId: string,
): Promise<Array<{ id: number; name: string; isDefault: boolean }> | null> {
if (!current) return null;
try {
return await current
.get<SavedWorkspaces>("workspaces.saved", { userId })
.list();
} catch {
return null;
}
}
+4 -11
View File
@@ -7,9 +7,9 @@ import {
createCurrentHostRepository,
createCurrentNetworkTopologyRepository,
createCurrentSnippetRepository,
createCurrentWorkspaceRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import { num, objectSchema, type AiTool } from "./types.js";
import { listSavedWorkspaces } from "../services.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -178,16 +178,9 @@ export const readTools: AiTool[] = [
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const workspaces = await createCurrentWorkspaceRepository().listByUser(
context.userId,
);
return {
workspaces: workspaces.map((workspace: any) => ({
id: workspace.id,
name: workspace.name,
isDefault: workspace.isDefault ?? false,
})),
};
const workspaces = await listSavedWorkspaces(context.userId);
if (workspaces === null) return { workspaces: [], unavailable: true };
return { workspaces };
},
},
{
+10
View File
@@ -3,3 +3,13 @@
## 1.0.0
Bundled with Termix 2.9.0.
- Owns its data: core's `user_workspaces` table is renamed to
`p_workspaces_workspaces` on first activation, keeping every saved workspace.
- New `workspaces.use` permission, given to the admin and user roles. Without
it the rail item is hidden and the routes answer 403.
- Saved workspaces sync between the desktop app and a server. The per-device
"Last Session" does not.
- Offers `workspaces.saved` to other plugins, used by the AI assistant.
- A tab from a plugin that is off is kept as a placeholder when a workspace is
applied, instead of being dropped.
+6 -2
View File
@@ -2,12 +2,15 @@
Save and restore named tab/split layouts per user.
Bundled with Termix and enabled by default.
Bundled with Termix and enabled by default. This is the reference
conversion: it imports only `@termix/plugin-sdk`, and "Converting a feature
into a plugin" in packages/plugin-sdk/ARCHITECTURE.md points here for each step.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx) / deactivate(), plus the routes
src/backend/ activate(ctx), the table, repository and routes
migrations/ one .sql per dialect, generated from src/backend/tables.ts
src/frontend/ the UI it registers
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
@@ -18,5 +21,6 @@ Bundled with Termix and enabled by default.
npm run build bundle into dist/
npm run test run this plugin's tests
npm run typecheck
npx termix-plugin migrations <name> after changing src/backend/tables.ts
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
+13 -1
View File
@@ -41,7 +41,13 @@
"lastSessionDescription": "Automatically updated with your most recent tab arrangement. Apply it to recover what you had open.",
"lastUsed": "Last used {{time}}",
"neverUsed": "Never applied",
"tabsSkipped": "Workspace applied — {{count}} tab(s) skipped (host no longer exists): {{names}}"
"tabsSkipped": "Workspace applied, but {{count}} tab(s) were skipped because their host no longer exists: {{names}}",
"timeAgo": {
"seconds": "{{count}}s ago",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
}
}
},
@@ -51,5 +57,11 @@
"onboarding": {
"workflow_workspaces": "Workspaces",
"workflow_workspaces_desc": "Save a set of open tabs and reopen the whole layout later."
},
"permissions": {
"use": {
"title": "Use workspaces",
"description": "Save, apply and manage saved tab layouts."
}
}
}
+15
View File
@@ -15,6 +15,13 @@
"api": "1"
},
"capabilities": ["db:own", "network:serve", "ui:surface"],
"provides": [
{
"service": "workspaces.saved",
"version": "1.0.0",
"permission": "workspaces.use"
}
],
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
"contributes": {
@@ -24,6 +31,14 @@
"titleKey": "nav.workspaces",
"icon": "LayoutTemplate"
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
]
}
}
@@ -0,0 +1,19 @@
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `user_workspaces` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`color` text,
`icon` text,
`kind` text NOT NULL DEFAULT ('manual'),
`is_default` boolean NOT NULL DEFAULT false,
`payload` text NOT NULL DEFAULT ('{}'),
`sync_id` varchar(255) UNIQUE,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`last_used_at` text,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `user_workspaces` TO `p_workspaces_workspaces`;
@@ -0,0 +1,20 @@
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "user_workspaces" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"color" text,
"icon" text,
"kind" text NOT NULL DEFAULT 'manual',
"is_default" boolean NOT NULL DEFAULT false,
"payload" text NOT NULL DEFAULT '{}',
"sync_id" varchar(255) UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_used_at" text,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_user_workspaces_user_id" ON "user_workspaces" ("user_id");
ALTER TABLE "user_workspaces" RENAME TO "p_workspaces_workspaces";
@@ -0,0 +1,69 @@
{
"version": 1,
"tables": {
"workspaces": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"color": {
"type": "text"
},
"icon": {
"type": "text"
},
"kind": {
"type": "text",
"notNull": true,
"defaultValue": "manual"
},
"isDefault": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"payload": {
"type": "text",
"notNull": true,
"defaultValue": "{}"
},
"syncId": {
"type": "varchar",
"length": 255,
"unique": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"lastUsedAt": {
"type": "timestamp"
}
},
"indexes": [
{
"name": "idx_user_workspaces_user_id",
"columns": ["userId"]
}
],
"adopts": "user_workspaces"
}
}
}
@@ -0,0 +1,20 @@
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "user_workspaces" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"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,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_user_workspaces_user_id" ON "user_workspaces" ("user_id");
ALTER TABLE "user_workspaces" RENAME TO "p_workspaces_workspaces";
+41 -3
View File
@@ -1,11 +1,49 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startWorkspacesService, stopWorkspacesService } from "./routes.js";
import { workspaces } from "./tables.js";
import { createWorkspaceRepository } from "./repository.js";
import { registerWorkspaceRoutes } from "./routes.js";
/** What other plugins get from ctx.services.get("workspaces.saved"). */
export interface WorkspacesService {
/** The calling user's workspaces, without their layouts. */
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
}
export async function activate(ctx: PluginContext) {
startWorkspacesService(ctx.http.router());
const table = await ctx.db.define(workspaces);
const repo = createWorkspaceRepository(ctx.db, table);
registerWorkspaceRoutes(ctx.http.router<Router>(), repo, ctx);
// The last_session row belongs to one install. Syncing it would leave the
// user with two, one from each side.
ctx.sync.registerEntity({
type: "workspaces",
table,
order: 200,
shouldSync: (row) => row.kind !== "last_session",
});
const service: WorkspacesService = {
list: async () => {
const userId = ctx.currentActor();
if (!userId) return [];
const rows = await repo.listByUser(userId);
return rows
.filter((row) => row.kind === "manual")
.map((row) => ({
id: row.id,
name: row.name,
isDefault: !!row.isDefault,
}));
},
};
ctx.services.provide("workspaces.saved", service);
ctx.log.info("Workspaces routes mounted at /plugin-api/workspaces");
}
export async function deactivate() {
stopWorkspacesService();
// Everything above was registered through ctx and is disposed by core.
}
@@ -0,0 +1,252 @@
import { randomUUID } from "node:crypto";
import { and, eq, ne } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export type WorkspaceKind = "manual" | "last_session";
export interface WorkspaceRecord {
id: number;
userId: string;
name: string;
color: string | null;
icon: string | null;
kind: WorkspaceKind;
isDefault: boolean;
payload: string;
syncId: string | null;
createdAt: string;
updatedAt: string;
lastUsedAt: string | null;
}
export interface WorkspaceCreateInput {
name: string;
color?: string | null;
icon?: string | null;
payload: string;
}
export interface WorkspaceUpdateInput {
name?: string;
color?: string | null;
icon?: string | null;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type WorkspaceRepository = ReturnType<typeof createWorkspaceRepository>;
/**
* Per-user workspace storage.
*
* Written without RETURNING so it runs the same on all three engines: an
* insert is read back by the sync id it was given, an update by its id.
*/
export function createWorkspaceRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
async function findById(
userId: string,
id: number,
): Promise<WorkspaceRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return (rows[0] as WorkspaceRecord) ?? null;
}
async function findBySyncId(syncId: string): Promise<WorkspaceRecord> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(eq(table.syncId, syncId))
.limit(1);
return rows[0] as WorkspaceRecord;
}
async function findLastSession(
userId: string,
): Promise<WorkspaceRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.kind, "last_session")))
.limit(1);
return (rows[0] as WorkspaceRecord) ?? null;
}
async function insert(
userId: string,
values: Omit<
WorkspaceRecord,
"id" | "userId" | "syncId" | "createdAt" | "updatedAt" | "lastUsedAt"
>,
now: string,
): Promise<WorkspaceRecord> {
const syncId = randomUUID();
const drizzle = await client();
await drizzle
.insert(table)
.values({ ...values, userId, syncId, createdAt: now, updatedAt: now });
await db.persist();
return findBySyncId(syncId);
}
async function write(
userId: string,
id: number,
values: Record<string, unknown>,
): Promise<WorkspaceRecord | null> {
const drizzle = await client();
await drizzle
.update(table)
.set(values)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return findById(userId, id);
}
/** A manual workspace the user owns, or null. Last Session is not editable. */
async function findManual(
userId: string,
id: number,
): Promise<WorkspaceRecord | null> {
const existing = await findById(userId, id);
return existing && existing.kind === "manual" ? existing : null;
}
return {
findById,
findLastSession,
async listByUser(userId: string): Promise<WorkspaceRecord[]> {
const drizzle = await client();
return drizzle.select().from(table).where(eq(table.userId, userId));
},
async upsertLastSession(
userId: string,
payload: string,
now = new Date().toISOString(),
): Promise<WorkspaceRecord> {
const existing = await findLastSession(userId);
if (!existing) {
return insert(
userId,
{
name: "Last Session",
color: null,
icon: null,
kind: "last_session",
isDefault: false,
payload,
},
now,
);
}
return write(userId, existing.id, { payload, updatedAt: now });
},
async create(
userId: string,
input: WorkspaceCreateInput,
now = new Date().toISOString(),
): Promise<WorkspaceRecord> {
return insert(
userId,
{
name: input.name,
color: input.color ?? null,
icon: input.icon ?? null,
kind: "manual",
isDefault: false,
payload: input.payload,
},
now,
);
},
async update(
userId: string,
id: number,
input: WorkspaceUpdateInput,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
const existing = await findManual(userId, id);
if (!existing) return null;
return write(userId, id, {
name: input.name ?? existing.name,
color: input.color === undefined ? existing.color : input.color,
icon: input.icon === undefined ? existing.icon : input.icon,
updatedAt: now,
});
},
async updateContent(
userId: string,
id: number,
payload: string,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
if (!(await findManual(userId, id))) return null;
return write(userId, id, { payload, updatedAt: now });
},
async setDefault(
userId: string,
id: number,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
if (!(await findManual(userId, id))) return null;
const drizzle = await client();
await drizzle
.update(table)
.set({ isDefault: false, updatedAt: now })
.where(
and(
eq(table.userId, userId),
eq(table.isDefault, true),
ne(table.id, id),
),
);
return write(userId, id, { isDefault: true, updatedAt: now });
},
async unsetDefault(
userId: string,
id: number,
now = new Date().toISOString(),
): Promise<WorkspaceRecord | null> {
if (!(await findManual(userId, id))) return null;
return write(userId, id, { isDefault: false, updatedAt: now });
},
async touchLastUsed(
userId: string,
id: number,
now = new Date().toISOString(),
): Promise<void> {
await write(userId, id, { lastUsedAt: now });
},
async delete(userId: string, id: number): Promise<boolean> {
if (!(await findManual(userId, id))) return false;
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return true;
},
};
}
+329 -397
View File
@@ -1,15 +1,6 @@
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import express, { type Request, type Response, type Router } from "express";
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.js";
import type { WorkspaceRecord } from "../../../../src/backend/database/repositories/workspace-repository.js";
export const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { WorkspaceRecord, WorkspaceRepository } from "./repository.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
@@ -46,73 +37,93 @@ function serialize(record: WorkspaceRecord) {
};
}
function actor(ctx: PluginContext): string {
// Core's plugin router authenticates every request and runs it as that user.
return ctx.currentActor() as string;
}
function logError(
ctx: PluginContext,
message: string,
error: unknown,
details: { operation: string; workspaceId?: number },
): void {
const target =
details.workspaceId !== undefined ? ` ${details.workspaceId}` : "";
ctx.log.error(
`${message}${target} (${details.operation})`,
error instanceof Error ? error : new Error(String(error)),
);
}
/**
* @openapi
* /workspaces:
* get:
* summary: List the current user's saved workspaces
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
* tags:
* - Workspaces
* responses:
* 200:
* description: List of workspaces.
* Mounts the workspace routes on the plugin's router, which core serves at
* /plugin-api/workspaces with auth in front. Every route needs workspaces.use,
* so an admin can hide the feature per role.
*/
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
export function registerWorkspaceRoutes(
router: Router,
repo: WorkspaceRepository,
ctx: PluginContext,
): void {
router.use(ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/workspaces:
* get:
* summary: List the current user's saved workspaces
* description: Returns every manual workspace plus the single auto-maintained "Last Session" workspace, each with a computed tabCount.
* tags:
* - Workspaces
* responses:
* 200:
* description: List of workspaces.
*/
router.get("/", async (req: Request, res: Response) => {
const userId = actor(ctx);
try {
const records =
await createCurrentWorkspaceRepository().listByUser(userId);
const records = await repo.listByUser(userId);
res.json(records.map(serialize));
} catch (err) {
databaseLogger.error("Failed to list workspaces", err, {
logError(ctx, "Failed to list workspaces", err, {
operation: "workspace_list_failed",
userId,
});
res.status(500).json({ error: "Failed to list workspaces" });
}
},
);
});
/**
* @openapi
* /workspaces:
* post:
* summary: Save the current tab arrangement as a new named workspace
* tags:
* - Workspaces
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* color:
* type: string
* icon:
* type: string
* payload:
* type: object
* responses:
* 200:
* description: Workspace created.
* 400:
* description: Invalid request body.
*/
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces:
* post:
* summary: Save the current tab arrangement as a new named workspace
* tags:
* - Workspaces
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* color:
* type: string
* icon:
* type: string
* payload:
* type: object
* responses:
* 200:
* description: Workspace created.
* 400:
* description: Invalid request body.
*/
router.post("/", async (req: Request, res: Response) => {
const userId = actor(ctx);
const { name, color, icon, payload } = req.body ?? {};
if (!isNonEmptyString(name)) {
@@ -125,7 +136,7 @@ router.post(
}
try {
const created = await createCurrentWorkspaceRepository().create(userId, {
const created = await repo.create(userId, {
name: name.trim(),
color: isNonEmptyString(color) ? color : null,
icon: isNonEmptyString(icon) ? icon : null,
@@ -133,77 +144,64 @@ router.post(
});
res.json(serialize(created));
} catch (err) {
databaseLogger.error("Failed to create workspace", err, {
logError(ctx, "Failed to create workspace", err, {
operation: "workspace_create_failed",
userId,
});
res.status(500).json({ error: "Failed to create workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/last-session:
* get:
* summary: Fetch the auto-maintained "Last Session" workspace
* description: Returns null if the current session has never been auto-saved yet.
* tags:
* - Workspaces
* responses:
* 200:
* description: The Last Session workspace, or null.
*/
router.get(
"/last-session",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/last-session:
* get:
* summary: Fetch the auto-maintained "Last Session" workspace
* description: Returns null if the current session has never been auto-saved yet.
* tags:
* - Workspaces
* responses:
* 200:
* description: The Last Session workspace, or null.
*/
router.get("/last-session", async (req: Request, res: Response) => {
const userId = actor(ctx);
try {
const record =
await createCurrentWorkspaceRepository().findLastSession(userId);
const record = await repo.findLastSession(userId);
res.json(record ? serialize(record) : null);
} catch (err) {
databaseLogger.error("Failed to fetch last session workspace", err, {
logError(ctx, "Failed to fetch last session workspace", err, {
operation: "workspace_last_session_get_failed",
userId,
});
res.status(500).json({ error: "Failed to fetch last session workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/last-session:
* put:
* summary: Upsert the auto-maintained "Last Session" workspace
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
* tags:
* - Workspaces
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* payload:
* type: object
* responses:
* 200:
* description: Last Session workspace saved.
* 400:
* description: Invalid request body.
*/
router.put(
"/last-session",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/last-session:
* put:
* summary: Upsert the auto-maintained "Last Session" workspace
* description: Always overwrites the single Last Session row for the caller - never creates a second one. Called by the frontend's debounced auto-save effect.
* tags:
* - Workspaces
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* payload:
* type: object
* responses:
* 200:
* description: Last Session workspace saved.
* 400:
* description: Invalid request body.
*/
router.put("/last-session", async (req: Request, res: Response) => {
const userId = actor(ctx);
const { payload } = req.body ?? {};
if (!isValidPayload(payload)) {
@@ -213,49 +211,43 @@ router.put(
}
try {
const saved = await createCurrentWorkspaceRepository().upsertLastSession(
const saved = await repo.upsertLastSession(
userId,
JSON.stringify(payload),
);
res.json(serialize(saved));
} catch (err) {
databaseLogger.error("Failed to save last session workspace", err, {
logError(ctx, "Failed to save last session workspace", err, {
operation: "workspace_last_session_save_failed",
userId,
});
res.status(500).json({ error: "Failed to save last session workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/{id}:
* patch:
* summary: Rename or recolor a workspace
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace updated.
* 400:
* description: Invalid request, or target is the Last Session workspace.
* 404:
* description: Workspace not found.
*/
router.patch(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/{id}:
* patch:
* summary: Rename or recolor a workspace
* description: Does not accept a payload - use PUT /workspaces/{id}/content to overwrite a workspace's saved tab arrangement. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace updated.
* 400:
* description: Invalid request, or target is the Last Session workspace.
* 404:
* description: Workspace not found.
*/
router.patch("/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
@@ -267,68 +259,58 @@ router.patch(
}
try {
const updated = await createCurrentWorkspaceRepository().update(
userId,
id,
{
name: name !== undefined ? name.trim() : undefined,
color,
icon,
},
);
const updated = await repo.update(userId, id, {
name: name !== undefined ? name.trim() : undefined,
color,
icon,
});
if (!updated) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to update workspace", err, {
logError(ctx, "Failed to update workspace", err, {
operation: "workspace_update_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to update workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/{id}/content:
* put:
* summary: Overwrite a workspace's saved tab arrangement with a new payload
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* payload:
* type: object
* responses:
* 200:
* description: Workspace content updated.
* 400:
* description: Invalid request body.
* 404:
* description: Workspace not found.
*/
router.put(
"/:id/content",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/{id}/content:
* put:
* summary: Overwrite a workspace's saved tab arrangement with a new payload
* description: Used by "Update with current" in the Workspaces panel. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* payload:
* type: object
* responses:
* 200:
* description: Workspace content updated.
* 400:
* description: Invalid request body.
* 404:
* description: Workspace not found.
*/
router.put("/:id/content", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
@@ -342,7 +324,7 @@ router.put(
}
try {
const updated = await createCurrentWorkspaceRepository().updateContent(
const updated = await repo.updateContent(
userId,
id,
JSON.stringify(payload),
@@ -353,50 +335,44 @@ router.put(
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to update workspace content", err, {
logError(ctx, "Failed to update workspace content", err, {
operation: "workspace_content_update_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to update workspace content" });
}
},
);
});
/**
* @openapi
* /workspaces/{id}/duplicate:
* post:
* summary: Duplicate a workspace's content and color/icon under a new name
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 200:
* description: New workspace created from the duplicate.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/duplicate",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/{id}/duplicate:
* post:
* summary: Duplicate a workspace's content and color/icon under a new name
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* responses:
* 200:
* description: New workspace created from the duplicate.
* 404:
* description: Workspace not found.
*/
router.post("/:id/duplicate", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseWorkspaceId(req.params.id);
const { name } = req.body ?? {};
if (id === null) {
@@ -407,13 +383,12 @@ router.post(
}
try {
const repository = createCurrentWorkspaceRepository();
const source = await repository.findById(userId, id);
const source = await repo.findById(userId, id);
if (!source) {
return res.status(404).json({ error: "Workspace not found" });
}
const created = await repository.create(userId, {
const created = await repo.create(userId, {
name: name.trim(),
color: source.color,
icon: source.icon,
@@ -421,224 +396,181 @@ router.post(
});
res.json(serialize(created));
} catch (err) {
databaseLogger.error("Failed to duplicate workspace", err, {
logError(ctx, "Failed to duplicate workspace", err, {
operation: "workspace_duplicate_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to duplicate workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/{id}/set-default:
* post:
* summary: Mark a workspace as the restore-on-login default
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace set as default.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/set-default",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/{id}/set-default:
* post:
* summary: Mark a workspace as the restore-on-login default
* description: Clears isDefault on any other workspace for the caller. Idempotent if the target is already the default. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace set as default.
* 404:
* description: Workspace not found.
*/
router.post("/:id/set-default", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const updated = await createCurrentWorkspaceRepository().setDefault(
userId,
id,
);
const updated = await repo.setDefault(userId, id);
if (!updated) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to set default workspace", err, {
logError(ctx, "Failed to set default workspace", err, {
operation: "workspace_set_default_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to set default workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/{id}/unset-default:
* post:
* summary: Remove a workspace as the restore-on-login default
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace unset as default.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/unset-default",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/{id}/unset-default:
* post:
* summary: Remove a workspace as the restore-on-login default
* description: Idempotent if the target is not currently the default. Rejects the Last Session workspace.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace unset as default.
* 404:
* description: Workspace not found.
*/
router.post("/:id/unset-default", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const updated = await createCurrentWorkspaceRepository().unsetDefault(
userId,
id,
);
const updated = await repo.unsetDefault(userId, id);
if (!updated) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json(serialize(updated));
} catch (err) {
databaseLogger.error("Failed to unset default workspace", err, {
logError(ctx, "Failed to unset default workspace", err, {
operation: "workspace_unset_default_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to unset default workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/{id}/apply:
* post:
* summary: Fetch a workspace to apply and mark it as just used
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The workspace to apply.
* 404:
* description: Workspace not found.
*/
router.post(
"/:id/apply",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/{id}/apply:
* post:
* summary: Fetch a workspace to apply and mark it as just used
* description: Returns the full workspace with its payload parsed, and touches lastUsedAt server-side so the caller does not need a second round trip.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The workspace to apply.
* 404:
* description: Workspace not found.
*/
router.post("/:id/apply", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const repository = createCurrentWorkspaceRepository();
const record = await repository.findById(userId, id);
const record = await repo.findById(userId, id);
if (!record) {
return res.status(404).json({ error: "Workspace not found" });
}
await repository.touchLastUsed(userId, id);
await repo.touchLastUsed(userId, id);
res.json(serialize(record));
} catch (err) {
databaseLogger.error("Failed to apply workspace", err, {
logError(ctx, "Failed to apply workspace", err, {
operation: "workspace_apply_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to apply workspace" });
}
},
);
});
/**
* @openapi
* /workspaces/{id}:
* delete:
* summary: Delete a workspace
* description: Rejects the Last Session workspace, which is not user-deletable.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace deleted.
* 404:
* description: Workspace not found.
*/
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
/**
* @openapi
* /plugin-api/workspaces/{id}:
* delete:
* summary: Delete a workspace
* description: Rejects the Last Session workspace, which is not user-deletable.
* tags:
* - Workspaces
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Workspace deleted.
* 404:
* description: Workspace not found.
*/
router.delete("/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = parseWorkspaceId(req.params.id);
if (id === null) {
return res.status(400).json({ error: "Invalid workspace ID" });
}
try {
const deleted = await createCurrentWorkspaceRepository().delete(
userId,
id,
);
const deleted = await repo.delete(userId, id);
if (!deleted) {
return res.status(404).json({ error: "Workspace not found" });
}
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to delete workspace", err, {
logError(ctx, "Failed to delete workspace", err, {
operation: "workspace_delete_failed",
userId,
workspaceId: id,
});
res.status(500).json({ error: "Failed to delete workspace" });
}
},
);
export function startWorkspacesService(mountOn: Router): void {
mountOn.use(router);
}
export function stopWorkspacesService(): void {
// Unmounted by the runtime when the plugin deactivates.
});
}
+45
View File
@@ -0,0 +1,45 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Saved tab arrangements, one row per workspace plus one "last_session" row
* per user that the frontend keeps current.
*
* Adopted from core's user_workspaces, so the column and index names are the
* legacy ones: the rename carries the existing rows and index across.
*/
export const workspaces = adoptLegacyTable(
"user_workspaces",
defineTable(
"workspaces",
{
id: id(),
userId: refUser(),
name: text().notNull(),
color: text(),
icon: text(),
// "manual" | "last_session"
kind: text().notNull().default("manual"),
isDefault: boolean().notNull().default(false),
// JSON: the shell layout from app.tabs.getLayout
payload: text().notNull().default("{}"),
syncId: varchar().unique(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
lastUsedAt: timestamp(),
},
{
indexes: [{ name: "idx_user_workspaces_user_id", columns: ["userId"] }],
},
),
);
export const tables = [workspaces];
@@ -1,6 +1,9 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
usePluginApi,
useTranslation,
type ShellLayout,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
LayoutTemplate,
@@ -12,40 +15,34 @@ import {
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Badge } from "@/components/badge";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { FOLDER_COLORS } from "@/lib/theme";
import {
listWorkspaces,
createWorkspace,
renameWorkspace,
updateWorkspaceContent,
deleteWorkspace,
duplicateWorkspace,
setDefaultWorkspace,
unsetDefaultWorkspace,
} from "./workspaces-api";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
FOLDER_COLORS,
Input,
} from "@termix/plugin-sdk/ui";
import { createWorkspacesApi, type WorkspacesApi } from "./workspaces-api";
import { errorMessage, type Workspace } from "./types";
function timeAgo(isoStr: string): string {
type Translate = (key: string, options?: Record<string, unknown>) => string;
function timeAgo(isoStr: string, t: Translate): string {
const ms = Date.now() - new Date(isoStr).getTime();
if (!Number.isFinite(ms)) return "";
const prefix = "newUi.sidebar.workspaces.timeAgo";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
if (sec < 60) return t(`${prefix}.seconds`, { count: sec });
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
if (min < 60) return t(`${prefix}.minutes`, { count: min });
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
if (hr < 24) return t(`${prefix}.hours`, { count: hr });
return t(`${prefix}.days`, { count: Math.floor(hr / 24) });
}
function WorkspaceSaveDialog({
@@ -153,10 +150,12 @@ function WorkspaceSaveDialog({
}
function WorkspaceRenameDialog({
api,
workspace,
onClose,
onSaved,
}: {
api: WorkspacesApi;
workspace: Workspace | null;
onClose: () => void;
onSaved: () => void;
@@ -180,12 +179,12 @@ function WorkspaceRenameDialog({
}
setSaving(true);
try {
await renameWorkspace(workspace.id, { name: name.trim(), color });
await api.rename(workspace.id, { name: name.trim(), color });
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
onSaved();
onClose();
} catch (error) {
const message = getErrorMessage(error, "");
const message = errorMessage(error);
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
} finally {
setSaving(false);
@@ -309,7 +308,7 @@ function WorkspaceRow({
? t("newUi.sidebar.workspaces.lastSessionDescription")
: workspace.lastUsedAt
? t("newUi.sidebar.workspaces.lastUsed", {
time: timeAgo(workspace.lastUsedAt),
time: timeAgo(workspace.lastUsedAt, t),
})
: t("newUi.sidebar.workspaces.neverUsed")}
</span>
@@ -390,10 +389,12 @@ export function WorkspacesPanel({
onApplyWorkspace,
}: {
active?: boolean;
currentPayload: () => WorkspacePayload;
currentPayload: () => ShellLayout | null;
onApplyWorkspace: (workspace: Workspace) => void;
}) {
const { t } = useTranslation();
const pluginApi = usePluginApi();
const api = useMemo(() => createWorkspacesApi(pluginApi), [pluginApi]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
@@ -404,13 +405,13 @@ export function WorkspacesPanel({
const loadWorkspaces = useCallback(async () => {
try {
const data = await listWorkspaces();
const data = await api.list();
setWorkspaces(data);
} catch (error) {
const message = getErrorMessage(error, "");
const message = errorMessage(error);
toast.error(message || t("newUi.sidebar.workspaces.loadFailed"));
}
}, [t]);
}, [api, t]);
useEffect(() => {
if (!active || hasLoadedRef.current) return;
@@ -421,11 +422,13 @@ export function WorkspacesPanel({
async function handleSaveNew(name: string, color: string) {
try {
await createWorkspace({ name, color, payload: currentPayload() });
const payload = currentPayload();
if (!payload) return;
await api.create({ name, color, payload });
toast.success(t("newUi.sidebar.workspaces.workspaceCreated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
const message = errorMessage(error);
toast.error(message || t("newUi.sidebar.workspaces.saveFailed"));
throw error;
}
@@ -433,18 +436,20 @@ export function WorkspacesPanel({
async function handleUpdateWithCurrent(workspace: Workspace) {
try {
await updateWorkspaceContent(workspace.id, currentPayload());
const payload = currentPayload();
if (!payload) return;
await api.updateContent(workspace.id, payload);
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
const message = errorMessage(error);
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
}
}
async function handleDuplicate(workspace: Workspace) {
try {
await duplicateWorkspace(
await api.duplicate(
workspace.id,
t("newUi.sidebar.workspaces.duplicateNameSuffix", {
name: workspace.name,
@@ -453,7 +458,7 @@ export function WorkspacesPanel({
toast.success(t("newUi.sidebar.workspaces.workspaceDuplicated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
const message = errorMessage(error);
toast.error(message || t("newUi.sidebar.workspaces.duplicateFailed"));
}
}
@@ -461,13 +466,13 @@ export function WorkspacesPanel({
async function handleToggleDefault(workspace: Workspace) {
try {
if (workspace.isDefault) {
await unsetDefaultWorkspace(workspace.id);
await api.unsetDefault(workspace.id);
} else {
await setDefaultWorkspace(workspace.id);
await api.setDefault(workspace.id);
}
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
const message = errorMessage(error);
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
}
}
@@ -475,12 +480,12 @@ export function WorkspacesPanel({
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteWorkspace(deleteTarget.id);
await api.remove(deleteTarget.id);
toast.success(t("newUi.sidebar.workspaces.workspaceDeleted"));
setDeleteTarget(null);
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
const message = errorMessage(error);
toast.error(message || t("newUi.sidebar.workspaces.deleteFailed"));
}
}
@@ -584,6 +589,7 @@ export function WorkspacesPanel({
/>
<WorkspaceRenameDialog
api={api}
workspace={renameTarget}
onClose={() => setRenameTarget(null)}
onSaved={loadWorkspaces}
+31 -30
View File
@@ -1,34 +1,28 @@
import { LayoutTemplate } from "lucide-react";
import i18next from "i18next";
import { toast } from "sonner";
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
import { WorkspacesPanel } from "./WorkspacesPanel";
import {
applyWorkspaceServer,
listWorkspaces,
saveLastSessionWorkspace,
} from "./workspaces-api";
import { createWorkspacesApi } from "./workspaces-api";
import type { Workspace } from "./types";
const LAST_SESSION_DELAY_MS = 2000;
export function activate(app: TermixApp): void {
const api = createWorkspacesApi(app.api);
const applyWorkspace = async (workspace: Workspace) => {
const { skipped } = await app.tabs.applyLayout(
workspace.payload as unknown as Parameters<
typeof app.tabs.applyLayout
>[0],
{ name: workspace.name },
);
const { skipped } = await app.tabs.applyLayout(workspace.payload, {
name: workspace.name,
});
if (skipped.length > 0) {
toast.warning(
i18next.t("workspaces:newUi.sidebar.workspaces.tabsSkipped", {
app.t("newUi.sidebar.workspaces.tabsSkipped", {
count: skipped.length,
names: skipped.join(", "),
}),
);
}
applyWorkspaceServer(workspace.id).catch(() => {});
api.apply(workspace.id).catch(() => {});
};
function Panel({ active }: PanelProps) {
@@ -36,9 +30,7 @@ export function activate(app: TermixApp): void {
<div className="flex-1 min-h-0 overflow-y-auto">
<WorkspacesPanel
active={active}
currentPayload={() =>
app.tabs.getLayout() as unknown as WorkspacePayload
}
currentPayload={() => app.tabs.getLayout()}
onApplyWorkspace={(workspace) => void applyWorkspace(workspace)}
/>
</div>
@@ -50,22 +42,32 @@ export function activate(app: TermixApp): void {
icon: LayoutTemplate,
titleKey: "nav.workspaces",
after: "split-screen",
permission: "use",
});
app.registerPanel("workspaces", Panel);
// Without workspaces.use the restore and the autosave below would only
// collect 403s. The lookup is cached, so asking each time is cheap and a
// revoke takes effect.
const allowed = () => app.hasPermission("use");
// After login, a default workspace applies unless the session already
// brought tabs back, which is the more precise restore.
app.tabs.onReady(() => {
const layout = app.tabs.getLayout() as unknown as WorkspacePayload | null;
if (layout && layout.tabs.length > 0) return;
listWorkspaces()
.then((workspaces) => {
const layout = app.tabs.getLayout() as { tabs?: unknown[] } | null;
if (layout && (layout.tabs?.length ?? 0) > 0) return;
void allowed().then(async (ok) => {
if (!ok) return;
try {
const workspaces = await api.list();
const preferred = workspaces.find(
(workspace) => workspace.kind === "manual" && workspace.isDefault,
);
if (preferred) void applyWorkspace(preferred);
})
.catch(() => {});
if (preferred) await applyWorkspace(preferred);
} catch {
// A failed restore leaves the empty session as it is.
}
});
});
// Keeps an implicit "last session" snapshot current, so an arrangement can
@@ -75,11 +77,10 @@ export function activate(app: TermixApp): void {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const layout = app.tabs.getLayout();
if (layout) {
saveLastSessionWorkspace(layout as unknown as WorkspacePayload).catch(
() => {},
);
}
if (!layout) return;
void allowed().then((ok) => {
if (ok) api.saveLastSession(layout).catch(() => {});
});
}, LAST_SESSION_DELAY_MS);
});
app.onDispose(() => {
+29
View File
@@ -0,0 +1,29 @@
import type { ShellLayout } from "@termix/plugin-sdk/frontend";
export type WorkspaceKind = "manual" | "last_session";
/** A saved workspace as the routes return it. */
export interface Workspace {
id: number;
userId: string;
name: string;
color: string | null;
icon: string | null;
kind: WorkspaceKind;
isDefault: boolean;
/** Whatever app.tabs.getLayout returned when it was saved. */
payload: ShellLayout & { tabs?: unknown[] };
syncId: string | null;
createdAt: string;
updatedAt: string;
lastUsedAt: string | null;
tabCount: number;
}
/** The server's error text when it sent one, else the error's own message. */
export function errorMessage(error: unknown): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
if (typeof data?.error === "string") return data.error;
return "";
}
+35 -129
View File
@@ -1,132 +1,38 @@
import { authApi, handleApiError } from "@/main-axios";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
import type { PluginApiClient, ShellLayout } from "@termix/plugin-sdk/frontend";
import type { Workspace } from "./types";
export async function listWorkspaces(): Promise<Workspace[]> {
try {
const response = await authApi.get("/plugin-api/workspaces");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch workspaces");
}
/**
* The workspace routes, through the plugin's own client. Paths are relative
* to /plugin-api/workspaces/, which the client already points at.
*/
export function createWorkspacesApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
list: () => data(api.get<Workspace[]>("/")),
create: (body: {
name: string;
color?: string | null;
icon?: string | null;
payload: ShellLayout;
}) => data(api.post<Workspace>("/", body)),
rename: (
id: number,
body: { name?: string; color?: string | null; icon?: string | null },
) => data(api.patch<Workspace>(`/${id}`, body)),
updateContent: (id: number, payload: ShellLayout) =>
data(api.put<Workspace>(`/${id}/content`, { payload })),
remove: (id: number) => data(api.delete<{ success: boolean }>(`/${id}`)),
duplicate: (id: number, name: string) =>
data(api.post<Workspace>(`/${id}/duplicate`, { name })),
setDefault: (id: number) => data(api.post<Workspace>(`/${id}/set-default`)),
unsetDefault: (id: number) =>
data(api.post<Workspace>(`/${id}/unset-default`)),
apply: (id: number) => data(api.post<Workspace>(`/${id}/apply`)),
getLastSession: () => data(api.get<Workspace | null>("/last-session")),
saveLastSession: (payload: ShellLayout) =>
data(api.put<Workspace>("/last-session", { payload })),
};
}
export async function createWorkspace(data: {
name: string;
color?: string | null;
icon?: string | null;
payload: WorkspacePayload;
}): Promise<Workspace> {
try {
const response = await authApi.post("/plugin-api/workspaces", data);
return response.data;
} catch (error) {
throw handleApiError(error, "create workspace");
}
}
export async function renameWorkspace(
id: number,
data: { name?: string; color?: string | null; icon?: string | null },
): Promise<Workspace> {
try {
const response = await authApi.patch(`/plugin-api/workspaces/${id}`, data);
return response.data;
} catch (error) {
throw handleApiError(error, "update workspace");
}
}
export async function updateWorkspaceContent(
id: number,
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put(`/plugin-api/workspaces/${id}/content`, {
payload,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update workspace content");
}
}
export async function deleteWorkspace(
id: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/plugin-api/workspaces/${id}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete workspace");
}
}
export async function duplicateWorkspace(
id: number,
name: string,
): Promise<Workspace> {
try {
const response = await authApi.post(
`/plugin-api/workspaces/${id}/duplicate`,
{
name,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "duplicate workspace");
}
}
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(
`/plugin-api/workspaces/${id}/set-default`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "set default workspace");
}
}
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
try {
const response = await authApi.post(
`/plugin-api/workspaces/${id}/unset-default`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unset default workspace");
}
}
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
try {
const response = await authApi.post(`/plugin-api/workspaces/${id}/apply`);
return response.data;
} catch (error) {
throw handleApiError(error, "apply workspace");
}
}
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
try {
const response = await authApi.get("/plugin-api/workspaces/last-session");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch last session workspace");
}
}
export async function saveLastSessionWorkspace(
payload: WorkspacePayload,
): Promise<Workspace> {
try {
const response = await authApi.put("/plugin-api/workspaces/last-session", {
payload,
});
return response.data;
} catch (error) {
throw handleApiError(error, "save last session workspace");
}
}
export type WorkspacesApi = ReturnType<typeof createWorkspacesApi>;
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("workspaces activate", () => {
it("registers a sync entity that leaves the per-install last session out", async () => {
server = await startServer();
const [entity] = server.mock.syncEntities;
expect(entity.type).toBe("workspaces");
expect(entity.shouldSync?.({ kind: "manual" })).toBe(true);
expect(entity.shouldSync?.({ kind: "last_session" })).toBe(false);
});
it("offers the user's manual workspaces as a service", async () => {
server = await startServer();
await server.request("POST", "/", {
body: { name: "Prod", payload: { version: 1, tabs: [] } },
});
await server.request("PUT", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
const service = server.mock.services.get("workspaces.saved") as {
list: () => Promise<unknown>;
};
const listed = await server.mock.ctx.asUser("user-1", () => service.list());
expect(listed).toEqual([
{ id: expect.any(Number), name: "Prod", isDefault: false },
]);
});
it("fails closed without db:own", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: ["network:serve", "ui:surface"],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
it("fails closed without network:serve", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: ["db:own", "ui:surface"],
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
});
+103
View File
@@ -0,0 +1,103 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: { permissions?: string[]; users?: string[] } = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["workspaces.use"],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
export const layout = (
tabs: unknown[] = [{ slotId: "a", type: "terminal" }],
) => ({
version: 1,
tabs,
activeSlotId: null,
splitMode: "none",
paneTabIds: [],
rowSizes: [],
rowColSizes: [],
});
@@ -0,0 +1,141 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// user_workspaces as core's SQLite bootstrap created it before 2.9.0, plus the
// index performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE 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
);
CREATE INDEX idx_user_workspaces_user_id ON user_workspaces (user_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting user_workspaces", () => {
it("keeps every existing row when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO user_workspaces (user_id, name, kind, is_default, payload, sync_id, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
"user-1",
"Prod",
"manual",
1,
'{"version":1,"tabs":[{"type":"terminal"}]}',
"sync-a",
"2026-01-01T00:00:00.000Z",
);
sqlite
.prepare(
"INSERT INTO user_workspaces (user_id, name, kind, payload, sync_id) VALUES (?, ?, ?, ?, ?)",
)
.run("user-2", "Last Session", "last_session", "{}", "sync-b");
},
});
expect(db.applied).toEqual(["0001_adopt_user_workspaces"]);
expect(tableExists("user_workspaces")).toBe(false);
expect(tableExists("p_workspaces_workspaces")).toBe(true);
const rows = db.sqlite
.prepare(
"SELECT user_id, name, kind, is_default, payload, sync_id, last_used_at FROM p_workspaces_workspaces ORDER BY id",
)
.all();
expect(rows).toEqual([
{
user_id: "user-1",
name: "Prod",
kind: "manual",
is_default: 1,
payload: '{"version":1,"tabs":[{"type":"terminal"}]}',
sync_id: "sync-a",
last_used_at: "2026-01-01T00:00:00.000Z",
},
{
user_id: "user-2",
name: "Last Session",
kind: "last_session",
is_default: 0,
payload: "{}",
sync_id: "sync-b",
last_used_at: null,
},
]);
expect(indexOn("p_workspaces_workspaces")).toEqual([
"idx_user_workspaces_user_id",
]);
});
it("creates the table fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("user_workspaces")).toBe(false);
expect(tableExists("p_workspaces_workspaces")).toBe(true);
expect(indexOn("p_workspaces_workspaces")).toEqual([
"idx_user_workspaces_user_id",
]);
expect(
db.sqlite.prepare("SELECT * FROM p_workspaces_workspaces").all(),
).toEqual([]);
});
it("removes a user's workspaces when the user is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
const insert = db.sqlite.prepare(
"INSERT INTO p_workspaces_workspaces (user_id, name) VALUES (?, ?)",
);
insert.run("user-1", "Mine");
insert.run("user-2", "Theirs");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
const names = (
db.sqlite.prepare("SELECT name FROM p_workspaces_workspaces").all() as {
name: string;
}[]
).map((row) => row.name);
expect(names).toEqual(["Theirs"]);
});
});
@@ -1,204 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { WorkspaceRepository } from "../../../../../src/backend/database/repositories/workspace-repository.js";
describe("WorkspaceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<WorkspaceRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new WorkspaceRepository(context, onWrite);
}
it("creates and lists manual workspaces scoped to the owning user", async () => {
const repo = await createRepository();
await repo.create("user-1", { name: "Prod Debugging", payload: "{}" });
await repo.create("user-2", { name: "Other user's", payload: "{}" });
const list = await repo.listByUser("user-1");
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({
userId: "user-1",
name: "Prod Debugging",
kind: "manual",
isDefault: false,
});
expect(list[0].syncId).toBeTruthy();
});
it("finds a workspace by id scoped to the owner", async () => {
const repo = await createRepository();
const created = await repo.create("user-1", {
name: "Test A",
payload: "{}",
});
expect(await repo.findById("user-1", created.id)).toMatchObject({
id: created.id,
});
expect(await repo.findById("user-2", created.id)).toBeNull();
});
it("upsertLastSession creates then updates a single row, never a second one", async () => {
const repo = await createRepository();
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}');
expect(first.kind).toBe("last_session");
const second = await repo.upsertLastSession(
"user-1",
'{"tabs":[{"slotId":"a"}]}',
);
expect(second.id).toBe(first.id);
expect(second.payload).toBe('{"tabs":[{"slotId":"a"}]}');
const all = await repo.listByUser("user-1");
expect(all.filter((w) => w.kind === "last_session")).toHaveLength(1);
});
it("update renames/recolors a manual workspace but rejects last_session", async () => {
const repo = await createRepository();
const manual = await repo.create("user-1", {
name: "Old Name",
payload: "{}",
});
const lastSession = await repo.upsertLastSession("user-1", "{}");
const updated = await repo.update("user-1", manual.id, {
name: "New Name",
color: "#fff",
});
expect(updated).toMatchObject({ name: "New Name", color: "#fff" });
const rejected = await repo.update("user-1", lastSession.id, {
name: "Should not work",
});
expect(rejected).toBeNull();
});
it("updateContent overwrites payload for a manual workspace but rejects last_session", async () => {
const repo = await createRepository();
const manual = await repo.create("user-1", {
name: "Test A",
payload: "{}",
});
const lastSession = await repo.upsertLastSession("user-1", "{}");
const updated = await repo.updateContent(
"user-1",
manual.id,
'{"tabs":[1]}',
);
expect(updated?.payload).toBe('{"tabs":[1]}');
const rejected = await repo.updateContent(
"user-1",
lastSession.id,
'{"tabs":[2]}',
);
expect(rejected).toBeNull();
});
it("setDefault clears any prior default and rejects last_session", async () => {
const repo = await createRepository();
const a = await repo.create("user-1", { name: "A", payload: "{}" });
const b = await repo.create("user-1", { name: "B", payload: "{}" });
const lastSession = await repo.upsertLastSession("user-1", "{}");
await repo.setDefault("user-1", a.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
await repo.setDefault("user-1", b.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
const rejected = await repo.setDefault("user-1", lastSession.id);
expect(rejected).toBeNull();
});
it("unsetDefault clears isDefault on a manual workspace and rejects last_session", async () => {
const repo = await createRepository();
const a = await repo.create("user-1", { name: "A", payload: "{}" });
const lastSession = await repo.upsertLastSession("user-1", "{}");
await repo.setDefault("user-1", a.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(true);
await repo.unsetDefault("user-1", a.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
const rejected = await repo.unsetDefault("user-1", lastSession.id);
expect(rejected).toBeNull();
});
it("touchLastUsed sets lastUsedAt", async () => {
const repo = await createRepository();
const workspace = await repo.create("user-1", {
name: "A",
payload: "{}",
});
expect(workspace.lastUsedAt).toBeNull();
await repo.touchLastUsed(
"user-1",
workspace.id,
"2026-08-11T00:00:00.000Z",
);
expect((await repo.findById("user-1", workspace.id))?.lastUsedAt).toBe(
"2026-08-11T00:00:00.000Z",
);
});
it("delete removes a manual workspace but rejects last_session", async () => {
const repo = await createRepository();
const manual = await repo.create("user-1", { name: "A", payload: "{}" });
const lastSession = await repo.upsertLastSession("user-1", "{}");
await expect(repo.delete("user-1", lastSession.id)).resolves.toBe(false);
await expect(repo.delete("user-1", manual.id)).resolves.toBe(true);
expect(await repo.findById("user-1", manual.id)).toBeNull();
});
it("triggers writes on create/update/delete", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const created = await repo.create("user-1", {
name: "A",
payload: "{}",
});
await repo.update("user-1", created.id, { name: "B" });
await repo.delete("user-1", created.id);
expect(writeCount).toBe(3);
});
it("deleteByUserId removes every workspace owned by the user", async () => {
const repo = await createRepository();
await repo.create("user-1", { name: "A", payload: "{}" });
await repo.create("user-1", { name: "B", payload: "{}" });
await repo.create("user-2", { name: "C", payload: "{}" });
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
expect(await repo.listByUser("user-1")).toHaveLength(0);
expect(await repo.listByUser("user-2")).toHaveLength(1);
});
});
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { workspaces } from "../../src/backend/tables.js";
import {
createWorkspaceRepository,
type WorkspaceRepository,
} from "../../src/backend/repository.js";
import { pluginDir } from "./helpers";
let db: TestDb;
let repo: WorkspaceRepository;
beforeEach(async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) =>
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')"),
});
repo = createWorkspaceRepository(
db.database,
await db.database.define(workspaces),
);
});
afterEach(() => db.close());
describe("workspace repository", () => {
it("reads a new row back by its own sync id", async () => {
const a = await repo.create("user-1", { name: "A", payload: "{}" });
const b = await repo.create("user-1", { name: "B", payload: "{}" });
expect(a.name).toBe("A");
expect(b.name).toBe("B");
expect(a.syncId).not.toBe(b.syncId);
expect(a.createdAt).toBe(a.updatedAt);
});
it("returns the updated last session rather than a new one", async () => {
const first = await repo.upsertLastSession("user-1", '{"tabs":[]}', "t1");
const second = await repo.upsertLastSession("user-1", '{"tabs":[1]}', "t2");
expect(second.id).toBe(first.id);
expect(second.payload).toBe('{"tabs":[1]}');
expect(second.updatedAt).toBe("t2");
});
it("switches the default without touching another user's", async () => {
const theirs = await repo.create("user-2", { name: "T", payload: "{}" });
await repo.setDefault("user-2", theirs.id);
const a = await repo.create("user-1", { name: "A", payload: "{}" });
const b = await repo.create("user-1", { name: "B", payload: "{}" });
await repo.setDefault("user-1", a.id);
await repo.setDefault("user-1", b.id);
expect((await repo.findById("user-1", a.id))?.isDefault).toBe(false);
expect((await repo.findById("user-1", b.id))?.isDefault).toBe(true);
expect((await repo.findById("user-2", theirs.id))?.isDefault).toBe(true);
});
it("refuses to edit or delete the last session", async () => {
const last = await repo.upsertLastSession("user-1", "{}");
expect(await repo.update("user-1", last.id, { name: "x" })).toBeNull();
expect(await repo.setDefault("user-1", last.id)).toBeNull();
expect(await repo.delete("user-1", last.id)).toBe(false);
});
});
+190 -505
View File
@@ -1,518 +1,203 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { layout, startServer, type TestServer } from "./helpers";
type WorkspaceRow = {
id: number;
userId: string;
name: string;
color: string | null;
icon: string | null;
kind: "manual" | "last_session";
isDefault: boolean;
payload: string;
syncId: string;
createdAt: string;
updatedAt: string;
lastUsedAt: string | null;
};
let server: TestServer;
const state = vi.hoisted(() => ({
currentUserId: "user-1",
workspaces: new Map<number, WorkspaceRow>(),
nextId: 1,
}));
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
function findByIdForUser(userId: string, id: number): WorkspaceRow | null {
const row = state.workspaces.get(id);
return row && row.userId === userId ? row : null;
}
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentWorkspaceRepository: () => ({
listByUser: async (userId: string) =>
[...state.workspaces.values()].filter((w) => w.userId === userId),
findById: async (userId: string, id: number) => findByIdForUser(userId, id),
findLastSession: async (userId: string) =>
[...state.workspaces.values()].find(
(w) => w.userId === userId && w.kind === "last_session",
) ?? null,
upsertLastSession: async (userId: string, payload: string) => {
const existing = [...state.workspaces.values()].find(
(w) => w.userId === userId && w.kind === "last_session",
);
if (existing) {
existing.payload = payload;
existing.updatedAt = new Date().toISOString();
return existing;
}
const row: WorkspaceRow = {
id: state.nextId++,
userId,
name: "Last Session",
color: null,
icon: null,
kind: "last_session",
isDefault: false,
payload,
syncId: `sync-${state.nextId}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastUsedAt: null,
};
state.workspaces.set(row.id, row);
return row;
},
create: async (
userId: string,
input: {
name: string;
color?: string | null;
icon?: string | null;
payload: string;
},
) => {
const row: WorkspaceRow = {
id: state.nextId++,
userId,
name: input.name,
color: input.color ?? null,
icon: input.icon ?? null,
kind: "manual",
isDefault: false,
payload: input.payload,
syncId: `sync-${state.nextId}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastUsedAt: null,
};
state.workspaces.set(row.id, row);
return row;
},
update: async (
userId: string,
id: number,
input: { name?: string; color?: string | null; icon?: string | null },
) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
if (input.name !== undefined) row.name = input.name;
if (input.color !== undefined) row.color = input.color;
if (input.icon !== undefined) row.icon = input.icon;
return row;
},
updateContent: async (userId: string, id: number, payload: string) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
row.payload = payload;
return row;
},
setDefault: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
for (const w of state.workspaces.values()) {
if (w.userId === userId && w.kind === "manual") w.isDefault = false;
}
row.isDefault = true;
return row;
},
unsetDefault: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return null;
row.isDefault = false;
return row;
},
touchLastUsed: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (row) row.lastUsedAt = new Date().toISOString();
},
delete: async (userId: string, id: number) => {
const row = findByIdForUser(userId, id);
if (!row || row.kind !== "manual") return false;
state.workspaces.delete(id);
return true;
},
}),
}));
vi.mock(
"../../../../src/backend/database/routes/workspace-dispatch.js",
() => ({
registerWorkspacesRouter: vi.fn(),
unregisterWorkspacesRouter: vi.fn(),
}),
);
const { router } = await import("../../src/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
} = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.workspaces = new Map();
state.nextId = 1;
beforeEach(async () => {
server = await startServer();
});
describe("GET /", () => {
it("returns the list with a computed tabCount", async () => {
await invoke("post", "/", {
body: {
name: "Test A",
payload: { version: 1, tabs: [{ slotId: "a" }, { slotId: "b" }] },
},
});
afterEach(async () => {
await server.close();
});
const res = await invoke("get", "/");
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toHaveLength(1);
async function create(name = "Prod", user = "user-1") {
const res = await server.request("POST", "/", {
user,
body: { name, color: "#ef4444", payload: layout() },
});
expect(res.status).toBe(200);
return res.body;
}
describe("workspace routes", () => {
it("creates and lists workspaces with a parsed payload and tab count", async () => {
const created = await create();
expect(created).toMatchObject({
name: "Prod",
color: "#ef4444",
kind: "manual",
isDefault: false,
tabCount: 1,
});
expect(created.payload.tabs).toHaveLength(1);
expect(created.syncId).toEqual(expect.any(String));
const list = await server.request("GET", "/");
expect(list.body).toHaveLength(1);
expect(list.body[0].id).toBe(created.id);
});
it("rejects a missing name or payload", async () => {
expect(
(res.jsonBody as unknown as { tabCount: number }[])[0].tabCount,
).toBe(2);
});
});
describe("POST /", () => {
it("400s when name is missing", async () => {
const res = await invoke("post", "/", {
body: { payload: { version: 1, tabs: [] } },
});
expect(res.statusCode).toBe(400);
});
it("400s when payload has no tabs array", async () => {
const res = await invoke("post", "/", {
body: { name: "Test A", payload: { version: 1 } },
});
expect(res.statusCode).toBe(400);
});
it("200s and creates a manual workspace", async () => {
const res = await invoke("post", "/", {
body: { name: "Test A", payload: { version: 1, tabs: [] } },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ name: "Test A", kind: "manual" });
});
});
describe("PATCH /:id", () => {
it("rejects renaming the last_session row", async () => {
const created = await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("patch", "/:id", {
params: { id: String(id) },
body: { name: "Nope" },
});
expect(res.statusCode).toBe(404);
});
it("renames a manual workspace", async () => {
const created = await invoke("post", "/", {
body: { name: "Old", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("patch", "/:id", {
params: { id: String(id) },
body: { name: "New" },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ name: "New" });
});
it("404s for a nonexistent id", async () => {
const res = await invoke("patch", "/:id", {
params: { id: "999" },
body: { name: "New" },
});
expect(res.statusCode).toBe(404);
});
});
describe("PUT /:id/content", () => {
it("updates payload for a manual workspace", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("put", "/:id/content", {
params: { id: String(id) },
body: { payload: { version: 1, tabs: [{ slotId: "x" }] } },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
});
it("404s on wrong owner", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
state.currentUserId = "user-2";
const res = await invoke("put", "/:id/content", {
params: { id: String(id) },
body: { payload: { version: 1, tabs: [] } },
});
expect(res.statusCode).toBe(404);
});
});
describe("DELETE /:id", () => {
it("rejects deleting the last_session row", async () => {
const created = await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
expect(res.statusCode).toBe(404);
});
it("404s for a nonexistent id", async () => {
const res = await invoke("delete", "/:id", { params: { id: "999" } });
expect(res.statusCode).toBe(404);
});
it("deletes a manual workspace", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("delete", "/:id", { params: { id: String(id) } });
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toEqual({ success: true });
});
});
describe("POST /:id/duplicate", () => {
it("produces a second independent row", async () => {
const created = await invoke("post", "/", {
body: {
name: "A",
payload: { version: 1, tabs: [{ slotId: "a" }] },
},
});
const id = (created.jsonBody as unknown as { id: number }).id;
const dup = await invoke("post", "/:id/duplicate", {
params: { id: String(id) },
body: { name: "A (copy)" },
});
expect(dup.statusCode).toBe(200);
expect(dup.jsonBody).toMatchObject({ name: "A (copy)", tabCount: 1 });
const updateOriginal = await invoke("put", "/:id/content", {
params: { id: String(id) },
body: { payload: { version: 1, tabs: [] } },
});
expect(updateOriginal.jsonBody).toMatchObject({ tabCount: 0 });
const dupId = (dup.jsonBody as unknown as { id: number }).id;
const refetched = await invoke("get", "/");
const dupRow = (
refetched.jsonBody as unknown as { id: number; tabCount: number }[]
).find((w) => w.id === dupId);
expect(dupRow?.tabCount).toBe(1);
});
});
describe("POST /:id/set-default", () => {
it("clears a previous default and sets the new one", async () => {
const a = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const b = await invoke("post", "/", {
body: { name: "B", payload: { version: 1, tabs: [] } },
});
const aId = (a.jsonBody as unknown as { id: number }).id;
const bId = (b.jsonBody as unknown as { id: number }).id;
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
const second = await invoke("post", "/:id/set-default", {
params: { id: String(bId) },
});
expect(second.jsonBody).toMatchObject({ isDefault: true });
const list = await invoke("get", "/");
const aRow = (
list.jsonBody as unknown as { id: number; isDefault: boolean }[]
).find((w) => w.id === aId);
expect(aRow?.isDefault).toBe(false);
});
it("is idempotent when re-called on an already-default row", async () => {
const a = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const aId = (a.jsonBody as unknown as { id: number }).id;
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
const again = await invoke("post", "/:id/set-default", {
params: { id: String(aId) },
});
expect(again.statusCode).toBe(200);
expect(again.jsonBody).toMatchObject({ isDefault: true });
});
});
describe("POST /:id/unset-default", () => {
it("clears isDefault on a manual workspace", async () => {
const a = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [] } },
});
const aId = (a.jsonBody as unknown as { id: number }).id;
await invoke("post", "/:id/set-default", { params: { id: String(aId) } });
const res = await invoke("post", "/:id/unset-default", {
params: { id: String(aId) },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({ isDefault: false });
});
it("rejects unsetting the last_session row", async () => {
const created = await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("post", "/:id/unset-default", {
params: { id: String(id) },
});
expect(res.statusCode).toBe(404);
});
});
describe("POST /:id/apply", () => {
it("touches lastUsedAt and returns the parsed payload", async () => {
const created = await invoke("post", "/", {
body: { name: "A", payload: { version: 1, tabs: [{ slotId: "a" }] } },
});
const id = (created.jsonBody as unknown as { id: number }).id;
const res = await invoke("post", "/:id/apply", {
params: { id: String(id) },
});
expect(res.statusCode).toBe(200);
expect(res.jsonBody).toMatchObject({
payload: { version: 1, tabs: [{ slotId: "a" }] },
});
(await server.request("POST", "/", { body: { payload: layout() } }))
.status,
).toBe(400);
expect(
(res.jsonBody as unknown as { lastUsedAt: string | null }).lastUsedAt,
).toBeTruthy();
(await server.request("POST", "/", { body: { name: "x", payload: {} } }))
.status,
).toBe(400);
});
it("keeps one last session row per user and never lists it as editable", async () => {
expect((await server.request("GET", "/last-session")).body).toBeNull();
await server.request("PUT", "/last-session", {
body: { payload: layout() },
});
const second = await server.request("PUT", "/last-session", {
body: { payload: layout([]) },
});
expect(second.body.tabCount).toBe(0);
const list = (await server.request("GET", "/")).body;
expect(
list.filter(
(w: { kind: string; isDefault: boolean; id: number }) =>
w.kind === "last_session",
),
).toHaveLength(1);
const id = second.body.id;
expect(
(await server.request("PATCH", `/${id}`, { body: { name: "x" } })).status,
).toBe(404);
expect((await server.request("DELETE", `/${id}`)).status).toBe(404);
});
it("renames, replaces content and duplicates", async () => {
const created = await create();
const renamed = await server.request("PATCH", `/${created.id}`, {
body: { name: " Staging ", color: "#22c55e" },
});
expect(renamed.body).toMatchObject({ name: "Staging", color: "#22c55e" });
const replaced = await server.request("PUT", `/${created.id}/content`, {
body: { payload: layout([]) },
});
expect(replaced.body.tabCount).toBe(0);
const copy = await server.request("POST", `/${created.id}/duplicate`, {
body: { name: "Staging (copy)" },
});
expect(copy.body).toMatchObject({
name: "Staging (copy)",
color: "#22c55e",
tabCount: 0,
});
expect(copy.body.id).not.toBe(created.id);
});
it("keeps a single default", async () => {
const a = await create("A");
const b = await create("B");
await server.request("POST", `/${a.id}/set-default`);
await server.request("POST", `/${b.id}/set-default`);
const list = (await server.request("GET", "/")).body;
const defaults = list.filter(
(w: { kind: string; isDefault: boolean; id: number }) => w.isDefault,
);
expect(
defaults.map(
(w: { kind: string; isDefault: boolean; id: number }) => w.id,
),
).toEqual([b.id]);
const unset = await server.request("POST", `/${b.id}/unset-default`);
expect(unset.body.isDefault).toBe(false);
});
it("marks a workspace as used when it is applied", async () => {
const created = await create();
expect(created.lastUsedAt).toBeNull();
const applied = await server.request("POST", `/${created.id}/apply`);
expect(applied.status).toBe(200);
const list = (await server.request("GET", "/")).body;
expect(list[0].lastUsedAt).toEqual(expect.any(String));
});
it("deletes a workspace", async () => {
const created = await create();
expect((await server.request("DELETE", `/${created.id}`)).body).toEqual({
success: true,
});
expect((await server.request("GET", "/")).body).toEqual([]);
expect((await server.request("DELETE", `/${created.id}`)).status).toBe(404);
});
it("rejects ids that are not numbers", async () => {
expect((await server.request("DELETE", "/abc")).status).toBe(400);
expect((await server.request("POST", "/abc/apply")).status).toBe(400);
});
it("never shows or changes another user's workspaces", async () => {
const mine = await create("Mine", "user-1");
expect((await server.request("GET", "/", { user: "user-2" })).body).toEqual(
[],
);
for (const [method, path] of [
["PATCH", `/${mine.id}`],
["DELETE", `/${mine.id}`],
["POST", `/${mine.id}/apply`],
["POST", `/${mine.id}/set-default`],
["POST", `/${mine.id}/duplicate`],
] as const) {
const res = await server.request(method, path, {
user: "user-2",
body: { name: "stolen" },
});
expect(res.status).toBe(404);
}
});
it("flushes the database after every write", async () => {
const before = server.db.persisted;
const created = await create();
await server.request("POST", `/${created.id}/set-default`);
await server.request("DELETE", `/${created.id}`);
expect(server.db.persisted).toBeGreaterThanOrEqual(before + 3);
});
});
describe("PUT /last-session and GET /last-session", () => {
it("returns null before any save", async () => {
const res = await invoke("get", "/last-session");
expect(res.jsonBody).toBeNull();
});
describe("workspaces.use", () => {
it("refuses every route without it", async () => {
await server.close();
server = await startServer({ permissions: [] });
it("upserts idempotently - two calls produce one row", async () => {
await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [] } },
});
await invoke("put", "/last-session", {
body: { payload: { version: 1, tabs: [{ slotId: "a" }] } },
});
const list = await invoke("get", "/");
const lastSessionRows = (
list.jsonBody as unknown as { kind: string }[]
).filter((w) => w.kind === "last_session");
expect(lastSessionRows).toHaveLength(1);
const res = await invoke("get", "/last-session");
expect(res.jsonBody).toMatchObject({ tabCount: 1 });
for (const [method, path] of [
["GET", "/"],
["POST", "/"],
["GET", "/last-session"],
["PUT", "/last-session"],
["PATCH", "/1"],
["PUT", "/1/content"],
["POST", "/1/duplicate"],
["POST", "/1/set-default"],
["POST", "/1/unset-default"],
["POST", "/1/apply"],
["DELETE", "/1"],
] as const) {
const res = await server.request(method, path, {
body: { name: "x", payload: layout() },
});
expect(res.status, `${method} ${path}`).toBe(403);
expect(res.body.required).toBe("workspaces.use");
}
});
});
@@ -1,4 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
@@ -51,3 +53,164 @@ describe(`${manifest.id} activate`, () => {
expect(app.registered.actions()).toEqual([]);
});
});
function workspace(overrides: Record<string, unknown> = {}) {
return {
id: 1,
userId: "user-1",
name: "Prod",
color: "#ef4444",
icon: null,
kind: "manual",
isDefault: false,
payload: {
version: 1,
tabs: [{ slotId: "s1", type: "network_graph", label: "Graph" }],
activeSlotId: "s1",
splitMode: "none",
paneTabIds: [],
rowSizes: [],
rowColSizes: [],
},
syncId: "sync-1",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: null,
tabCount: 1,
...overrides,
};
}
function stubApi(workspaces: unknown[]) {
return {
get: vi.fn(async () => ({ data: workspaces })),
post: vi.fn(async () => ({ data: workspaces[0] })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: { success: true } })),
};
}
async function applyThroughPanel(app: RenderedPluginApp, name: string) {
app.renderPanel("workspaces", { active: true });
fireEvent.click(await screen.findByText(name));
fireEvent.click(
await screen.findByText("Apply Workspace", { selector: "button" }),
);
await waitFor(() =>
expect(app.shellCalls.some((call) => call.method === "applyLayout")).toBe(
true,
),
);
}
describe("workspaces panel", () => {
it("gates its rail item on workspaces.use", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.railItems()).toEqual([
expect.objectContaining({
id: "workspaces",
permission: "workspaces.use",
}),
]);
});
it("lists the user's workspaces", async () => {
const api = stubApi([
workspace(),
workspace({ id: 2, name: "Last Session", kind: "last_session" }),
]);
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
});
rendered.renderPanel("workspaces", { active: true });
expect(await screen.findByText("Prod")).toBeTruthy();
expect(
screen.getByText(locales.newUi.sidebar.workspaces.lastSession),
).toBeTruthy();
expect(api.get).toHaveBeenCalledWith("/");
});
it("applies a workspace to the tabs and marks it used", async () => {
const saved = workspace();
const api = stubApi([saved]);
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
});
await applyThroughPanel(rendered, "Prod");
const call = rendered.shellCalls.find((c) => c.method === "applyLayout");
expect(call?.args[0]).toEqual(saved.payload);
expect(call?.args[1]).toEqual({ name: "Prod" });
await waitFor(() => expect(api.post).toHaveBeenCalledWith("/1/apply"));
});
it("keeps a tab from a plugin that is not running and shows its placeholder", async () => {
const saved = workspace({
payload: {
version: 1,
tabs: [{ slotId: "s1", type: "not-installed-view", label: "Gone" }],
activeSlotId: "s1",
splitMode: "none",
paneTabIds: [],
rowSizes: [],
rowColSizes: [],
},
});
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi([saved]) as unknown as PluginApiClient,
});
await applyThroughPanel(rendered, "Prod");
expect(rendered.openedTabs()).toEqual([
{ type: "not-installed-view", hostId: undefined, label: "Gone" },
]);
const tab = rendered.renderOpenedTab(0);
expect(
tab.querySelector('[data-testid="plugin-view-placeholder"]'),
).not.toBeNull();
});
it("restores the default workspace once the shell is ready", async () => {
const saved = workspace({ isDefault: true });
rendered = await renderWithApp(plugin, {
manifest,
locales,
permissions: ["workspaces.use"],
ready: true,
api: stubApi([saved]) as unknown as PluginApiClient,
});
await waitFor(() =>
expect(
rendered!.shellCalls.find((c) => c.method === "applyLayout")?.args[0],
).toEqual(saved.payload),
);
});
it("does not restore or autosave for a user without workspaces.use", async () => {
const api = stubApi([workspace({ isDefault: true })]);
rendered = await renderWithApp(plugin, {
manifest,
locales,
permissions: [],
ready: true,
api: api as unknown as PluginApiClient,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(api.get).not.toHaveBeenCalled();
expect(rendered.shellCalls.some((c) => c.method === "applyLayout")).toBe(
false,
);
});
});
@@ -1,133 +1,57 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import { createWorkspacesApi } from "../../src/frontend/workspaces-api";
const authApiMock = vi.hoisted(() => ({
get: vi.fn(async () => ({ data: [] })),
post: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: { success: true } })),
}));
function fakeClient() {
const reply = (data: unknown) => vi.fn(async () => ({ data }));
return {
get: reply([]),
post: reply({ id: 1 }),
put: reply({ id: 1 }),
patch: reply({ id: 1 }),
delete: reply({ success: true }),
};
}
vi.mock("@/main-axios", () => ({
authApi: authApiMock,
handleApiError: (error: unknown) => error,
}));
const layout = { version: 1, tabs: [] };
import {
listWorkspaces,
createWorkspace,
renameWorkspace,
updateWorkspaceContent,
deleteWorkspace,
duplicateWorkspace,
setDefaultWorkspace,
applyWorkspaceServer,
getLastSessionWorkspace,
saveLastSessionWorkspace,
} from "../../src/frontend/workspaces-api";
import type { WorkspacePayload } from "@/types/ui-types";
describe("workspaces api", () => {
it("calls each route relative to the plugin's own mount point", async () => {
const client = fakeClient();
const api = createWorkspacesApi(client as unknown as PluginApiClient);
beforeEach(() => {
authApiMock.get.mockClear();
authApiMock.post.mockClear();
authApiMock.patch.mockClear();
authApiMock.put.mockClear();
authApiMock.delete.mockClear();
});
await api.list();
await api.create({ name: "A", color: "#fff", payload: layout });
await api.rename(3, { name: "B" });
await api.updateContent(3, layout);
await api.remove(3);
await api.duplicate(3, "C");
await api.setDefault(3);
await api.unsetDefault(3);
await api.apply(3);
await api.getLastSession();
await api.saveLastSession(layout);
const samplePayload: WorkspacePayload = {
version: 1,
tabs: [],
activeSlotId: null,
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
};
describe("workspaces-api", () => {
it("listWorkspaces GETs /plugin-api/workspaces", async () => {
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
const result = await listWorkspaces();
expect(authApiMock.get).toHaveBeenCalledWith("/plugin-api/workspaces");
expect(result).toEqual([{ id: 1 }]);
expect(client.get.mock.calls).toEqual([["/"], ["/last-session"]]);
expect(client.post.mock.calls).toEqual([
["/", { name: "A", color: "#fff", payload: layout }],
["/3/duplicate", { name: "C" }],
["/3/set-default"],
["/3/unset-default"],
["/3/apply"],
]);
expect(client.patch.mock.calls).toEqual([["/3", { name: "B" }]]);
expect(client.put.mock.calls).toEqual([
["/3/content", { payload: layout }],
["/last-session", { payload: layout }],
]);
expect(client.delete.mock.calls).toEqual([["/3"]]);
});
it("createWorkspace POSTs /plugin-api/workspaces with the payload", async () => {
await createWorkspace({
name: "Test A",
color: "#fff",
payload: samplePayload,
});
expect(authApiMock.post).toHaveBeenCalledWith("/plugin-api/workspaces", {
name: "Test A",
color: "#fff",
payload: samplePayload,
});
});
it("renameWorkspace PATCHes /plugin-api/workspaces/:id", async () => {
await renameWorkspace(5, { name: "New Name" });
expect(authApiMock.patch).toHaveBeenCalledWith("/plugin-api/workspaces/5", {
name: "New Name",
});
});
it("updateWorkspaceContent PUTs /plugin-api/workspaces/:id/content", async () => {
await updateWorkspaceContent(5, samplePayload);
expect(authApiMock.put).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/content",
{
payload: samplePayload,
},
);
});
it("deleteWorkspace DELETEs /plugin-api/workspaces/:id", async () => {
await deleteWorkspace(5);
expect(authApiMock.delete).toHaveBeenCalledWith("/plugin-api/workspaces/5");
});
it("duplicateWorkspace POSTs /plugin-api/workspaces/:id/duplicate with a name", async () => {
await duplicateWorkspace(5, "Test A (copy)");
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/duplicate",
{
name: "Test A (copy)",
},
);
});
it("setDefaultWorkspace POSTs /plugin-api/workspaces/:id/set-default", async () => {
await setDefaultWorkspace(5);
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/set-default",
);
});
it("applyWorkspaceServer POSTs /plugin-api/workspaces/:id/apply", async () => {
await applyWorkspaceServer(5);
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/workspaces/5/apply",
);
});
it("getLastSessionWorkspace GETs /plugin-api/workspaces/last-session", async () => {
authApiMock.get.mockResolvedValueOnce({ data: null });
const result = await getLastSessionWorkspace();
expect(authApiMock.get).toHaveBeenCalledWith(
"/plugin-api/workspaces/last-session",
);
expect(result).toBeNull();
});
it("saveLastSessionWorkspace PUTs /plugin-api/workspaces/last-session", async () => {
await saveLastSessionWorkspace(samplePayload);
expect(authApiMock.put).toHaveBeenCalledWith(
"/plugin-api/workspaces/last-session",
{
payload: samplePayload,
},
);
it("returns the response body", async () => {
const client = fakeClient();
client.get = vi.fn(async () => ({ data: [{ id: 7 }] }));
const api = createWorkspacesApi(client as unknown as PluginApiClient);
expect(await api.list()).toEqual([{ id: 7 }]);
});
});
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+2 -6
View File
@@ -106,10 +106,7 @@
"plugins/web-endpoint/src/frontend/index.tsx",
"plugins/web-endpoint/src/frontend/web-endpoint-api.ts",
"plugins/web-endpoint/src/frontend/web-endpoint-url.ts",
"plugins/web-endpoint/src/frontend/web-endpoint-validation.ts",
"plugins/workspaces/src/frontend/WorkspacesPanel.tsx",
"plugins/workspaces/src/frontend/index.tsx",
"plugins/workspaces/src/frontend/workspaces-api.ts"
"plugins/web-endpoint/src/frontend/web-endpoint-validation.ts"
],
"plugin-to-core": [
"plugins/ai/src/backend/egress.ts",
@@ -179,8 +176,7 @@
"plugins/remote-desktop/src/backend/token-service.ts",
"plugins/ssh-terminal/src/backend/index.ts",
"plugins/tailscale/src/backend/routes.ts",
"plugins/web-endpoint/src/backend/routes.ts",
"plugins/workspaces/src/backend/routes.ts"
"plugins/web-endpoint/src/backend/routes.ts"
],
"plugin-to-plugin": [
"plugins/ai/src/backend/tools/executor.ts",
-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"]);
});
});