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