mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35: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;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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,
|
"when": 1790166644943,
|
||||||
"tag": "0032_lethal_marrow",
|
"tag": "0032_lethal_marrow",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1790166644445,
|
||||||
"tag": "0033_bent_trish_tilby",
|
"tag": "0033_bent_trish_tilby",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1790166643921,
|
||||||
"tag": "0029_numerous_aaron_stack",
|
"tag": "0029_numerous_aaron_stack",
|
||||||
"breakpoints": true
|
"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",
|
"resolved": "plugins/remote-desktop",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@termix-plugin/snippets": {
|
||||||
|
"resolved": "plugins/snippets",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@termix-plugin/ssh-terminal": {
|
"node_modules/@termix-plugin/ssh-terminal": {
|
||||||
"resolved": "plugins/ssh-terminal",
|
"resolved": "plugins/ssh-terminal",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -18573,6 +18577,13 @@
|
|||||||
"@termix/plugin-sdk": "*"
|
"@termix/plugin-sdk": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugins/snippets": {
|
||||||
|
"name": "@termix-plugin/snippets",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@termix/plugin-sdk": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"plugins/ssh-terminal": {
|
"plugins/ssh-terminal": {
|
||||||
"name": "@termix-plugin/ssh-terminal",
|
"name": "@termix-plugin/ssh-terminal",
|
||||||
"version": "1.0.0",
|
"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
|
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.
|
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
|
### 6. HTTP and WebSockets
|
||||||
|
|
||||||
Plugin routes live under `/plugin-api/<id>/` and sockets under
|
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:
|
and `session.remoteDisplay`. Cross-plugin frontend calls go through actions:
|
||||||
`automations.list`, `fleets.list`, `session.remoteDisplay.token`.
|
`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
|
#### View ownership
|
||||||
|
|
||||||
Who owns a tab, panel or card comes from manifests, so it is known even for a
|
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.
|
`renderWithApp(plugin, options)` for frontend tests.
|
||||||
|
|
||||||
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
|
`createTestDb(pluginDir, { before })` opens an in-memory SQLite with stub
|
||||||
`users` and `ssh_data` tables and foreign keys on, runs `before` (seed a legacy
|
`users`, `ssh_data`, `roles` and `user_roles` tables and foreign keys on, runs
|
||||||
table there to test an adoption), then applies the plugin's own
|
`before` (seed a legacy table there to test an adoption), then applies the
|
||||||
`migrations/sqlite` with the runner's splitter. Pass its `database` to
|
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
|
`createMockCtx({ db })`: `define` builds the real table, `client` is Drizzle, and
|
||||||
`persisted` counts `persist()` calls. The mock also takes `router` (for example
|
`persisted` counts `persist()` calls. The mock also takes `router` (for example
|
||||||
`() => express.Router()`, so routes are served for real), `permissions`
|
`() => 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
|
`ctx.ssh` was pulled forward from B so plugin transports go through core's
|
||||||
connect pipeline instead of importing ssh2 helpers from core:
|
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.
|
- `withConnection(host, { pool, purpose }, fn)` borrows a pooled connection.
|
||||||
- `jumpChain(jumpHosts, { forHost? })` returns a client at the end of a jump
|
- `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`,
|
- `prepare(host, { client, purpose })`, `openTransport`,
|
||||||
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
|
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
|
||||||
transport with its own prompt flow (docker's console, host metrics).
|
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
|
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
|
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`
|
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
|
hands it `users`, `ssh_data`, `roles` and `user_roles` outright (the last two
|
||||||
line, lint and review are the contract. The engine is not.
|
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,
|
So the honest boundary is: capabilities stop accidental and casual overreach,
|
||||||
and make every privileged call auditable. What stops malicious code is
|
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
|
## Legacy core imports: the debt D1 removes
|
||||||
|
|
||||||
The bundled plugins predate the SDK, apart from workspaces, which A9 rebuilt
|
The bundled plugins predate the SDK, apart from workspaces (A9) and snippets
|
||||||
as the reference conversion and which imports nothing from core. They still reach core by relative
|
(B2), which import nothing from core. The others still reach core by relative
|
||||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||||
and rewrites to the compiled output path (`../../../backend/backend/...`, or
|
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 | - |
|
| Core importing a plugin backend | **Error** | 0 | - |
|
||||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||||
| The shell importing plugin code | **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 core by relative path | Warning | 67 files | D1 |
|
||||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
| 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.
|
* to query through `db`. Both require db:own.
|
||||||
*
|
*
|
||||||
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
|
* 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
|
* users, ssh_data, roles and user_roles read-only so a plugin can join against
|
||||||
* able to write them. In-process code could reach around all of this - see
|
* them without being able to write them. In-process code could reach around
|
||||||
* "What this protects, and what it does not" in ARCHITECTURE.md. The
|
* all of this - see "What this protects, and what it does not" in
|
||||||
* capability, the prefix, lint and review are the contract, not a sandbox.
|
* ARCHITECTURE.md. The capability, the prefix, lint and review are the
|
||||||
|
* contract, not a sandbox.
|
||||||
*/
|
*/
|
||||||
export interface PluginDatabase {
|
export interface PluginDatabase {
|
||||||
/** Registers a definition and returns its queryable table object. */
|
/** 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. */
|
/** Adds an entity to remote sync between a desktop backend and a server. */
|
||||||
export interface PluginSync {
|
export interface PluginSync {
|
||||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
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 {
|
export interface PluginRegistry {
|
||||||
@@ -481,6 +493,8 @@ export interface PluginSshConnection<Client = unknown> {
|
|||||||
/** An ssh2 Client, already "ready". */
|
/** An ssh2 Client, already "ready". */
|
||||||
client: Client;
|
client: Client;
|
||||||
jumpClient: Client | null;
|
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. */
|
/** Ends the connection and its jump chain. Also runs on deactivate. */
|
||||||
dispose: () => void;
|
dispose: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,8 +92,11 @@ export function buildTable(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A minimal stand-in for a core table, for tests that join against users or
|
* A minimal stand-in for a core table, for tests that join against users,
|
||||||
* ssh_data without core's schema. Only what createTestDb's stubs declare.
|
* 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(
|
export function buildRefTable(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -101,8 +104,11 @@ export function buildRefTable(
|
|||||||
): unknown {
|
): unknown {
|
||||||
const built: Record<string, unknown> = {};
|
const built: Record<string, unknown> = {};
|
||||||
for (const [property, type] of Object.entries(columns)) {
|
for (const [property, type] of Object.entries(columns)) {
|
||||||
|
const columnSqlName = columnName(property);
|
||||||
built[property] =
|
built[property] =
|
||||||
type === "integer" ? sqliteInteger(property) : sqliteText(property);
|
type === "integer"
|
||||||
|
? sqliteInteger(columnSqlName)
|
||||||
|
: sqliteText(columnSqlName);
|
||||||
}
|
}
|
||||||
return sqliteTable(name, built as never);
|
return sqliteTable(name, built as never);
|
||||||
}
|
}
|
||||||
@@ -70,6 +70,8 @@ export interface FakePluginContext {
|
|||||||
tables: PluginTableDefinition[];
|
tables: PluginTableDefinition[];
|
||||||
/** Sync entities registered through ctx.sync.registerEntity, in order. */
|
/** Sync entities registered through ctx.sync.registerEntity, in order. */
|
||||||
syncEntities: SyncEntityRegistration[];
|
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. */
|
/** WebSocket routes registered through ctx.ws, in order. */
|
||||||
wsRoutes: Array<{ path: string; raw: boolean }>;
|
wsRoutes: Array<{ path: string; raw: boolean }>;
|
||||||
/** Router options passed to ctx.http.router, in order. */
|
/** Router options passed to ctx.http.router, in order. */
|
||||||
@@ -137,6 +139,11 @@ export function createFakeContext(
|
|||||||
const kv = new Map<string, unknown>();
|
const kv = new Map<string, unknown>();
|
||||||
const tables: PluginTableDefinition[] = [];
|
const tables: PluginTableDefinition[] = [];
|
||||||
const syncEntities: SyncEntityRegistration[] = [];
|
const syncEntities: SyncEntityRegistration[] = [];
|
||||||
|
const tombstones: Array<{
|
||||||
|
userId: string;
|
||||||
|
entityType: string;
|
||||||
|
syncId: string;
|
||||||
|
}> = [];
|
||||||
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
||||||
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
||||||
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||||
@@ -263,6 +270,10 @@ export function createFakeContext(
|
|||||||
registerEntity: (entity) => {
|
registerEntity: (entity) => {
|
||||||
syncEntities.push(entity);
|
syncEntities.push(entity);
|
||||||
},
|
},
|
||||||
|
recordTombstone: async (userId, entityType, syncId) => {
|
||||||
|
if (!syncId) return;
|
||||||
|
tombstones.push({ userId, entityType, syncId });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
registry: {
|
registry: {
|
||||||
@@ -380,6 +391,9 @@ export function createFakeContext(
|
|||||||
return {
|
return {
|
||||||
client: sshClient as never,
|
client: sshClient as never,
|
||||||
jumpClient: null,
|
jumpClient: null,
|
||||||
|
host: (typeof host === "number"
|
||||||
|
? { id: host, ip: "", port: 22, username: "" }
|
||||||
|
: host) as never,
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -390,6 +404,7 @@ export function createFakeContext(
|
|||||||
jumpChain: async () => ({
|
jumpChain: async () => ({
|
||||||
client: sshClient as never,
|
client: sshClient as never,
|
||||||
jumpClient: null,
|
jumpClient: null,
|
||||||
|
host: { id: 0, ip: "", port: 22, username: "" } as never,
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
}),
|
}),
|
||||||
poolKey: (pool, host) =>
|
poolKey: (pool, host) =>
|
||||||
@@ -452,6 +467,7 @@ export function createFakeContext(
|
|||||||
kv,
|
kv,
|
||||||
tables,
|
tables,
|
||||||
syncEntities,
|
syncEntities,
|
||||||
|
tombstones,
|
||||||
wsRoutes,
|
wsRoutes,
|
||||||
httpRouters,
|
httpRouters,
|
||||||
settings,
|
settings,
|
||||||
@@ -722,10 +738,11 @@ export interface TestDb {
|
|||||||
/**
|
/**
|
||||||
* An in-memory SQLite database with a plugin's own migrations applied.
|
* 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
|
* Core's users, ssh_data, roles and user_roles exist as minimal stubs with
|
||||||
* refUser or refHost column cascades exactly as it does on the server. The
|
* foreign keys on, so a refUser or refHost column cascades exactly as it
|
||||||
* migrations are read from <pluginDir>/migrations/sqlite and split with the
|
* does on the server, and ctx.db.refs() has something real to join against.
|
||||||
* same splitter the server's runner uses.
|
* The migrations are read from <pluginDir>/migrations/sqlite and split with
|
||||||
|
* the same splitter the server's runner uses.
|
||||||
*/
|
*/
|
||||||
export async function createTestDb(
|
export async function createTestDb(
|
||||||
pluginDir: string,
|
pluginDir: string,
|
||||||
@@ -747,6 +764,12 @@ export async function createTestDb(
|
|||||||
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL DEFAULT '')",
|
"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 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 handle = drizzle(sqlite);
|
||||||
const pluginId = path.basename(pluginDir);
|
const pluginId = path.basename(pluginDir);
|
||||||
@@ -781,6 +804,16 @@ export async function createTestDb(
|
|||||||
const refs = {
|
const refs = {
|
||||||
users: builder.buildRefTable("users", { id: "text", username: "text" }),
|
users: builder.buildRefTable("users", { id: "text", username: "text" }),
|
||||||
hosts: builder.buildRefTable("ssh_data", { id: "integer" }),
|
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 = {
|
const database: PluginDatabase = {
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (problems.length > 0) {
|
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}`);
|
for (const problem of problems) console.error(` ${problem}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ export function scanLegacyCoreModules(repoRoot) {
|
|||||||
}
|
}
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (!plugin.isDirectory()) continue;
|
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 file of files) {
|
||||||
for (const specifier of findLegacyCoreImports(
|
for (const specifier of findLegacyCoreImports(
|
||||||
fs.readFileSync(file, "utf8"),
|
fs.readFileSync(file, "utf8"),
|
||||||
|
|||||||
@@ -92,7 +92,9 @@
|
|||||||
"plugins/remote-desktop/src/frontend/index.tsx",
|
"plugins/remote-desktop/src/frontend/index.tsx",
|
||||||
"plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts",
|
"plugins/remote-desktop/src/frontend/quick-connect-guac-host.ts",
|
||||||
"plugins/remote-desktop/src/frontend/rdp-credential-prompt.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/index.tsx",
|
||||||
|
"plugins/ssh-terminal/src/frontend/session-registry.ts",
|
||||||
"plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx",
|
"plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx",
|
||||||
"plugins/tailscale/src/frontend/TailscaleCheckOverlay.tsx",
|
"plugins/tailscale/src/frontend/TailscaleCheckOverlay.tsx",
|
||||||
"plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx",
|
"plugins/tailscale/src/frontend/TailscaleDevicesPanel.tsx",
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export { default } from ${target};`
|
|||||||
}
|
}
|
||||||
const require = createRequire(path.join(repoRoot, "package.json"));
|
const require = createRequire(path.join(repoRoot, "package.json"));
|
||||||
const names = Object.keys(require(specifier)).filter(
|
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)};`];
|
const lines = [`import * as m from ${JSON.stringify(specifier)};`];
|
||||||
lines.push("const d = m.default ?? m;");
|
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)) {",
|
"for (const [file, load] of Object.entries(locales)) {",
|
||||||
' const parts = file.split("/");',
|
' const parts = file.split("/");',
|
||||||
' const id = parts[2];',
|
" const id = parts[2];",
|
||||||
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
|
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
|
||||||
" (workspaceLocales[id] ??= {})[name] = load;",
|
" (workspaceLocales[id] ??= {})[name] = load;",
|
||||||
"}",
|
"}",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
|
|||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
|
||||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||||
@@ -1764,7 +1763,6 @@ app.use("/host", hostRoutes);
|
|||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/ssh-auth", sshAuthRoutes);
|
app.use("/ssh-auth", sshAuthRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
|
||||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
app.use("/session_logs", sessionLogRoutes);
|
app.use("/session_logs", sessionLogRoutes);
|
||||||
|
|||||||
@@ -435,17 +435,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -1581,65 +1570,6 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||||
addColumnIfNotExists("file_manager_shortcuts", "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 {
|
try {
|
||||||
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
|
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -98,23 +98,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
|||||||
columns: "user_id",
|
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.
|
// 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", {
|
export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
userId: varchar("user_id", { length: 255 })
|
userId: varchar("user_id", { length: 255 })
|
||||||
@@ -616,39 +574,6 @@ export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
|
|||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.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(
|
export const sshFolders = mysqlTable(
|
||||||
"ssh_folders",
|
"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", {
|
export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
userId: varchar("user_id", { length: 255 })
|
userId: varchar("user_id", { length: 255 })
|
||||||
@@ -617,39 +575,6 @@ export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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(
|
export const sshFolders = pgTable(
|
||||||
"ssh_folders",
|
"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", {
|
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
@@ -609,39 +567,6 @@ export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.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(
|
export const sshFolders = sqliteTable(
|
||||||
"ssh_folders",
|
"ssh_folders",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
|||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-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 { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||||
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
||||||
import { SsoProviderRepository } from "./sso-provider-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 {
|
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||||
return new SshCredentialUsageRepository(
|
return new SshCredentialUsageRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
hosts,
|
hosts,
|
||||||
roles,
|
roles,
|
||||||
sharedHostSecrets,
|
sharedHostSecrets,
|
||||||
snippetAccess,
|
|
||||||
snippets,
|
|
||||||
users,
|
users,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
@@ -43,29 +41,6 @@ export interface RbacSharedHost {
|
|||||||
ownerUsername: string;
|
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 {
|
export interface RbacRoleHostAccessCredentialSource {
|
||||||
hostAccessId: number;
|
hostAccessId: number;
|
||||||
credentialId: number | null;
|
credentialId: number | null;
|
||||||
@@ -93,12 +68,6 @@ export type UpsertHostAccessInput = RbacAccessTarget & {
|
|||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpsertSnippetAccessInput = RbacAccessTarget & {
|
|
||||||
snippetId: number;
|
|
||||||
grantedBy: string;
|
|
||||||
expiresAt: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
|
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
|
||||||
|
|
||||||
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
|
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
|
||||||
@@ -249,76 +218,6 @@ export class RbacAccessRepository {
|
|||||||
return rows[0] ?? null;
|
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(
|
async listSharedHosts(
|
||||||
userId: string,
|
userId: string,
|
||||||
roleIds: number[],
|
roleIds: number[],
|
||||||
@@ -371,146 +270,6 @@ export class RbacAccessRepository {
|
|||||||
.orderBy(desc(hostAccess.createdAt));
|
.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(
|
async deleteExpiredHostAccess(
|
||||||
now = new Date().toISOString(),
|
now = new Date().toISOString(),
|
||||||
): Promise<number> {
|
): 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) {
|
private async findHostAccess(hostId: number, target: RbacAccessTarget) {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.select()
|
.select()
|
||||||
@@ -698,23 +446,6 @@ export class RbacAccessRepository {
|
|||||||
return rows[0] ?? null;
|
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> {
|
private async afterWrite(): Promise<void> {
|
||||||
await this.onWrite?.();
|
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,
|
createCurrentSessionRecordingRepository,
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
createCurrentSharedHostSecretsRepository,
|
createCurrentSharedHostSecretsRepository,
|
||||||
createCurrentSnippetRepository,
|
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
createCurrentTermixIdentityCaRepository,
|
createCurrentTermixIdentityCaRepository,
|
||||||
createCurrentTermixIdentityRepository,
|
createCurrentTermixIdentityRepository,
|
||||||
@@ -93,7 +92,8 @@ export async function deleteUserAndRelatedData(
|
|||||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||||
await createCurrentDismissedAlertRepository().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);
|
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
createCurrentSnippetRepository,
|
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} 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
|
* @openapi
|
||||||
* /rbac/host-access/{hostId}/auth/{protocol}:
|
* /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,
|
hosts,
|
||||||
sshCredentials,
|
sshCredentials,
|
||||||
sshFolders,
|
sshFolders,
|
||||||
snippets,
|
|
||||||
snippetFolders,
|
|
||||||
vaultProfiles,
|
vaultProfiles,
|
||||||
dashboardServiceLinks,
|
dashboardServiceLinks,
|
||||||
homepageItems,
|
homepageItems,
|
||||||
@@ -95,12 +93,6 @@ export function registerCoreSyncEntities(): void {
|
|||||||
references: [CREDENTIAL_REFERENCE],
|
references: [CREDENTIAL_REFERENCE],
|
||||||
});
|
});
|
||||||
|
|
||||||
registerEntity(CORE_OWNER, {
|
|
||||||
type: "snippetFolders",
|
|
||||||
table: snippetFolders,
|
|
||||||
order: 40,
|
|
||||||
});
|
|
||||||
|
|
||||||
registerEntity(CORE_OWNER, {
|
registerEntity(CORE_OWNER, {
|
||||||
type: "hosts",
|
type: "hosts",
|
||||||
table: hosts,
|
table: hosts,
|
||||||
@@ -112,12 +104,6 @@ export function registerCoreSyncEntities(): void {
|
|||||||
encryptedFields: ["ssh_data"],
|
encryptedFields: ["ssh_data"],
|
||||||
});
|
});
|
||||||
|
|
||||||
registerEntity(CORE_OWNER, {
|
|
||||||
type: "snippets",
|
|
||||||
table: snippets,
|
|
||||||
order: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
registerEntity(CORE_OWNER, {
|
registerEntity(CORE_OWNER, {
|
||||||
type: "dashboardServiceLinks",
|
type: "dashboardServiceLinks",
|
||||||
table: dashboardServiceLinks,
|
table: dashboardServiceLinks,
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentRecentActivityRepository,
|
createCurrentRecentActivityRepository,
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
createCurrentSnippetRepository,
|
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||||
|
|
||||||
interface UserPasswordResetRoutesDeps {
|
interface UserPasswordResetRoutesDeps {
|
||||||
authManager: AuthManager;
|
authManager: AuthManager;
|
||||||
@@ -73,9 +73,11 @@ export async function resetUserPassword(
|
|||||||
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
|
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
|
||||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
||||||
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
|
||||||
await createCurrentSnippetRepository().deleteByUserId(userId);
|
|
||||||
await createCurrentHostRepository().deleteByUserId(userId);
|
await createCurrentHostRepository().deleteByUserId(userId);
|
||||||
await createCurrentCredentialRepository().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");
|
const { UserKeyManager } = await import("../../utils/user-keys.js");
|
||||||
await UserKeyManager.getInstance().rotateUserDEK(userId);
|
await UserKeyManager.getInstance().rotateUserDEK(userId);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function validateDefaultsJson(value: string): boolean {
|
|||||||
* /user-preferences:
|
* /user-preferences:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get preferences for the current user
|
* 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:
|
* tags:
|
||||||
* - User Preferences
|
* - User Preferences
|
||||||
* responses:
|
* responses:
|
||||||
@@ -160,7 +160,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* /user-preferences:
|
* /user-preferences:
|
||||||
* put:
|
* put:
|
||||||
* summary: Update preferences for the current user
|
* 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:
|
* tags:
|
||||||
* - User Preferences
|
* - User Preferences
|
||||||
* requestBody:
|
* requestBody:
|
||||||
@@ -259,11 +259,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
rdpDefaults?: string | null;
|
rdpDefaults?: string | null;
|
||||||
terminalMacros?: string | null;
|
terminalMacros?: string | null;
|
||||||
};
|
};
|
||||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme,
|
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||||
// foldersCollapsed are no longer writable here -- they moved to
|
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||||
// /host-sidebar/preferences as of the sidebar redesign. The columns stay
|
// sidebar redesign. The columns stay in the table (read once as a
|
||||||
// in the table (read once as a migration seed by that route) but this
|
// migration seed by that route) but this endpoint silently ignores them if
|
||||||
// endpoint silently ignores them if a stale client still sends them.
|
// a stale client still sends them. foldersCollapsed similarly moved out,
|
||||||
|
// to the snippets plugin's own user settings.
|
||||||
|
|
||||||
const updates: UserPreferenceUpdate = {
|
const updates: UserPreferenceUpdate = {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -135,7 +135,11 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|||||||
};
|
};
|
||||||
open.add(dispose);
|
open.add(dispose);
|
||||||
connection.client.once("close", () => open.delete(dispose));
|
connection.client.once("close", () => open.delete(dispose));
|
||||||
return { ...connection, dispose };
|
return {
|
||||||
|
...connection,
|
||||||
|
host: connection.host as unknown as PluginSshHost,
|
||||||
|
dispose,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await audit("ssh_connect", describeHost(host), {
|
await audit("ssh_connect", describeHost(host), {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -156,6 +160,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|||||||
return {
|
return {
|
||||||
client: connection.client as never,
|
client: connection.client as never,
|
||||||
jumpClient: connection.jumpClient as never,
|
jumpClient: connection.jumpClient as never,
|
||||||
|
host: connection.host,
|
||||||
dispose: connection.dispose,
|
dispose: connection.dispose,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -201,7 +206,15 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|||||||
};
|
};
|
||||||
open.add(dispose);
|
open.add(dispose);
|
||||||
client.once("close", () => open.delete(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,
|
poolKey,
|
||||||
|
|||||||
@@ -288,7 +288,12 @@ export function createPluginContext(
|
|||||||
// write through them. The capability and the audit line are the record
|
// write through them. The capability and the audit line are the record
|
||||||
// that it did. See the header of this file.
|
// that it did. See the header of this file.
|
||||||
const schema = await import("../database/db/schema.js");
|
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" },
|
{ action: "db_refs" },
|
||||||
);
|
);
|
||||||
@@ -353,6 +358,16 @@ export function createPluginContext(
|
|||||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
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: {
|
kv: {
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export const TOPICS = {
|
|||||||
hostDeleted: "host.deleted",
|
hostDeleted: "host.deleted",
|
||||||
/** Someone logged in over SSH to a host. */
|
/** Someone logged in over SSH to a host. */
|
||||||
hostLogin: "host.login",
|
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;
|
} as const;
|
||||||
|
|
||||||
export type EventListener = (payload: unknown) => void;
|
export type EventListener = (payload: unknown) => void;
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ describe("RbacAccessRepository", () => {
|
|||||||
INSERT INTO ssh_data (
|
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)
|
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');
|
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 (
|
INSERT INTO host_access (
|
||||||
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
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'),
|
(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'),
|
(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');
|
(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 (
|
INSERT INTO shared_host_secrets (
|
||||||
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
|
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 () => {
|
it("lists shared hosts for direct and role access", async () => {
|
||||||
const repo = await createRepository();
|
const repo = await createRepository();
|
||||||
|
|
||||||
@@ -253,67 +220,6 @@ describe("RbacAccessRepository", () => {
|
|||||||
expect(writeCount).toBe(1);
|
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 () => {
|
it("upserts host access and updates overrides", async () => {
|
||||||
let writeCount = 0;
|
let writeCount = 0;
|
||||||
const repo = await createRepository(() => {
|
const repo = await createRepository(() => {
|
||||||
@@ -432,39 +338,4 @@ describe("RbacAccessRepository", () => {
|
|||||||
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0);
|
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0);
|
||||||
expect(writeCount).toBe(1);
|
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",
|
"hosts",
|
||||||
"sshCredentials",
|
"sshCredentials",
|
||||||
"sshFolders",
|
"sshFolders",
|
||||||
"snippets",
|
|
||||||
"snippetFolders",
|
|
||||||
"vaultProfiles",
|
"vaultProfiles",
|
||||||
"dashboardServiceLinks",
|
"dashboardServiceLinks",
|
||||||
"homepageItems",
|
"homepageItems",
|
||||||
@@ -88,7 +86,7 @@ describe("stripWritePayload", () => {
|
|||||||
|
|
||||||
it("does not mutate the original payload object", () => {
|
it("does not mutate the original payload object", () => {
|
||||||
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
|
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
|
||||||
stripWritePayload("snippets", payload);
|
stripWritePayload("sshFolders", payload);
|
||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
id: 1,
|
id: 1,
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const calls = vi.hoisted(() => ({
|
|||||||
deletedFor: [] as string[],
|
deletedFor: [] as string[],
|
||||||
rotatedFor: [] as string[],
|
rotatedFor: [] as string[],
|
||||||
legacyWrapsDeletedFor: [] as string[],
|
legacyWrapsDeletedFor: [] as string[],
|
||||||
|
dataWipedEvents: [] as Array<{ userId: string }>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function deletingRepo(label: string) {
|
function deletingRepo(label: string) {
|
||||||
@@ -29,11 +30,19 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
|
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
|
||||||
createCurrentRecentActivityRepository: deletingRepo("activity"),
|
createCurrentRecentActivityRepository: deletingRepo("activity"),
|
||||||
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
|
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
|
||||||
createCurrentSnippetRepository: deletingRepo("snippets"),
|
|
||||||
createCurrentHostRepository: deletingRepo("hosts"),
|
createCurrentHostRepository: deletingRepo("hosts"),
|
||||||
createCurrentCredentialRepository: deletingRepo("credentials"),
|
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", () => ({
|
vi.mock("../../../utils/user-keys.js", () => ({
|
||||||
UserKeyManager: {
|
UserKeyManager: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
@@ -65,6 +74,7 @@ beforeEach(() => {
|
|||||||
calls.deletedFor = [];
|
calls.deletedFor = [];
|
||||||
calls.rotatedFor = [];
|
calls.rotatedFor = [];
|
||||||
calls.legacyWrapsDeletedFor = [];
|
calls.legacyWrapsDeletedFor = [];
|
||||||
|
calls.dataWipedEvents = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resetUserPassword", () => {
|
describe("resetUserPassword", () => {
|
||||||
@@ -110,10 +120,10 @@ describe("resetUserPassword", () => {
|
|||||||
"bookmarks:user-1",
|
"bookmarks:user-1",
|
||||||
"activity:user-1",
|
"activity:user-1",
|
||||||
"alerts:user-1",
|
"alerts:user-1",
|
||||||
"snippets:user-1",
|
|
||||||
"hosts:user-1",
|
"hosts:user-1",
|
||||||
"credentials:user-1",
|
"credentials:user-1",
|
||||||
]);
|
]);
|
||||||
|
expect(calls.dataWipedEvents).toEqual([{ userId: "user-1" }]);
|
||||||
expect(calls.rotatedFor).toEqual(["user-1"]);
|
expect(calls.rotatedFor).toEqual(["user-1"]);
|
||||||
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
|
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ vi.mock("../../utils/database-save-trigger.js", () => ({
|
|||||||
vi.mock("../../database/db/schema.js", () => ({
|
vi.mock("../../database/db/schema.js", () => ({
|
||||||
users: { marker: "users" },
|
users: { marker: "users" },
|
||||||
hosts: { marker: "hosts" },
|
hosts: { marker: "hosts" },
|
||||||
|
roles: { marker: "roles" },
|
||||||
|
userRoles: { marker: "userRoles" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
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>;
|
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"]);
|
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", () => {
|
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);
|
const ids = idsFor(pluginId);
|
||||||
registerPluginPermissions({
|
registerPluginPermissions({
|
||||||
group: pluginId,
|
group: pluginId,
|
||||||
@@ -402,6 +412,8 @@ describe("shipped manifests keep their permission ids", () => {
|
|||||||
"automations.run",
|
"automations.run",
|
||||||
"automations.view",
|
"automations.view",
|
||||||
"tailscale.devices.view",
|
"tailscale.devices.view",
|
||||||
|
"snippets.view",
|
||||||
|
"snippets.share",
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
|
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
|
||||||
|
|||||||
@@ -96,19 +96,26 @@ describe("core sync entities", () => {
|
|||||||
registerCoreSyncEntities();
|
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", () => {
|
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());
|
const coreTypes = new Set(listEntityTypes());
|
||||||
for (const type of SYNCED_ENTITY_TYPES) {
|
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);
|
expect(coreTypes.has(type), type).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the dependency order the frozen Electron array encodes", () => {
|
it("keeps the dependency order the frozen Electron array encodes", () => {
|
||||||
expect(listEntityTypes()).toEqual(
|
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 = [
|
const IMPERSONATION_PATH_ALLOWLIST = [
|
||||||
/^\/host\/db\//,
|
/^\/host\/db\//,
|
||||||
/^\/credentials(\/|$)/,
|
/^\/credentials(\/|$)/,
|
||||||
/^\/snippets(\/|$)/,
|
/^\/plugin-api\/snippets(\/|$)/,
|
||||||
];
|
];
|
||||||
|
|
||||||
class AuthManager {
|
class AuthManager {
|
||||||
|
|||||||
@@ -34,17 +34,6 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
|||||||
"hosts.share",
|
"hosts.share",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
group: "snippets",
|
|
||||||
labelKey: "admin.rolePermissions.groups.snippets",
|
|
||||||
permissions: [
|
|
||||||
"snippets.view",
|
|
||||||
"snippets.create",
|
|
||||||
"snippets.edit",
|
|
||||||
"snippets.delete",
|
|
||||||
"snippets.share",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
group: "credentials",
|
group: "credentials",
|
||||||
labelKey: "admin.rolePermissions.groups.credentials",
|
labelKey: "admin.rolePermissions.groups.credentials",
|
||||||
@@ -183,6 +172,6 @@ export const SYSTEM_ROLE_DEFAULTS = {
|
|||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
description: "Regular user",
|
description: "Regular user",
|
||||||
permissions: ["hosts.*", "snippets.*", "credentials.*"],
|
permissions: ["hosts.*", "credentials.*"],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
Reference in new issue
Block a user