feat: move snippets backend and tables into a plugin (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:58:53 -05:00
1 parent fdedca39fd
commit e07221eaf3
51 files changed
+28426 -3553

No files matched your search

+1 -1
View File
@@ -161,7 +161,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-QkuxsYffmY3dh9W+bFkdt12Ku27IheHoKtf6Nwc2rFE='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-6OrZHOmlg8SaaMdXOWFOMsBrosTVDr0JC5JXhs2mSls='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+1 -1
View File
@@ -142,7 +142,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-QkuxsYffmY3dh9W+bFkdt12Ku27IheHoKtf6Nwc2rFE='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-6OrZHOmlg8SaaMdXOWFOMsBrosTVDr0JC5JXhs2mSls='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+4
View File
@@ -0,0 +1,4 @@
-- snippets, snippet_folders and snippet_access moved to the snippets plugin
-- (p_snippets_*), adopted by rename rather than dropped. A real DROP TABLE
-- here would run before the plugin's adoption migration and delete every row.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -232,6 +232,13 @@
"when": 1790166644943,
"tag": "0032_lethal_marrow",
"breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1790169398523,
"tag": "0033_clear_anthem",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- snippets, snippet_folders and snippet_access moved to the snippets plugin
-- (p_snippets_*), adopted by rename rather than dropped. A real DROP TABLE
-- here would run before the plugin's adoption migration and delete every row.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -239,6 +239,13 @@
"when": 1790166644445,
"tag": "0033_bent_trish_tilby",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1790169398038,
"tag": "0034_free_smiling_tiger",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- snippets, snippet_folders and snippet_access moved to the snippets plugin
-- (p_snippets_*), adopted by rename rather than dropped. A real DROP TABLE
-- here would run before the plugin's adoption migration and delete every row.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -211,6 +211,13 @@
"when": 1790166643921,
"tag": "0029_numerous_aaron_stack",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1790169397528,
"tag": "0030_quick_felicia_hardy",
"breakpoints": true
}
]
}
+11
View File
@@ -7009,6 +7009,10 @@
"resolved": "plugins/remote-desktop",
"link": true
},
"node_modules/@termix-plugin/snippets": {
"resolved": "plugins/snippets",
"link": true
},
"node_modules/@termix-plugin/ssh-terminal": {
"resolved": "plugins/ssh-terminal",
"link": true
@@ -18573,6 +18577,13 @@
"@termix/plugin-sdk": "*"
}
},
"plugins/snippets": {
"name": "@termix-plugin/snippets",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/ssh-terminal": {
"name": "@termix-plugin/ssh-terminal",
"version": "1.0.0",
+39 -10
View File
@@ -190,6 +190,14 @@ a push, update or tombstone aimed at one is refused with 400. It exists for
state that belongs to one install, such as the workspaces plugin's "Last
Session" row, which would otherwise leave a user with one from each side.
**Deletes need a tombstone.** Registering an entity does not make a delete
propagate on its own: a pull reads `sync_tombstones` for the entity's wire
name, so a plugin route that deletes a synced row calls
`ctx.sync.recordTombstone(userId, entityType, syncId)` right after, the way
core's own delete routes call `SyncTombstoneRepository.record`. **B2** added
this method to the SDK; the snippets plugin's folder and snippet deletes are
its first callers.
### 6. HTTP and WebSockets
Plugin routes live under `/plugin-api/<id>/` and sockets under
@@ -418,6 +426,14 @@ Slots core owns: `terminal.toolbar`, `terminal.dock`, `terminal.overlay`
and `session.remoteDisplay`. Cross-plugin frontend calls go through actions:
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
**B2** added a live-terminal-session surface, for a plugin that needs to push
resolved text into an open SSH session rather than just fill a slot with a
button: ssh-terminal registers `terminal.listSessions()` (open sessions with
their host info), `terminal.sendToActive(text, { run })` and
`terminal.sendToSession(sessionId, text, { run })`, backed by a small registry
its terminal tab wrapper populates from the core terminal ref it already
holds. The snippets plugin's run/paste flow is the first caller.
#### View ownership
Who owns a tab, panel or card comes from manifests, so it is known even for a
@@ -507,9 +523,14 @@ not care about the gates, `createTestDb(pluginDir)` for a real database, and
`renderWithApp(plugin, options)` for frontend tests.
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
`users` and `ssh_data` tables and foreign keys on, runs `before` (seed a legacy
table there to test an adoption), then applies the plugin's own
`migrations/sqlite` with the runner's splitter. Pass its `database` to
`users`, `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs
`before` (seed a legacy table there to test an adoption), then applies the
plugin's own `migrations/sqlite` with the runner's splitter. `ctx.db.refs()`
against this database returns the same four, built by `buildRefTable`, which
maps each declared property to its real snake_case column (`displayName` to
`display_name`) the same way `buildTable` does, so a repository written
against the real schema's property names works unchanged in a test. Pass its
`database` to
`createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and
`persisted` counts `persist()` calls. The mock also takes `router` (for example
`() => express.Router()`, so routes are served for real), `permissions`
@@ -1017,10 +1038,15 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
`ctx.ssh` was pulled forward from B so plugin transports go through core's
connect pipeline instead of importing ssh2 helpers from core:
- `connect(hostOrId, { purpose, pool? })` returns `{ client, dispose }`.
- `connect(hostOrId, { purpose, pool? })` returns `{ client, host, dispose }`.
`host` is the host core resolved (ip, port, username, name and the rest),
for a plugin that only had a numeric id and needs the details, such as
substituting them into a command. **B2** added this field.
- `withConnection(host, { pool, purpose }, fn)` borrows a pooled connection.
- `jumpChain(jumpHosts, { forHost? })` returns a client at the end of a jump
chain, for forwarding to something that is not SSH.
chain, for forwarding to something that is not SSH. `host` on the result is
`forHost` when given, else a placeholder built from the last hop's id, since
a chain has no single resolved host of its own.
- `prepare(host, { client, purpose })`, `openTransport`,
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
transport with its own prompt flow (docker's console, host metrics).
@@ -1115,8 +1141,11 @@ architecture did.
tables, and the `p_<id>_` prefix, the CLI check and the runner's check all say
a plugin may only touch its own. None of that is enforced by the engine: a
plugin holding that handle can write any table in the database, and `ctx.db.refs`
hands it `users` and `ssh_data` outright. The prefix, the capability, the audit
line, lint and review are the contract. The engine is not.
hands it `users`, `ssh_data`, `roles` and `user_roles` outright (the last two
added in **B2**, for a plugin that shares its own rows with a role and needs
to resolve a caller's role membership and display names). The prefix, the
capability, the audit line, lint and review are the contract. The engine is
not.
So the honest boundary is: capabilities stop accidental and casual overreach,
and make every privileged call auditable. What stops malicious code is
@@ -1482,8 +1511,8 @@ Then, with the app running:
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces, which A9 rebuilt
as the reference conversion and which imports nothing from core. They still reach core by relative
The bundled plugins predate the SDK, apart from workspaces (A9) and snippets
(B2), which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or
@@ -1510,7 +1539,7 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 105 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 107 files | D1 |
| A plugin importing core by relative path | Warning | 67 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
+18 -4
View File
@@ -81,10 +81,11 @@ export interface PluginKeyValue {
* to query through `db`. Both require db:own.
*
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
* users and ssh_data read-only so a plugin can join against them without being
* able to write them. In-process code could reach around all of this - see
* "What this protects, and what it does not" in ARCHITECTURE.md. The
* capability, the prefix, lint and review are the contract, not a sandbox.
* users, ssh_data, roles and user_roles read-only so a plugin can join against
* them without being able to write them. In-process code could reach around
* all of this - see "What this protects, and what it does not" in
* ARCHITECTURE.md. The capability, the prefix, lint and review are the
* contract, not a sandbox.
*/
export interface PluginDatabase {
/** Registers a definition and returns its queryable table object. */
@@ -158,6 +159,17 @@ export interface SyncEntityRegistration {
/** Adds an entity to remote sync between a desktop backend and a server. */
export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
/**
* Records that a row was deleted, so a pull on the other side of remote
* sync removes it too. Call it with the row's syncId right after deleting
* it, for a registered entity whose rows can be deleted (a no-op silently
* loses deletes across devices otherwise). A falsy syncId is ignored.
*/
recordTombstone: (
userId: string,
entityType: string,
syncId: string | null | undefined,
) => Promise<void>;
}
export interface PluginRegistry {
@@ -481,6 +493,8 @@ export interface PluginSshConnection<Client = unknown> {
/** An ssh2 Client, already "ready". */
client: Client;
jumpClient: Client | null;
/** The host as core resolved it: ip, port, username, name and the rest. */
host: PluginSshHost;
/** Ends the connection and its jump chain. Also runs on deactivate. */
dispose: () => void;
}
+9 -3
View File
@@ -92,8 +92,11 @@ export function buildTable(
}
/**
* A minimal stand-in for a core table, for tests that join against users or
* ssh_data without core's schema. Only what createTestDb's stubs declare.
* A minimal stand-in for a core table, for tests that join against users,
* ssh_data, roles or user_roles without core's schema. Only what
* createTestDb's stubs declare. The property name is camelCased and mapped
* to its snake_case column the same way buildTable does, so e.g. a stub's
* "displayName" property reads the real "display_name" SQL column.
*/
export function buildRefTable(
name: string,
@@ -101,8 +104,11 @@ export function buildRefTable(
): unknown {
const built: Record<string, unknown> = {};
for (const [property, type] of Object.entries(columns)) {
const columnSqlName = columnName(property);
built[property] =
type === "integer" ? sqliteInteger(property) : sqliteText(property);
type === "integer"
? sqliteInteger(columnSqlName)
: sqliteText(columnSqlName);
}
return sqliteTable(name, built as never);
}
+37 -4
View File
@@ -70,6 +70,8 @@ export interface FakePluginContext {
tables: PluginTableDefinition[];
/** Sync entities registered through ctx.sync.registerEntity, in order. */
syncEntities: SyncEntityRegistration[];
/** Tombstones recorded through ctx.sync.recordTombstone, in order. */
tombstones: Array<{ userId: string; entityType: string; syncId: string }>;
/** WebSocket routes registered through ctx.ws, in order. */
wsRoutes: Array<{ path: string; raw: boolean }>;
/** Router options passed to ctx.http.router, in order. */
@@ -137,6 +139,11 @@ export function createFakeContext(
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
const tombstones: Array<{
userId: string;
entityType: string;
syncId: string;
}> = [];
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
const httpRouters: Array<PluginRouterOptions | undefined> = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>();
@@ -263,6 +270,10 @@ export function createFakeContext(
registerEntity: (entity) => {
syncEntities.push(entity);
},
recordTombstone: async (userId, entityType, syncId) => {
if (!syncId) return;
tombstones.push({ userId, entityType, syncId });
},
},
registry: {
@@ -380,6 +391,9 @@ export function createFakeContext(
return {
client: sshClient as never,
jumpClient: null,
host: (typeof host === "number"
? { id: host, ip: "", port: 22, username: "" }
: host) as never,
dispose: () => {},
};
},
@@ -390,6 +404,7 @@ export function createFakeContext(
jumpChain: async () => ({
client: sshClient as never,
jumpClient: null,
host: { id: 0, ip: "", port: 22, username: "" } as never,
dispose: () => {},
}),
poolKey: (pool, host) =>
@@ -452,6 +467,7 @@ export function createFakeContext(
kv,
tables,
syncEntities,
tombstones,
wsRoutes,
httpRouters,
settings,
@@ -722,10 +738,11 @@ export interface TestDb {
/**
* An in-memory SQLite database with a plugin's own migrations applied.
*
* Core's users and ssh_data exist as minimal stubs with foreign keys on, so a
* refUser or refHost column cascades exactly as it does on the server. The
* migrations are read from <pluginDir>/migrations/sqlite and split with the
* same splitter the server's runner uses.
* Core's users, ssh_data, roles and user_roles exist as minimal stubs with
* foreign keys on, so a refUser or refHost column cascades exactly as it
* does on the server, and ctx.db.refs() has something real to join against.
* The migrations are read from <pluginDir>/migrations/sqlite and split with
* the same splitter the server's runner uses.
*/
export async function createTestDb(
pluginDir: string,
@@ -747,6 +764,12 @@ export async function createTestDb(
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')",
);
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT)");
sqlite.exec(
"CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '')",
);
sqlite.exec(
"CREATE TABLE user_roles (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE)",
);
const handle = drizzle(sqlite);
const pluginId = path.basename(pluginDir);
@@ -781,6 +804,16 @@ export async function createTestDb(
const refs = {
users: builder.buildRefTable("users", { id: "text", username: "text" }),
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
roles: builder.buildRefTable("roles", {
id: "integer",
name: "text",
displayName: "text",
}),
userRoles: builder.buildRefTable("user_roles", {
id: "integer",
userId: "text",
roleId: "integer",
}),
};
const database: PluginDatabase = {
+3 -1
View File
@@ -111,7 +111,9 @@ function main() {
}
if (problems.length > 0) {
console.error("The shell names plugins it should reach through a registry:");
console.error(
"The shell names plugins it should reach through a registry:",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
+4 -1
View File
@@ -74,7 +74,10 @@ export function scanLegacyCoreModules(repoRoot) {
}
for (const plugin of plugins) {
if (!plugin.isDirectory()) continue;
const files = walk(path.join(pluginsDir, plugin.name, "src", "frontend"), []);
const files = walk(
path.join(pluginsDir, plugin.name, "src", "frontend"),
[],
);
for (const file of files) {
for (const specifier of findLegacyCoreImports(
fs.readFileSync(file, "utf8"),
+2
View File
@@ -92,7 +92,9 @@
"plugins/remote-desktop/src/frontend/index.tsx",
"plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts",
"plugins/remote-desktop/src/frontend/rdp-credential-prompt.ts",
"plugins/ssh-terminal/src/frontend/TerminalTabWithRegistry.tsx",
"plugins/ssh-terminal/src/frontend/index.tsx",
"plugins/ssh-terminal/src/frontend/session-registry.ts",
"plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx",
"plugins/tailscale/src/frontend/TailscaleCheckOverlay.tsx",
"plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx",
+3 -2
View File
@@ -52,7 +52,8 @@ export { default } from ${target};`
}
const require = createRequire(path.join(repoRoot, "package.json"));
const names = Object.keys(require(specifier)).filter(
(name) => name !== "default" && name !== "__esModule" && IDENTIFIER.test(name),
(name) =>
name !== "default" && name !== "__esModule" && IDENTIFIER.test(name),
);
const lines = [`import * as m from ${JSON.stringify(specifier)};`];
lines.push("const d = m.default ?? m;");
@@ -140,7 +141,7 @@ export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
"}",
"for (const [file, load] of Object.entries(locales)) {",
' const parts = file.split("/");',
' const id = parts[2];',
" const id = parts[2];",
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
" (workspaceLocales[id] ??= {})[name] = load;",
"}",
-2
View File
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
@@ -1764,7 +1763,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
-70
View File
@@ -435,17 +435,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1581,65 +1570,6 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("snippets", "folder", "TEXT");
addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("snippets", "host_filter", "TEXT");
addColumnIfNotExists("snippets", "is_note", "INTEGER NOT NULL DEFAULT 0");
try {
sqlite
.prepare("SELECT id FROM snippet_folders LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS snippet_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create snippet_folders table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS snippet_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'view',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create snippet_access table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
} catch {
@@ -98,23 +98,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Snippets.
{ name: "idx_snippets_user_id", table: "snippets", columns: "user_id" },
{
name: "idx_snippet_access_user_id",
table: "snippet_access",
columns: "user_id",
},
{
name: "idx_snippet_access_snippet_id",
table: "snippet_access",
columns: "snippet_id",
},
{
name: "idx_snippet_access_role_id",
table: "snippet_access",
columns: "role_id",
},
// Per-user history and file manager surfaces.
{
-75
View File
@@ -557,48 +557,6 @@ export const sshCredentialUsage = mysqlTable(
],
);
export const snippets = mysqlTable(
"snippets",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
order: int("order").notNull().default(0),
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)`),
hostFilter: text("host_filter"),
isNote: boolean("is_note").notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = mysqlTable("snippet_folders", {
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"),
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)`),
});
export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -616,39 +574,6 @@ export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const snippetAccess = mysqlTable(
"snippet_access",
{
id: int("id").autoincrement().primaryKey(),
snippetId: int("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = mysqlTable(
"ssh_folders",
{
-75
View File
@@ -558,48 +558,6 @@ export const sshCredentialUsage = pgTable(
],
);
export const snippets = pgTable(
"snippets",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
order: integer("order").notNull().default(0),
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`),
hostFilter: text("host_filter"),
isNote: boolean("is_note").notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = pgTable("snippet_folders", {
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"),
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`),
});
export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -617,39 +575,6 @@ export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = pgTable(
"snippet_access",
{
id: serial("id").primaryKey(),
snippetId: integer("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = pgTable(
"ssh_folders",
{
-75
View File
@@ -550,48 +550,6 @@ export const sshCredentialUsage = sqliteTable(
],
);
export const snippets = sqliteTable(
"snippets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
hostFilter: text("host_filter"),
isNote: integer("is_note", { mode: "boolean" }).notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = sqliteTable("snippet_folders", {
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"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -609,39 +567,6 @@ export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = sqliteTable(
"snippet_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
snippetId: integer("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = sqliteTable(
"ssh_folders",
{
@@ -54,7 +54,6 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
@@ -504,13 +503,6 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
);
}
export function createCurrentSnippetRepository(): SnippetRepository {
return new SnippetRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("snippet_repository_write"),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository(
createCurrentRepositoryContext(),
@@ -4,8 +4,6 @@ import {
hosts,
roles,
sharedHostSecrets,
snippetAccess,
snippets,
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -43,29 +41,6 @@ export interface RbacSharedHost {
ownerUsername: string;
}
export interface RbacSharedSnippet {
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}
export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
userId: string;
order: number;
createdAt: string;
updatedAt: string;
isNote: boolean;
}
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
hostFilter: string | null;
}
export interface RbacRoleHostAccessCredentialSource {
hostAccessId: number;
credentialId: number | null;
@@ -93,12 +68,6 @@ export type UpsertHostAccessInput = RbacAccessTarget & {
expiresAt: string | null;
};
export type UpsertSnippetAccessInput = RbacAccessTarget & {
snippetId: number;
grantedBy: string;
expiresAt: string | null;
};
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
@@ -249,76 +218,6 @@ export class RbacAccessRepository {
return rows[0] ?? null;
}
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
const rows = await this.context.drizzle
.select({
id: snippetAccess.id,
userId: snippetAccess.userId,
roleId: snippetAccess.roleId,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
grantedBy: snippetAccess.grantedBy,
grantedByUsername: sql<
string | null
>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
createdAt: snippetAccess.createdAt,
})
.from(snippetAccess)
.leftJoin(users, eq(snippetAccess.userId, users.id))
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
.where(eq(snippetAccess.snippetId, snippetId))
.orderBy(desc(snippetAccess.createdAt));
return rows.map(toAccessListItem);
}
async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{
id: number;
created: boolean;
}> {
const existing = await this.findSnippetAccess(input.snippetId, input);
if (existing) {
await this.context.drizzle
.update(snippetAccess)
.set({ expiresAt: input.expiresAt })
.where(eq(snippetAccess.id, existing.id));
await this.afterWrite();
return { id: existing.id, created: false };
}
const [created] = await insertReturning(this.context, snippetAccess, {
snippetId: input.snippetId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: "view",
expiresAt: input.expiresAt,
});
await this.afterWrite();
return { id: created.id, created: true };
}
async revokeSnippetAccess(
accessId: number,
snippetId: number,
): Promise<void> {
await this.context.drizzle
.delete(snippetAccess)
.where(
and(
eq(snippetAccess.id, accessId),
eq(snippetAccess.snippetId, snippetId),
),
);
await this.afterWrite();
}
async listSharedHosts(
userId: string,
roleIds: number[],
@@ -371,146 +270,6 @@ export class RbacAccessRepository {
.orderBy(desc(hostAccess.createdAt));
}
async listSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacSharedSnippet[]> {
const directShared = await this.context.drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(snippetAccess.userId, userId),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
);
if (roleIds.length === 0) {
return directShared;
}
const directIds = new Set(directShared.map((snippet) => snippet.id));
const roleShared = await this.context.drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
inArray(snippetAccess.roleId, roleIds),
),
);
return [
...directShared,
...roleShared.filter((snippet) => !directIds.has(snippet.id)),
];
}
async listVisibleSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacVisibleSharedSnippet[]> {
return this.context.drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
this.userOrRoleSnippetAccessFilter(userId, roleIds),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
);
}
async findAccessibleSharedSnippet(
snippetId: number,
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacAccessibleSnippet | null> {
const rows = await this.context.drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
hostFilter: snippets.hostFilter,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(snippetAccess.snippetId, snippetId),
this.userOrRoleSnippetAccessFilter(userId, roleIds),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
)
.limit(1);
return rows[0] ?? null;
}
async deleteExpiredHostAccess(
now = new Date().toISOString(),
): Promise<number> {
@@ -670,17 +429,6 @@ export class RbacAccessRepository {
);
}
private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) {
if (roleIds.length === 0) {
return eq(snippetAccess.userId, userId);
}
return or(
eq(snippetAccess.userId, userId),
inArray(snippetAccess.roleId, roleIds),
);
}
private async findHostAccess(hostId: number, target: RbacAccessTarget) {
const rows = await this.context.drizzle
.select()
@@ -698,23 +446,6 @@ export class RbacAccessRepository {
return rows[0] ?? null;
}
private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) {
const rows = await this.context.drizzle
.select()
.from(snippetAccess)
.where(
and(
eq(snippetAccess.snippetId, snippetId),
target.targetType === "user"
? eq(snippetAccess.userId, target.targetUserId)
: eq(snippetAccess.roleId, target.targetRoleId),
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
@@ -1,575 +0,0 @@
import { and, asc, eq, like, or, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type SnippetRecord = typeof snippets.$inferSelect;
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
export interface RenameSnippetFolderResult {
status: "renamed" | "missing" | "conflict";
}
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
export interface NewSnippetInput {
name: string;
content: string;
description?: string | null;
folder?: string | null;
order?: number | null;
hostFilter?: unknown;
isNote?: boolean;
}
export interface SnippetUpdateInput {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
order?: number;
hostFilter?: unknown;
isNote?: boolean;
}
export interface UpdateSnippetResult {
existing: SnippetRecord;
updated: SnippetRecord;
}
export interface SnippetBulkImportResult {
snippetsImported: number;
snippetsSkipped: number;
snippetsUpdated: number;
foldersImported: number;
foldersSkipped: number;
failed: number;
errors: string[];
}
interface BulkImportSnippetInput {
name?: unknown;
content?: unknown;
description?: string | null;
folder?: string | null;
order?: unknown;
hostFilter?: string | null;
}
interface BulkImportFolderInput {
name?: unknown;
color?: string | null;
icon?: string | null;
}
export class SnippetRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findOwnedById(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async listFolders(userId: string): Promise<SnippetFolderRecord[]> {
return this.context.drizzle
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
}
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
return (
this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
// coalesce, not asc(folder): folder is nullable, and NULLs sort first on
// SQLite and MySQL but last on Postgres. An export whose row order depends
// on the engine is not much of an export.
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order))
);
}
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
return this.listFolders(userId);
}
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
async listOwnedSnippetsInFolder(
userId: string,
folder: string,
): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
or(
eq(snippets.folder, folder),
like(snippets.folder, `${folder} / %`),
),
),
);
}
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
asc(snippets.folder),
asc(snippets.order),
sql`${snippets.updatedAt} DESC`,
);
}
async reorderSnippets(
userId: string,
updates: SnippetReorderUpdate[],
): Promise<void> {
for (const update of updates) {
const { id, order, folder } = update;
if (!id || order === undefined) {
continue;
}
const updateFields: Partial<{
order: number;
folder: string | null;
}> = {
order,
};
if (folder !== undefined) {
updateFields.folder = folder?.trim() || null;
}
await this.context.drizzle
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
}
await this.afterWrite();
}
async createSnippet(
userId: string,
input: NewSnippetInput,
): Promise<SnippetRecord> {
const folderValue = input.folder?.trim() || "";
const order =
input.order === undefined || input.order === null
? await this.nextOrderForFolder(userId, folderValue)
: input.order;
const rows = await insertReturning(this.context, snippets, {
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
isNote: input.isNote ?? false,
});
await this.afterWrite();
return rows[0];
}
async updateSnippet(
userId: string,
snippetId: number,
input: SnippetUpdateInput,
): Promise<UpdateSnippetResult | null> {
const existing = await this.findOwnedById(userId, snippetId);
if (!existing) return null;
const updateFields: Partial<{
updatedAt: ReturnType<typeof sql>;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
hostFilter: string | null;
isNote: boolean;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (input.name !== undefined) updateFields.name = input.name.trim();
if (input.content !== undefined)
updateFields.content = input.content.trim();
if (input.description !== undefined)
updateFields.description = input.description?.trim() || null;
if (input.folder !== undefined)
updateFields.folder = input.folder?.trim() || null;
if (input.order !== undefined) updateFields.order = input.order;
if (input.hostFilter !== undefined)
updateFields.hostFilter = input.hostFilter
? JSON.stringify(input.hostFilter)
: null;
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
const rows = await updateReturning(
this.context,
snippets,
updateFields,
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
);
await this.afterWrite();
return { existing, updated: rows[0] };
}
async deleteSnippet(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const existing = await this.findOwnedById(userId, snippetId);
if (!existing) return null;
await this.context.drizzle
.delete(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
await this.afterWrite();
return existing;
}
async deleteByUserId(userId: string): Promise<{
snippetsDeleted: number;
foldersDeleted: number;
}> {
const snippetResult = await this.context.drizzle
.delete(snippets)
.where(eq(snippets.userId, userId));
const result = await this.context.drizzle
.delete(snippetFolders)
.where(eq(snippetFolders.userId, userId));
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
snippetsDeleted: rowsAffected(snippetResult),
foldersDeleted: rowsAffected(result),
};
}
async bulkImport(
userId: string,
snippetsToImport: unknown[] | undefined,
foldersToImport: unknown[] | undefined,
overwrite: boolean,
): Promise<SnippetBulkImportResult> {
const results: SnippetBulkImportResult = {
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 0,
foldersImported: 0,
foldersSkipped: 0,
failed: 0,
errors: [],
};
let changed = false;
if (Array.isArray(foldersToImport)) {
for (const rawFolder of foldersToImport) {
const folder = rawFolder as BulkImportFolderInput;
if (!isNonEmptyString(folder.name)) {
results.failed++;
results.errors.push(`Folder missing name`);
continue;
}
const created = await this.createFolder(
userId,
folder.name,
folder.color,
folder.icon,
false,
);
if (!created) {
results.foldersSkipped++;
continue;
}
changed = true;
results.foldersImported++;
}
}
if (Array.isArray(snippetsToImport)) {
for (let i = 0; i < snippetsToImport.length; i++) {
const snippet = snippetsToImport[i] as BulkImportSnippetInput;
if (
!isNonEmptyString(snippet.name) ||
!isNonEmptyString(snippet.content)
) {
results.failed++;
results.errors.push(
`Snippet ${i + 1}: name and content are required`,
);
continue;
}
const folderVal = snippet.folder?.trim() || null;
const existing = await this.findByNameAndFolder(
userId,
snippet.name.trim(),
folderVal,
);
if (existing) {
if (!overwrite) {
results.snippetsSkipped++;
continue;
}
await this.context.drizzle
.update(snippets)
.set({
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number"
? snippet.order
: existing.order,
hostFilter: snippet.hostFilter || null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(eq(snippets.id, existing.id), eq(snippets.userId, userId)),
);
changed = true;
results.snippetsUpdated++;
continue;
}
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
await this.context.drizzle.insert(snippets).values({
syncId: randomUUID(),
userId,
name: snippet.name.trim(),
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number" ? snippet.order : maxOrder + 1,
hostFilter: snippet.hostFilter || null,
});
changed = true;
results.snippetsImported++;
}
}
if (changed) {
await this.afterWrite();
}
return results;
}
async createFolder(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
triggerSave = true,
): Promise<SnippetFolderRecord | null> {
const existing = await this.findFolderByName(userId, name);
if (existing) return null;
const rows = await insertReturning(this.context, snippetFolders, {
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
});
if (triggerSave) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async updateFolderMetadata(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
): Promise<SnippetFolderRecord | null> {
const existing = await this.findFolderByName(userId, name);
if (!existing) return null;
const updateFields: Partial<{
color: string | null;
icon: string | null;
updatedAt: ReturnType<typeof sql>;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
const rows = await updateReturning(
this.context,
snippetFolders,
updateFields,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ?? null;
}
async renameFolder(
userId: string,
oldName: string,
newName: string,
): Promise<RenameSnippetFolderResult> {
const existing = await this.findFolderByName(userId, oldName);
if (!existing) return { status: "missing" };
const nameExists = await this.findFolderByName(userId, newName);
if (nameExists) return { status: "conflict" };
await this.context.drizzle
.update(snippetFolders)
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
await this.context.drizzle
.update(snippets)
.set({ folder: newName })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
await this.afterWrite();
return { status: "renamed" };
}
async deleteFolder(
userId: string,
name: string,
): Promise<{ syncId: string | null } | null> {
await this.context.drizzle
.update(snippets)
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
const rows = await deleteReturning(
this.context,
snippetFolders,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ? { syncId: rows[0].syncId } : null;
}
private async findFolderByName(
userId: string,
name: string,
): Promise<SnippetFolderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
)
.limit(1);
return rows[0] ?? null;
}
private async nextOrderForFolder(
userId: string,
folder: string,
): Promise<number> {
return (await this.maxOrderForFolder(userId, folder || null)) + 1;
}
private async maxOrderForFolder(
userId: string,
folder: string | null,
): Promise<number> {
const maxOrderResult = await this.context.drizzle
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
return maxOrder;
}
private async findByNameAndFolder(
userId: string,
name: string,
folder: string | null,
): Promise<SnippetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
eq(snippets.name, name),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -28,7 +28,6 @@ import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
@@ -93,7 +92,8 @@ export async function deleteUserAndRelatedData(
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
// snippets, snippet_folders and snippet_access cascade on the user's
// refUser() foreign key, as the snippets plugin's adopted tables.
await createCurrentHostFolderRepository().deleteByUserId(userId);
-378
View File
@@ -30,7 +30,6 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSnippetRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
@@ -1941,383 +1940,6 @@ router.delete(
},
);
// SNIPPET SHARING
/**
* @openapi
* /rbac/snippet/{id}/share:
* post:
* summary: Share a snippet
* description: Shares a snippet with a user or role.
* tags:
* - RBAC
*/
router.post(
"/snippet/:id/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const {
targetType = "user",
targetUserId,
targetRoleId,
durationHours,
} = req.body;
if (!["user", "role"].includes(targetType)) {
return res
.status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
}
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
return res
.status(400)
.json({ error: "Target user ID is required when sharing with user" });
}
if (targetType === "role" && !targetRoleId) {
return res
.status(400)
.json({ error: "Target role ID is required when sharing with role" });
}
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
if (targetType === "user") {
const targetUser =
await createCurrentUserRepository().findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "Target user not found" });
}
} else {
const targetRole =
await createCurrentRoleRepository().findRoleById(targetRoleId);
if (!targetRole) {
return res.status(404).json({ error: "Target role not found" });
}
}
let expiresAt: string | null = null;
if (
durationHours &&
typeof durationHours === "number" &&
durationHours > 0
) {
const expiryDate = new Date();
expiryDate.setHours(expiryDate.getHours() + durationHours);
expiresAt = expiryDate.toISOString();
}
const accessGrant =
await createCurrentRbacAccessRepository().upsertSnippetAccess({
snippetId,
grantedBy: userId,
expiresAt,
...(targetType === "user"
? { targetType: "user" as const, targetUserId: targetUserId! }
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
});
if (!accessGrant.created) {
return res.json({
success: true,
message: "Snippet access updated",
expiresAt,
});
}
databaseLogger.success("Snippet shared successfully", {
operation: "rbac_snippet_share",
userId,
});
res.json({
success: true,
message: `Snippet shared successfully with ${targetType}`,
expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to share snippet", error, {
operation: "share_snippet",
userId,
});
res.status(500).json({ error: "Failed to share snippet" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access/{accessId}:
* delete:
* summary: Revoke snippet access
* description: Revokes a user's or role's access to a snippet.
* tags:
* - RBAC
*/
/**
* @openapi
* /rbac/snippet-folder/share:
* post:
* summary: Share every snippet in a folder
* description: Grants view access to each owned snippet in the folder (and its subfolders) to the given users or roles.
* tags:
* - RBAC
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* folder:
* type: string
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* id:
* oneOf:
* - type: string
* - type: integer
* durationHours:
* type: number
* responses:
* 200:
* description: Folder shared.
* 404:
* description: A target was not found.
*/
router.post(
"/snippet-folder/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
const { folder, durationHours } = req.body ?? {};
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "Folder name is required" });
}
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
error:
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
});
}
try {
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
const found =
target.type === "user"
? await userRepository.findById(target.id as string)
: await roleRepository.findRoleById(target.id as number);
if (!found) {
return res.status(404).json({
error: `Target ${target.type} not found`,
targetId: target.id,
});
}
}
const snippetsInFolder =
await createCurrentSnippetRepository().listOwnedSnippetsInFolder(
userId,
folder,
);
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
for (const snippet of snippetsInFolder) {
for (const target of targets) {
if (target.type === "user" && target.id === userId) continue;
await rbacAccessRepository.upsertSnippetAccess({
snippetId: snippet.id,
grantedBy: userId,
expiresAt,
...(target.type === "user"
? {
targetType: "user" as const,
targetUserId: target.id as string,
}
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
}
}
databaseLogger.success("Snippet folder shared successfully", {
operation: "rbac_snippet_folder_share",
userId,
folder,
snippetsShared: snippetsInFolder.length,
targets: targets.length,
});
res.json({
success: true,
expiresAt,
snippetsShared: snippetsInFolder.length,
});
} catch (error) {
databaseLogger.error("Failed to share snippet folder", error, {
operation: "share_snippet_folder",
folder,
userId,
});
res.status(500).json({ error: "Failed to share snippet folder" });
}
},
);
router.delete(
"/snippet/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
? req.params.accessId[0]
: req.params.accessId;
const snippetId = parseInt(id, 10);
const accessId = parseInt(accessIdParam, 10);
const userId = req.userId!;
if (isNaN(snippetId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
await createCurrentRbacAccessRepository().revokeSnippetAccess(
accessId,
snippetId,
);
res.json({ success: true, message: "Snippet access revoked" });
} catch (error) {
databaseLogger.error("Failed to revoke snippet access", error, {
operation: "revoke_snippet_access",
userId,
});
res.status(500).json({ error: "Failed to revoke access" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access:
* get:
* summary: Get snippet access list
* description: Retrieves the list of users and roles with access to a snippet.
* tags:
* - RBAC
*/
router.get(
"/snippet/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
const accessList =
await createCurrentRbacAccessRepository().listSnippetAccess(snippetId);
res.json({ accessList });
} catch (error) {
databaseLogger.error("Failed to get snippet access list", error, {
operation: "get_snippet_access_list",
userId,
});
res.status(500).json({ error: "Failed to get access list" });
}
},
);
/**
* @openapi
* /rbac/shared-snippets:
* get:
* summary: Get shared snippets
* description: Retrieves snippets shared with the current user.
* tags:
* - RBAC
*/
router.get(
"/shared-snippets",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedSnippets =
await createCurrentRbacAccessRepository().listSharedSnippets(
userId,
roleIds,
);
res.json({ sharedSnippets });
} catch (error) {
databaseLogger.error("Failed to get shared snippets", error, {
operation: "get_shared_snippets",
userId,
});
res.status(500).json({ error: "Failed to get shared snippets" });
}
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
@@ -1,82 +0,0 @@
export interface SnippetExecutionResult {
success: boolean;
output: string;
error?: string;
}
export interface SnippetHostVars {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function replaceVar(content: string, name: string, value?: string): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
/**
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
* backend are separate builds, so this is kept as a small standalone copy
* rather than a shared package for one pure function.
*/
export function resolveSnippetCommand(
content: string,
host: SnippetHostVars | null,
inputValues: Record<string, string> = {},
): string {
let resolved = content;
resolved = replaceVar(resolved, "HOST", host?.ip);
resolved = replaceVar(resolved, "USER", host?.username);
resolved = replaceVar(
resolved,
"PORT",
host?.port !== undefined ? String(host.port) : undefined,
);
resolved = replaceVar(resolved, "NAME", host?.name);
resolved = resolved.replace(
INPUT_PATTERN,
(
fullMatch,
braceDigits: string | undefined,
_label,
plainDigits: string | undefined,
) => {
const key = `INPUT_${braceDigits ?? plainDigits}`;
return key in inputValues ? inputValues[key] : fullMatch;
},
);
return resolved;
}
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
): number | undefined {
if (value === undefined || value.trim() === "") return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
return seconds * 1000;
}
export function createSnippetExecutionResult(
exitCode: number | null,
output: string,
errorOutput: string,
): SnippetExecutionResult {
const success = exitCode === 0 || (exitCode === null && !errorOutput);
return {
success,
output,
...(errorOutput ? { error: errorOutput } : {}),
};
}
@@ -1,33 +0,0 @@
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
type SnippetReorderRequestBody = {
snippets?: unknown;
updates?: unknown;
};
export function extractSnippetReorderUpdates(
body: unknown,
): SnippetReorderUpdate[] | null {
if (!body || typeof body !== "object") {
return null;
}
const payload = body as SnippetReorderRequestBody;
// Keep accepting the legacy `updates` key so older clients do not break
// while the web and desktop helpers converge on `snippets`.
const snippetsUpdates = Array.isArray(payload.snippets)
? payload.snippets
: Array.isArray(payload.updates)
? payload.updates
: null;
if (!snippetsUpdates) {
return null;
}
return snippetsUpdates as SnippetReorderUpdate[];
}
File diff suppressed because it is too large. Load diff
@@ -15,8 +15,6 @@ import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
@@ -95,12 +93,6 @@ export function registerCoreSyncEntities(): void {
references: [CREDENTIAL_REFERENCE],
});
registerEntity(CORE_OWNER, {
type: "snippetFolders",
table: snippetFolders,
order: 40,
});
registerEntity(CORE_OWNER, {
type: "hosts",
table: hosts,
@@ -112,12 +104,6 @@ export function registerCoreSyncEntities(): void {
encryptedFields: ["ssh_data"],
});
registerEntity(CORE_OWNER, {
type: "snippets",
table: snippets,
order: 60,
});
registerEntity(CORE_OWNER, {
type: "dashboardServiceLinks",
table: dashboardServiceLinks,
@@ -12,10 +12,10 @@ import {
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
interface UserPasswordResetRoutesDeps {
authManager: AuthManager;
@@ -73,9 +73,11 @@ export async function resetUserPassword(
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (snippets and anything else keyed by
// refUser()) wipes it on this topic, since the user row itself survives.
pluginEvents.emit(TOPICS.userDataWiped, { userId });
const { UserKeyManager } = await import("../../utils/user-keys.js");
await UserKeyManager.getInstance().rotateUserDEK(userId);
@@ -60,7 +60,7 @@ export function validateDefaultsJson(value: string): boolean {
* /user-preferences:
* get:
* summary: Get preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are legacy fields, kept here read-only for backward compatibility. The authoritative copy is GET /host-sidebar/preferences.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences. foldersCollapsed is likewise legacy and read-only; its authoritative copy is the snippets plugin's own user settings.
* tags:
* - User Preferences
* responses:
@@ -160,7 +160,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /user-preferences:
* put:
* summary: Update preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. foldersCollapsed is no longer accepted either, now that it is a snippets plugin user setting.
* tags:
* - User Preferences
* requestBody:
@@ -259,11 +259,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
rdpDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme,
// foldersCollapsed are no longer writable here -- they moved to
// /host-sidebar/preferences as of the sidebar redesign. The columns stay
// in the table (read once as a migration seed by that route) but this
// endpoint silently ignores them if a stale client still sends them.
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
// sidebar redesign. The columns stay in the table (read once as a
// migration seed by that route) but this endpoint silently ignores them if
// a stale client still sends them. foldersCollapsed similarly moved out,
// to the snippets plugin's own user settings.
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),
+15 -2
View File
@@ -135,7 +135,11 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
open.add(dispose);
connection.client.once("close", () => open.delete(dispose));
return { ...connection, dispose };
return {
...connection,
host: connection.host as unknown as PluginSshHost,
dispose,
};
} catch (error) {
await audit("ssh_connect", describeHost(host), {
success: false,
@@ -156,6 +160,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
return {
client: connection.client as never,
jumpClient: connection.jumpClient as never,
host: connection.host,
dispose: connection.dispose,
};
},
@@ -201,7 +206,15 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
open.add(dispose);
client.once("close", () => open.delete(dispose));
return { client: client as never, jumpClient: null, dispose };
// A jump chain ends at a forwarding client, not a single resolved SSH
// host: report the caller's own host if it gave one, else the last hop.
const host: PluginSshHost = chainOptions?.forHost ?? {
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
ip: "",
port: 22,
username: "",
};
return { client: client as never, jumpClient: null, host, dispose };
},
poolKey,
+16 -1
View File
@@ -288,7 +288,12 @@ export function createPluginContext(
// write through them. The capability and the audit line are the record
// that it did. See the header of this file.
const schema = await import("../database/db/schema.js");
return { users: schema.users, hosts: schema.hosts };
return {
users: schema.users,
hosts: schema.hosts,
roles: schema.roles,
userRoles: schema.userRoles,
};
},
{ action: "db_refs" },
);
@@ -353,6 +358,16 @@ export function createPluginContext(
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
recordTombstone: async (userId, entityType, syncId) => {
if (!syncId) return;
const { createCurrentSyncTombstoneRepository } =
await import("../database/repositories/factory.js");
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
},
},
kv: {
+7
View File
@@ -40,6 +40,13 @@ export const TOPICS = {
hostDeleted: "host.deleted",
/** Someone logged in over SSH to a host. */
hostLogin: "host.login",
/**
* A user's encrypted data was wiped because their DEK could not be
* recovered on password reset. The user row itself survives, unlike
* user.deleted, so a plugin with a refUser() cascade still needs this:
* the row is not gone, only unrecoverable, and should go with it.
*/
userDataWiped: "user.data_wiped",
} as const;
export type EventListener = (payload: unknown) => void;
@@ -38,10 +38,6 @@ describe("RbacAccessRepository", () => {
INSERT INTO ssh_data (
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password');
INSERT INTO snippets (id, user_id, name, content)
VALUES
(99, 'owner-1', 'deploy', 'echo deploy'),
(100, 'owner-1', 'rollback', 'echo rollback');
INSERT INTO host_access (
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
)
@@ -49,12 +45,6 @@ describe("RbacAccessRepository", () => {
(1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
INSERT INTO snippet_access (
id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
)
VALUES
(3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
(4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z');
INSERT INTO shared_host_secrets (
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
)
@@ -95,29 +85,6 @@ describe("RbacAccessRepository", () => {
]);
});
it("lists snippet access with user and role target metadata", async () => {
const repo = await createRepository();
const accessList = await repo.listSnippetAccess(99);
expect(accessList.map((access) => access.targetType)).toEqual([
"role",
"user",
]);
expect(accessList[0]).toMatchObject({
id: 4,
roleId: 7,
roleName: "ops",
grantedByUsername: "admin",
});
expect(accessList[1]).toMatchObject({
id: 3,
userId: "user-1",
username: "alice",
grantedByUsername: "admin",
});
});
it("lists shared hosts for direct and role access", async () => {
const repo = await createRepository();
@@ -253,67 +220,6 @@ describe("RbacAccessRepository", () => {
expect(writeCount).toBe(1);
});
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
const repo = await createRepository();
const sharedSnippets = await repo.listSharedSnippets(
"user-1",
[7],
activeAccessTime,
);
expect(sharedSnippets).toHaveLength(1);
expect(sharedSnippets[0]).toMatchObject({
id: 99,
name: "deploy",
ownerUsername: "owner",
permissionLevel: "view",
});
});
it("lists visible shared snippets for the main snippets route", async () => {
const repo = await createRepository();
const sharedSnippets = await repo.listVisibleSharedSnippets(
"user-1",
[7],
activeAccessTime,
);
expect(sharedSnippets.map((snippet) => snippet.id)).toEqual([99, 99]);
expect(sharedSnippets[0]).toMatchObject({
userId: "owner-1",
name: "deploy",
content: "echo deploy",
ownerUsername: "owner",
});
});
it("finds an accessible shared snippet for direct or role access", async () => {
const repo = await createRepository();
await expect(
repo.findAccessibleSharedSnippet(99, "user-2", [7], activeAccessTime),
).resolves.toMatchObject({
id: 99,
userId: "owner-1",
name: "deploy",
content: "echo deploy",
ownerUsername: "owner",
permissionLevel: "view",
hostFilter: null,
});
await expect(
repo.findAccessibleSharedSnippet(
99,
"user-2",
[7],
"2026-06-28T00:00:00.000Z",
),
).resolves.toBeNull();
});
it("upserts host access and updates overrides", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
@@ -432,39 +338,4 @@ describe("RbacAccessRepository", () => {
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0);
expect(writeCount).toBe(1);
});
it("upserts and revokes snippet access", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertSnippetAccess({
snippetId: 99,
targetType: "user",
targetUserId: "user-1",
grantedBy: "admin",
expiresAt: "2026-06-28T00:00:00.000Z",
});
expect(updated).toEqual({ id: 3, created: false });
expect(
(await repo.listSnippetAccess(99)).find((row) => row.id === 3),
).toMatchObject({ expiresAt: "2026-06-28T00:00:00.000Z" });
const created = await repo.upsertSnippetAccess({
snippetId: 100,
targetType: "role",
targetRoleId: 7,
grantedBy: "admin",
expiresAt: null,
});
expect(created.created).toBe(true);
await repo.revokeSnippetAccess(3, 99);
expect((await repo.listSnippetAccess(99)).map((row) => row.id)).toEqual([
4,
]);
expect(writeCount).toBe(3);
});
});
@@ -1,356 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SnippetRepository } from "../../../database/repositories/snippet-repository.js";
describe("SnippetRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite?: () => void): Promise<{
repository: SnippetRepository;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO snippets (
id, user_id, name, content, description, folder, "order", host_filter
)
VALUES
(1, 'user-1', 'root', 'uptime', NULL, NULL, 2, NULL),
(2, 'user-1', 'deploy', 'make deploy', 'Deploy app', 'ops', 1, 'linux'),
(3, 'user-2', 'other', 'whoami', NULL, NULL, 1, NULL);
INSERT INTO snippet_folders (id, user_id, name, color, icon)
VALUES
(1, 'user-1', 'ops', '#123456', 'terminal'),
(2, 'user-1', 'db', NULL, NULL),
(3, 'user-2', 'other', NULL, NULL);
`);
return {
repository: new SnippetRepository(context, onWrite),
};
}
it("finds owned snippets only", async () => {
const { repository } = await createRepository();
await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({
id: 1,
userId: "user-1",
name: "root",
});
await expect(repository.findOwnedById("user-1", 3)).resolves.toBeNull();
await expect(repository.findOwnedById("user-1", 999)).resolves.toBeNull();
});
it("lists folders by name for a user", async () => {
const { repository } = await createRepository();
const rows = await repository.listFolders("user-1");
expect(rows.map((row) => row.name)).toEqual(["db", "ops"]);
});
it("lists export data for a user", async () => {
const { repository } = await createRepository();
const snippets = await repository.listSnippetsForExport("user-1");
const folders = await repository.listFoldersForExport("user-1");
expect(snippets.map((row) => row.name)).toEqual(["root", "deploy"]);
expect(folders.map((row) => row.name)).toEqual(["db", "ops"]);
});
it("lists owned snippets for route merging", async () => {
const { repository } = await createRepository();
const rows = await repository.listOwnedSnippets("user-1");
expect(rows.map((row) => row.name)).toEqual(["root", "deploy"]);
});
it("reorders snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await repository.reorderSnippets("user-1", [
{ id: 1, order: 9, folder: " ops " },
{ id: 999, order: 1 },
]);
await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({
order: 9,
folder: "ops",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("creates snippets with the next folder order", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const created = await repository.createSnippet("user-1", {
name: " new ",
content: " echo ok ",
description: " desc ",
folder: "ops",
hostFilter: { os: "linux" },
});
expect(created).toMatchObject({
userId: "user-1",
name: "new",
content: "echo ok",
description: "desc",
folder: "ops",
order: 2,
hostFilter: JSON.stringify({ os: "linux" }),
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("updates snippets and returns the original row for audit names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.updateSnippet("user-1", 2, {
name: " deploy new ",
content: " make deploy2 ",
description: null,
folder: null,
order: 7,
hostFilter: null,
});
const missing = await repository.updateSnippet("user-1", 3, {
name: "nope",
});
expect(result?.existing).toMatchObject({ name: "deploy" });
expect(result?.updated).toMatchObject({
name: "deploy new",
content: "make deploy2",
description: null,
folder: null,
order: 7,
hostFilter: null,
});
expect(missing).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes snippets and returns the deleted row for audit names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const deleted = await repository.deleteSnippet("user-1", 2);
const missing = await repository.deleteSnippet("user-1", 3);
expect(deleted).toMatchObject({ id: 2, name: "deploy" });
expect(missing).toBeNull();
await expect(repository.findOwnedById("user-1", 2)).resolves.toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes all snippets and folders for a user", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await expect(repository.deleteByUserId("user-1")).resolves.toEqual({
snippetsDeleted: 2,
foldersDeleted: 2,
});
expect(
await adapter!.query(sql`SELECT id FROM snippets ORDER BY id`),
).toEqual([{ id: 3 }]);
expect(
await adapter!.query(sql`SELECT id FROM snippet_folders ORDER BY id`),
).toEqual([{ id: 3 }]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("bulk imports folders and snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.bulkImport(
"user-1",
[
{
name: " new snippet ",
content: " echo hi ",
description: " desc ",
folder: " new folder ",
hostFilter: "linux",
},
{ name: "", content: "bad" },
{ name: "deploy", content: "skip", folder: "ops" },
],
[
{ name: " new folder ", color: " #fff ", icon: " star " },
{ name: "ops" },
{ name: "" },
],
false,
);
expect(result).toEqual({
snippetsImported: 1,
snippetsSkipped: 1,
snippetsUpdated: 0,
foldersImported: 1,
foldersSkipped: 1,
failed: 2,
errors: [
"Folder missing name",
"Snippet 2: name and content are required",
],
});
await expect(repository.findOwnedById("user-1", 4)).resolves.toMatchObject({
name: "new snippet",
content: "echo hi",
description: "desc",
folder: "new folder",
order: 0,
hostFilter: "linux",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("bulk import overwrites existing snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.bulkImport(
"user-1",
[
{
name: "deploy",
content: "make deploy v2",
folder: "ops",
order: 5,
hostFilter: "prod",
},
],
undefined,
true,
);
expect(result).toMatchObject({
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 1,
failed: 0,
});
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
content: "make deploy v2",
order: 5,
hostFilter: "prod",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("creates folders and rejects duplicate names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const created = await repository.createFolder(
"user-1",
" new ",
" #fff ",
" star ",
);
const duplicate = await repository.createFolder(
"user-1",
"ops",
null,
null,
);
expect(created).toMatchObject({
userId: "user-1",
name: "new",
color: "#fff",
icon: "star",
});
expect(duplicate).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("updates folder metadata", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const updated = await repository.updateFolderMetadata(
"user-1",
"ops",
" #abc ",
undefined,
);
const missing = await repository.updateFolderMetadata(
"user-1",
"missing",
null,
null,
);
expect(updated).toMatchObject({
name: "ops",
color: "#abc",
icon: "terminal",
});
expect(missing).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("renames folders and attached snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await expect(
repository.renameFolder("user-1", "missing", "new"),
).resolves.toEqual({ status: "missing" });
await expect(
repository.renameFolder("user-1", "ops", "db"),
).resolves.toEqual({
status: "conflict",
});
await expect(
repository.renameFolder("user-1", "ops", "deploys"),
).resolves.toEqual({ status: "renamed" });
await expect(repository.listFolders("user-1")).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ name: "deploys" })]),
);
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
folder: "deploys",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes folders and moves snippets to the root", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await repository.deleteFolder("user-1", "ops");
expect(
(await repository.listFolders("user-1")).map((row) => row.name),
).toEqual(["db"]);
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
folder: null,
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createSnippetExecutionResult,
getSnippetExecutionTimeoutMs,
resolveSnippetCommand,
} from "../../../database/routes/snippets-execution.js";
describe("snippet execution", () => {
it("treats stderr as diagnostic output when the command succeeds", () => {
expect(createSnippetExecutionResult(0, "done\n", "warning\n")).toEqual({
success: true,
output: "done\n",
error: "warning\n",
});
});
it("uses the exit code to report command failure", () => {
expect(createSnippetExecutionResult(1, "", "failed\n")).toEqual({
success: false,
output: "",
error: "failed\n",
});
});
it("preserves the previous fallback when no exit code is available", () => {
expect(createSnippetExecutionResult(null, "done\n", "")).toEqual({
success: true,
output: "done\n",
});
expect(createSnippetExecutionResult(null, "", "failed\n").success).toBe(
false,
);
});
it("disables the command timeout by default", () => {
expect(getSnippetExecutionTimeoutMs(undefined)).toBeUndefined();
});
it("converts a configured timeout from seconds to milliseconds", () => {
expect(getSnippetExecutionTimeoutMs("45")).toBe(45_000);
});
it.each(["", "0", "-1", "invalid"])(
"ignores invalid timeout value %j",
(value) => {
expect(getSnippetExecutionTimeoutMs(value)).toBeUndefined();
},
);
});
describe("resolveSnippetCommand", () => {
const host = { ip: "10.0.0.5", username: "root", port: 22, name: "web-01" };
it("substitutes host variables per target host", () => {
expect(
resolveSnippetCommand("ssh $USER@$HOST -p $PORT # $NAME", host),
).toBe("ssh root@10.0.0.5 -p 22 # web-01");
});
it("supports brace syntax for host variables", () => {
expect(resolveSnippetCommand("ping ${HOST}", host)).toBe("ping 10.0.0.5");
});
it("substitutes input placeholders from inputValues", () => {
expect(
resolveSnippetCommand("nc -zv $HOST ${INPUT_1:Port}", host, {
INPUT_1: "8080",
}),
).toBe("nc -zv 10.0.0.5 8080");
});
it("leaves host variables literal when no host context is given", () => {
expect(resolveSnippetCommand("ping $HOST", null)).toBe("ping $HOST");
});
it("leaves input placeholders literal when no value was supplied", () => {
expect(resolveSnippetCommand("echo $INPUT_1", null)).toBe("echo $INPUT_1");
});
});
@@ -1,20 +0,0 @@
import fs from "fs";
import path from "path";
import { describe, expect, it } from "vitest";
describe("snippet execution host access", () => {
it("uses the shared-host-aware resolver", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../../database/routes/snippets.ts"),
"utf8",
);
const executeRoute = source.slice(
source.indexOf('router.post(\n "/execute"'),
);
expect(executeRoute).toContain(
"const host = await resolveHostById(parseInt(hostId), userId)",
);
expect(executeRoute).not.toContain("host.userId !== userId");
});
});
@@ -33,8 +33,6 @@ describe("isValidEntityType", () => {
"hosts",
"sshCredentials",
"sshFolders",
"snippets",
"snippetFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
@@ -88,7 +86,7 @@ describe("stripWritePayload", () => {
it("does not mutate the original payload object", () => {
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
stripWritePayload("snippets", payload);
stripWritePayload("sshFolders", payload);
expect(payload).toEqual({
id: 1,
userId: "user-1",
@@ -6,6 +6,7 @@ const calls = vi.hoisted(() => ({
deletedFor: [] as string[],
rotatedFor: [] as string[],
legacyWrapsDeletedFor: [] as string[],
dataWipedEvents: [] as Array<{ userId: string }>,
}));
function deletingRepo(label: string) {
@@ -29,11 +30,19 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentSnippetRepository: deletingRepo("snippets"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
vi.mock("../../../plugins/events.js", () => ({
pluginEvents: {
emit: (_topic: string, payload: { userId: string }) => {
calls.dataWipedEvents.push(payload);
},
},
TOPICS: { userDataWiped: "user.data_wiped" },
}));
vi.mock("../../../utils/user-keys.js", () => ({
UserKeyManager: {
getInstance: () => ({
@@ -65,6 +74,7 @@ beforeEach(() => {
calls.deletedFor = [];
calls.rotatedFor = [];
calls.legacyWrapsDeletedFor = [];
calls.dataWipedEvents = [];
});
describe("resetUserPassword", () => {
@@ -110,10 +120,10 @@ describe("resetUserPassword", () => {
"bookmarks:user-1",
"activity:user-1",
"alerts:user-1",
"snippets:user-1",
"hosts:user-1",
"credentials:user-1",
]);
expect(calls.dataWipedEvents).toEqual([{ userId: "user-1" }]);
expect(calls.rotatedFor).toEqual(["user-1"]);
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
expect(
+8 -1
View File
@@ -60,6 +60,8 @@ vi.mock("../../utils/database-save-trigger.js", () => ({
vi.mock("../../database/db/schema.js", () => ({
users: { marker: "users" },
hosts: { marker: "hosts" },
roles: { marker: "roles" },
userRoles: { marker: "userRoles" },
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
@@ -195,7 +197,12 @@ describe("ctx.db capability", () => {
const refs = (await ctx.db.refs()) as Record<string, unknown>;
expect(Object.keys(refs).sort()).toEqual(["hosts", "users"]);
expect(Object.keys(refs).sort()).toEqual([
"hosts",
"roles",
"userRoles",
"users",
]);
});
});
@@ -384,8 +384,18 @@ describe("shipped manifests keep their permission ids", () => {
expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]);
});
it("snippets", () => {
expect(idsFor("snippets")).toEqual([
"snippets.view",
"snippets.create",
"snippets.edit",
"snippets.delete",
"snippets.share",
]);
});
it("a role holding the old ids still validates once registered", () => {
for (const pluginId of ["ai", "automations", "tailscale"]) {
for (const pluginId of ["ai", "automations", "tailscale", "snippets"]) {
const ids = idsFor(pluginId);
registerPluginPermissions({
group: pluginId,
@@ -402,6 +412,8 @@ describe("shipped manifests keep their permission ids", () => {
"automations.run",
"automations.view",
"tailscale.devices.view",
"snippets.view",
"snippets.share",
];
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
@@ -96,19 +96,26 @@ describe("core sync entities", () => {
registerCoreSyncEntities();
});
// The frozen array also lists networkTopology and snippets/snippetFolders,
// now registered by the network-topology and snippets plugins rather than
// core.
const PLUGIN_OWNED_TYPES = new Set([
"networkTopology",
"snippets",
"snippetFolders",
]);
it("registers every core-owned entity the Electron client knows about", () => {
// The frozen array also lists networkTopology, which is now registered
// by the network-topology plugin rather than core.
const coreTypes = new Set(listEntityTypes());
for (const type of SYNCED_ENTITY_TYPES) {
if (type === "networkTopology") continue;
if (PLUGIN_OWNED_TYPES.has(type)) continue;
expect(coreTypes.has(type), type).toBe(true);
}
});
it("keeps the dependency order the frozen Electron array encodes", () => {
expect(listEntityTypes()).toEqual(
[...SYNCED_ENTITY_TYPES].filter((type) => type !== "networkTopology"),
[...SYNCED_ENTITY_TYPES].filter((type) => !PLUGIN_OWNED_TYPES.has(type)),
);
});
+1 -1
View File
@@ -75,7 +75,7 @@ const ADMIN_TARGET_USER_HEADER = "x-admin-target-user";
const IMPERSONATION_PATH_ALLOWLIST = [
/^\/host\/db\//,
/^\/credentials(\/|$)/,
/^\/snippets(\/|$)/,
/^\/plugin-api\/snippets(\/|$)/,
];
class AuthManager {
+1 -12
View File
@@ -34,17 +34,6 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"hosts.share",
],
},
{
group: "snippets",
labelKey: "admin.rolePermissions.groups.snippets",
permissions: [
"snippets.view",
"snippets.create",
"snippets.edit",
"snippets.delete",
"snippets.share",
],
},
{
group: "credentials",
labelKey: "admin.rolePermissions.groups.credentials",
@@ -183,6 +172,6 @@ export const SYSTEM_ROLE_DEFAULTS = {
},
user: {
description: "Regular user",
permissions: ["hosts.*", "snippets.*", "credentials.*"],
permissions: ["hosts.*", "credentials.*"],
},
} as const;