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

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

No files matched your search

-2
View File
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
@@ -1764,7 +1763,6 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/ssh-auth", sshAuthRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
-70
View File
@@ -435,17 +435,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1581,65 +1570,6 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("snippets", "folder", "TEXT");
addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("snippets", "host_filter", "TEXT");
addColumnIfNotExists("snippets", "is_note", "INTEGER NOT NULL DEFAULT 0");
try {
sqlite
.prepare("SELECT id FROM snippet_folders LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS snippet_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create snippet_folders table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS snippet_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL,
user_id TEXT,
role_id INTEGER,
granted_by TEXT NOT NULL,
permission_level TEXT NOT NULL DEFAULT 'view',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create snippet_access table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
} catch {
@@ -98,23 +98,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
columns: "user_id",
},
// Snippets.
{ name: "idx_snippets_user_id", table: "snippets", columns: "user_id" },
{
name: "idx_snippet_access_user_id",
table: "snippet_access",
columns: "user_id",
},
{
name: "idx_snippet_access_snippet_id",
table: "snippet_access",
columns: "snippet_id",
},
{
name: "idx_snippet_access_role_id",
table: "snippet_access",
columns: "role_id",
},
// Per-user history and file manager surfaces.
{
-75
View File
@@ -557,48 +557,6 @@ export const sshCredentialUsage = mysqlTable(
],
);
export const snippets = mysqlTable(
"snippets",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
order: int("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
hostFilter: text("host_filter"),
isNote: boolean("is_note").notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = mysqlTable("snippet_folders", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
color: text("color"),
icon: text("icon"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -616,39 +574,6 @@ export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const snippetAccess = mysqlTable(
"snippet_access",
{
id: int("id").autoincrement().primaryKey(),
snippetId: int("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: int("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = mysqlTable(
"ssh_folders",
{
-75
View File
@@ -558,48 +558,6 @@ export const sshCredentialUsage = pgTable(
],
);
export const snippets = pgTable(
"snippets",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
content: text("content").notNull(),
description: text("description"),
folder: varchar("folder", { length: 255 }),
order: integer("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
hostFilter: text("host_filter"),
isNote: boolean("is_note").notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = pgTable("snippet_folders", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
color: text("color"),
icon: text("icon"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -617,39 +575,6 @@ export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = pgTable(
"snippet_access",
{
id: serial("id").primaryKey(),
snippetId: integer("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: varchar("granted_by", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = pgTable(
"ssh_folders",
{
-75
View File
@@ -550,48 +550,6 @@ export const sshCredentialUsage = sqliteTable(
],
);
export const snippets = sqliteTable(
"snippets",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
hostFilter: text("host_filter"),
isNote: integer("is_note", { mode: "boolean" }).notNull().default(false),
},
(table) => [index("idx_snippets_user_id").on(table.userId)],
);
export const snippetFolders = sqliteTable("snippet_folders", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -609,39 +567,6 @@ export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = sqliteTable(
"snippet_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
snippetId: integer("snippet_id")
.notNull()
.references(() => snippets.id, { onDelete: "cascade" }),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
roleId: integer("role_id").references(() => roles.id, {
onDelete: "cascade",
}),
grantedBy: text("granted_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permissionLevel: text("permission_level").notNull().default("view"),
expiresAt: text("expires_at"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same three lookup shapes as host_access: by grantee, by role, by snippet.
(table) => [
index("idx_snippet_access_user_id").on(table.userId),
index("idx_snippet_access_snippet_id").on(table.snippetId),
index("idx_snippet_access_role_id").on(table.roleId),
],
);
export const sshFolders = sqliteTable(
"ssh_folders",
{
@@ -54,7 +54,6 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
@@ -504,13 +503,6 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
);
}
export function createCurrentSnippetRepository(): SnippetRepository {
return new SnippetRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("snippet_repository_write"),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository(
createCurrentRepositoryContext(),
@@ -4,8 +4,6 @@ import {
hosts,
roles,
sharedHostSecrets,
snippetAccess,
snippets,
users,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -43,29 +41,6 @@ export interface RbacSharedHost {
ownerUsername: string;
}
export interface RbacSharedSnippet {
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}
export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
userId: string;
order: number;
createdAt: string;
updatedAt: string;
isNote: boolean;
}
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
hostFilter: string | null;
}
export interface RbacRoleHostAccessCredentialSource {
hostAccessId: number;
credentialId: number | null;
@@ -93,12 +68,6 @@ export type UpsertHostAccessInput = RbacAccessTarget & {
expiresAt: string | null;
};
export type UpsertSnippetAccessInput = RbacAccessTarget & {
snippetId: number;
grantedBy: string;
expiresAt: string | null;
};
type RawAccessListItem = Omit<RbacAccessListItem, "targetType">;
function toAccessListItem(access: RawAccessListItem): RbacAccessListItem {
@@ -249,76 +218,6 @@ export class RbacAccessRepository {
return rows[0] ?? null;
}
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
const rows = await this.context.drizzle
.select({
id: snippetAccess.id,
userId: snippetAccess.userId,
roleId: snippetAccess.roleId,
username: users.username,
roleName: roles.name,
roleDisplayName: roles.displayName,
grantedBy: snippetAccess.grantedBy,
grantedByUsername: sql<
string | null
>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
createdAt: snippetAccess.createdAt,
})
.from(snippetAccess)
.leftJoin(users, eq(snippetAccess.userId, users.id))
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
.where(eq(snippetAccess.snippetId, snippetId))
.orderBy(desc(snippetAccess.createdAt));
return rows.map(toAccessListItem);
}
async upsertSnippetAccess(input: UpsertSnippetAccessInput): Promise<{
id: number;
created: boolean;
}> {
const existing = await this.findSnippetAccess(input.snippetId, input);
if (existing) {
await this.context.drizzle
.update(snippetAccess)
.set({ expiresAt: input.expiresAt })
.where(eq(snippetAccess.id, existing.id));
await this.afterWrite();
return { id: existing.id, created: false };
}
const [created] = await insertReturning(this.context, snippetAccess, {
snippetId: input.snippetId,
userId: input.targetType === "user" ? input.targetUserId : null,
roleId: input.targetType === "role" ? input.targetRoleId : null,
grantedBy: input.grantedBy,
permissionLevel: "view",
expiresAt: input.expiresAt,
});
await this.afterWrite();
return { id: created.id, created: true };
}
async revokeSnippetAccess(
accessId: number,
snippetId: number,
): Promise<void> {
await this.context.drizzle
.delete(snippetAccess)
.where(
and(
eq(snippetAccess.id, accessId),
eq(snippetAccess.snippetId, snippetId),
),
);
await this.afterWrite();
}
async listSharedHosts(
userId: string,
roleIds: number[],
@@ -371,146 +270,6 @@ export class RbacAccessRepository {
.orderBy(desc(hostAccess.createdAt));
}
async listSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacSharedSnippet[]> {
const directShared = await this.context.drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(snippetAccess.userId, userId),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
);
if (roleIds.length === 0) {
return directShared;
}
const directIds = new Set(directShared.map((snippet) => snippet.id));
const roleShared = await this.context.drizzle
.select({
id: snippets.id,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
inArray(snippetAccess.roleId, roleIds),
),
);
return [
...directShared,
...roleShared.filter((snippet) => !directIds.has(snippet.id)),
];
}
async listVisibleSharedSnippets(
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacVisibleSharedSnippet[]> {
return this.context.drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
this.userOrRoleSnippetAccessFilter(userId, roleIds),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
);
}
async findAccessibleSharedSnippet(
snippetId: number,
userId: string,
roleIds: number[],
now = new Date().toISOString(),
): Promise<RbacAccessibleSnippet | null> {
const rows = await this.context.drizzle
.select({
id: snippets.id,
userId: snippets.userId,
name: snippets.name,
content: snippets.content,
description: snippets.description,
folder: snippets.folder,
order: snippets.order,
createdAt: snippets.createdAt,
updatedAt: snippets.updatedAt,
hostFilter: snippets.hostFilter,
isNote: snippets.isNote,
ownerUsername: users.username,
permissionLevel: snippetAccess.permissionLevel,
expiresAt: snippetAccess.expiresAt,
})
.from(snippetAccess)
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
.innerJoin(users, eq(snippets.userId, users.id))
.where(
and(
eq(snippetAccess.snippetId, snippetId),
this.userOrRoleSnippetAccessFilter(userId, roleIds),
or(
isNull(snippetAccess.expiresAt),
gte(snippetAccess.expiresAt, now),
),
),
)
.limit(1);
return rows[0] ?? null;
}
async deleteExpiredHostAccess(
now = new Date().toISOString(),
): Promise<number> {
@@ -670,17 +429,6 @@ export class RbacAccessRepository {
);
}
private userOrRoleSnippetAccessFilter(userId: string, roleIds: number[]) {
if (roleIds.length === 0) {
return eq(snippetAccess.userId, userId);
}
return or(
eq(snippetAccess.userId, userId),
inArray(snippetAccess.roleId, roleIds),
);
}
private async findHostAccess(hostId: number, target: RbacAccessTarget) {
const rows = await this.context.drizzle
.select()
@@ -698,23 +446,6 @@ export class RbacAccessRepository {
return rows[0] ?? null;
}
private async findSnippetAccess(snippetId: number, target: RbacAccessTarget) {
const rows = await this.context.drizzle
.select()
.from(snippetAccess)
.where(
and(
eq(snippetAccess.snippetId, snippetId),
target.targetType === "user"
? eq(snippetAccess.userId, target.targetUserId)
: eq(snippetAccess.roleId, target.targetRoleId),
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
@@ -1,575 +0,0 @@
import { and, asc, eq, like, or, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type SnippetRecord = typeof snippets.$inferSelect;
export type SnippetFolderRecord = typeof snippetFolders.$inferSelect;
export interface RenameSnippetFolderResult {
status: "renamed" | "missing" | "conflict";
}
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
export interface NewSnippetInput {
name: string;
content: string;
description?: string | null;
folder?: string | null;
order?: number | null;
hostFilter?: unknown;
isNote?: boolean;
}
export interface SnippetUpdateInput {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
order?: number;
hostFilter?: unknown;
isNote?: boolean;
}
export interface UpdateSnippetResult {
existing: SnippetRecord;
updated: SnippetRecord;
}
export interface SnippetBulkImportResult {
snippetsImported: number;
snippetsSkipped: number;
snippetsUpdated: number;
foldersImported: number;
foldersSkipped: number;
failed: number;
errors: string[];
}
interface BulkImportSnippetInput {
name?: unknown;
content?: unknown;
description?: string | null;
folder?: string | null;
order?: unknown;
hostFilter?: string | null;
}
interface BulkImportFolderInput {
name?: unknown;
color?: string | null;
icon?: string | null;
}
export class SnippetRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findOwnedById(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async listFolders(userId: string): Promise<SnippetFolderRecord[]> {
return this.context.drizzle
.select()
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
}
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
return (
this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
// coalesce, not asc(folder): folder is nullable, and NULLs sort first on
// SQLite and MySQL but last on Postgres. An export whose row order depends
// on the engine is not much of an export.
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order))
);
}
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
return this.listFolders(userId);
}
/** Snippets in a folder or any of its subfolders ("Parent / Child"). */
async listOwnedSnippetsInFolder(
userId: string,
folder: string,
): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
or(
eq(snippets.folder, folder),
like(snippets.folder, `${folder} / %`),
),
),
);
}
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
return this.context.drizzle
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
asc(snippets.folder),
asc(snippets.order),
sql`${snippets.updatedAt} DESC`,
);
}
async reorderSnippets(
userId: string,
updates: SnippetReorderUpdate[],
): Promise<void> {
for (const update of updates) {
const { id, order, folder } = update;
if (!id || order === undefined) {
continue;
}
const updateFields: Partial<{
order: number;
folder: string | null;
}> = {
order,
};
if (folder !== undefined) {
updateFields.folder = folder?.trim() || null;
}
await this.context.drizzle
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
}
await this.afterWrite();
}
async createSnippet(
userId: string,
input: NewSnippetInput,
): Promise<SnippetRecord> {
const folderValue = input.folder?.trim() || "";
const order =
input.order === undefined || input.order === null
? await this.nextOrderForFolder(userId, folderValue)
: input.order;
const rows = await insertReturning(this.context, snippets, {
syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
order,
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
isNote: input.isNote ?? false,
});
await this.afterWrite();
return rows[0];
}
async updateSnippet(
userId: string,
snippetId: number,
input: SnippetUpdateInput,
): Promise<UpdateSnippetResult | null> {
const existing = await this.findOwnedById(userId, snippetId);
if (!existing) return null;
const updateFields: Partial<{
updatedAt: ReturnType<typeof sql>;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
hostFilter: string | null;
isNote: boolean;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (input.name !== undefined) updateFields.name = input.name.trim();
if (input.content !== undefined)
updateFields.content = input.content.trim();
if (input.description !== undefined)
updateFields.description = input.description?.trim() || null;
if (input.folder !== undefined)
updateFields.folder = input.folder?.trim() || null;
if (input.order !== undefined) updateFields.order = input.order;
if (input.hostFilter !== undefined)
updateFields.hostFilter = input.hostFilter
? JSON.stringify(input.hostFilter)
: null;
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
const rows = await updateReturning(
this.context,
snippets,
updateFields,
and(eq(snippets.id, snippetId), eq(snippets.userId, userId)),
);
await this.afterWrite();
return { existing, updated: rows[0] };
}
async deleteSnippet(
userId: string,
snippetId: number,
): Promise<SnippetRecord | null> {
const existing = await this.findOwnedById(userId, snippetId);
if (!existing) return null;
await this.context.drizzle
.delete(snippets)
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
await this.afterWrite();
return existing;
}
async deleteByUserId(userId: string): Promise<{
snippetsDeleted: number;
foldersDeleted: number;
}> {
const snippetResult = await this.context.drizzle
.delete(snippets)
.where(eq(snippets.userId, userId));
const result = await this.context.drizzle
.delete(snippetFolders)
.where(eq(snippetFolders.userId, userId));
if (rowsAffected(snippetResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
snippetsDeleted: rowsAffected(snippetResult),
foldersDeleted: rowsAffected(result),
};
}
async bulkImport(
userId: string,
snippetsToImport: unknown[] | undefined,
foldersToImport: unknown[] | undefined,
overwrite: boolean,
): Promise<SnippetBulkImportResult> {
const results: SnippetBulkImportResult = {
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 0,
foldersImported: 0,
foldersSkipped: 0,
failed: 0,
errors: [],
};
let changed = false;
if (Array.isArray(foldersToImport)) {
for (const rawFolder of foldersToImport) {
const folder = rawFolder as BulkImportFolderInput;
if (!isNonEmptyString(folder.name)) {
results.failed++;
results.errors.push(`Folder missing name`);
continue;
}
const created = await this.createFolder(
userId,
folder.name,
folder.color,
folder.icon,
false,
);
if (!created) {
results.foldersSkipped++;
continue;
}
changed = true;
results.foldersImported++;
}
}
if (Array.isArray(snippetsToImport)) {
for (let i = 0; i < snippetsToImport.length; i++) {
const snippet = snippetsToImport[i] as BulkImportSnippetInput;
if (
!isNonEmptyString(snippet.name) ||
!isNonEmptyString(snippet.content)
) {
results.failed++;
results.errors.push(
`Snippet ${i + 1}: name and content are required`,
);
continue;
}
const folderVal = snippet.folder?.trim() || null;
const existing = await this.findByNameAndFolder(
userId,
snippet.name.trim(),
folderVal,
);
if (existing) {
if (!overwrite) {
results.snippetsSkipped++;
continue;
}
await this.context.drizzle
.update(snippets)
.set({
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number"
? snippet.order
: existing.order,
hostFilter: snippet.hostFilter || null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
.where(
and(eq(snippets.id, existing.id), eq(snippets.userId, userId)),
);
changed = true;
results.snippetsUpdated++;
continue;
}
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
await this.context.drizzle.insert(snippets).values({
syncId: randomUUID(),
userId,
name: snippet.name.trim(),
content: snippet.content.trim(),
description: snippet.description?.trim() || null,
folder: folderVal,
order:
typeof snippet.order === "number" ? snippet.order : maxOrder + 1,
hostFilter: snippet.hostFilter || null,
});
changed = true;
results.snippetsImported++;
}
}
if (changed) {
await this.afterWrite();
}
return results;
}
async createFolder(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
triggerSave = true,
): Promise<SnippetFolderRecord | null> {
const existing = await this.findFolderByName(userId, name);
if (existing) return null;
const rows = await insertReturning(this.context, snippetFolders, {
syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
icon: icon?.trim() || null,
});
if (triggerSave) {
await this.afterWrite();
}
return rows[0] ?? null;
}
async updateFolderMetadata(
userId: string,
name: string,
color: string | null | undefined,
icon: string | null | undefined,
): Promise<SnippetFolderRecord | null> {
const existing = await this.findFolderByName(userId, name);
if (!existing) return null;
const updateFields: Partial<{
color: string | null;
icon: string | null;
updatedAt: ReturnType<typeof sql>;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
const rows = await updateReturning(
this.context,
snippetFolders,
updateFields,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ?? null;
}
async renameFolder(
userId: string,
oldName: string,
newName: string,
): Promise<RenameSnippetFolderResult> {
const existing = await this.findFolderByName(userId, oldName);
if (!existing) return { status: "missing" };
const nameExists = await this.findFolderByName(userId, newName);
if (nameExists) return { status: "conflict" };
await this.context.drizzle
.update(snippetFolders)
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
await this.context.drizzle
.update(snippets)
.set({ folder: newName })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
await this.afterWrite();
return { status: "renamed" };
}
async deleteFolder(
userId: string,
name: string,
): Promise<{ syncId: string | null } | null> {
await this.context.drizzle
.update(snippets)
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
const rows = await deleteReturning(
this.context,
snippetFolders,
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
await this.afterWrite();
return rows[0] ? { syncId: rows[0].syncId } : null;
}
private async findFolderByName(
userId: string,
name: string,
): Promise<SnippetFolderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
)
.limit(1);
return rows[0] ?? null;
}
private async nextOrderForFolder(
userId: string,
folder: string,
): Promise<number> {
return (await this.maxOrderForFolder(userId, folder || null)) + 1;
}
private async maxOrderForFolder(
userId: string,
folder: string | null,
): Promise<number> {
const maxOrderResult = await this.context.drizzle
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
return maxOrder;
}
private async findByNameAndFolder(
userId: string,
name: string,
folder: string | null,
): Promise<SnippetRecord | null> {
const rows = await this.context.drizzle
.select()
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
eq(snippets.name, name),
folder
? eq(snippets.folder, folder)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
)
.limit(1);
return rows[0] ?? null;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
@@ -28,7 +28,6 @@ import {
createCurrentSessionRecordingRepository,
createCurrentSettingsRepository,
createCurrentSharedHostSecretsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentTermixIdentityCaRepository,
createCurrentTermixIdentityRepository,
@@ -93,7 +92,8 @@ export async function deleteUserAndRelatedData(
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
// snippets, snippet_folders and snippet_access cascade on the user's
// refUser() foreign key, as the snippets plugin's adopted tables.
await createCurrentHostFolderRepository().deleteByUserId(userId);
-378
View File
@@ -30,7 +30,6 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSnippetRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
@@ -1941,383 +1940,6 @@ router.delete(
},
);
// SNIPPET SHARING
/**
* @openapi
* /rbac/snippet/{id}/share:
* post:
* summary: Share a snippet
* description: Shares a snippet with a user or role.
* tags:
* - RBAC
*/
router.post(
"/snippet/:id/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const {
targetType = "user",
targetUserId,
targetRoleId,
durationHours,
} = req.body;
if (!["user", "role"].includes(targetType)) {
return res
.status(400)
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
}
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
return res
.status(400)
.json({ error: "Target user ID is required when sharing with user" });
}
if (targetType === "role" && !targetRoleId) {
return res
.status(400)
.json({ error: "Target role ID is required when sharing with role" });
}
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
if (targetType === "user") {
const targetUser =
await createCurrentUserRepository().findById(targetUserId);
if (!targetUser) {
return res.status(404).json({ error: "Target user not found" });
}
} else {
const targetRole =
await createCurrentRoleRepository().findRoleById(targetRoleId);
if (!targetRole) {
return res.status(404).json({ error: "Target role not found" });
}
}
let expiresAt: string | null = null;
if (
durationHours &&
typeof durationHours === "number" &&
durationHours > 0
) {
const expiryDate = new Date();
expiryDate.setHours(expiryDate.getHours() + durationHours);
expiresAt = expiryDate.toISOString();
}
const accessGrant =
await createCurrentRbacAccessRepository().upsertSnippetAccess({
snippetId,
grantedBy: userId,
expiresAt,
...(targetType === "user"
? { targetType: "user" as const, targetUserId: targetUserId! }
: { targetType: "role" as const, targetRoleId: targetRoleId! }),
});
if (!accessGrant.created) {
return res.json({
success: true,
message: "Snippet access updated",
expiresAt,
});
}
databaseLogger.success("Snippet shared successfully", {
operation: "rbac_snippet_share",
userId,
});
res.json({
success: true,
message: `Snippet shared successfully with ${targetType}`,
expiresAt,
});
} catch (error) {
databaseLogger.error("Failed to share snippet", error, {
operation: "share_snippet",
userId,
});
res.status(500).json({ error: "Failed to share snippet" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access/{accessId}:
* delete:
* summary: Revoke snippet access
* description: Revokes a user's or role's access to a snippet.
* tags:
* - RBAC
*/
/**
* @openapi
* /rbac/snippet-folder/share:
* post:
* summary: Share every snippet in a folder
* description: Grants view access to each owned snippet in the folder (and its subfolders) to the given users or roles.
* tags:
* - RBAC
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* folder:
* type: string
* targets:
* type: array
* items:
* type: object
* properties:
* type:
* type: string
* enum: [user, role]
* id:
* oneOf:
* - type: string
* - type: integer
* durationHours:
* type: number
* responses:
* 200:
* description: Folder shared.
* 404:
* description: A target was not found.
*/
router.post(
"/snippet-folder/share",
authenticateJWT,
permissionManager.requirePermission("snippets.share"),
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
const { folder, durationHours } = req.body ?? {};
if (!isNonEmptyString(folder)) {
return res.status(400).json({ error: "Folder name is required" });
}
const targets = parseShareTargets(req.body ?? {});
if (!targets) {
return res.status(400).json({
error:
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
});
}
try {
const userRepository = createCurrentUserRepository();
const roleRepository = createCurrentRoleRepository();
for (const target of targets) {
const found =
target.type === "user"
? await userRepository.findById(target.id as string)
: await roleRepository.findRoleById(target.id as number);
if (!found) {
return res.status(404).json({
error: `Target ${target.type} not found`,
targetId: target.id,
});
}
}
const snippetsInFolder =
await createCurrentSnippetRepository().listOwnedSnippetsInFolder(
userId,
folder,
);
const expiresAt = expiryFromDuration(durationHours);
const rbacAccessRepository = createCurrentRbacAccessRepository();
for (const snippet of snippetsInFolder) {
for (const target of targets) {
if (target.type === "user" && target.id === userId) continue;
await rbacAccessRepository.upsertSnippetAccess({
snippetId: snippet.id,
grantedBy: userId,
expiresAt,
...(target.type === "user"
? {
targetType: "user" as const,
targetUserId: target.id as string,
}
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
}
}
databaseLogger.success("Snippet folder shared successfully", {
operation: "rbac_snippet_folder_share",
userId,
folder,
snippetsShared: snippetsInFolder.length,
targets: targets.length,
});
res.json({
success: true,
expiresAt,
snippetsShared: snippetsInFolder.length,
});
} catch (error) {
databaseLogger.error("Failed to share snippet folder", error, {
operation: "share_snippet_folder",
folder,
userId,
});
res.status(500).json({ error: "Failed to share snippet folder" });
}
},
);
router.delete(
"/snippet/:id/access/:accessId",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId)
? req.params.accessId[0]
: req.params.accessId;
const snippetId = parseInt(id, 10);
const accessId = parseInt(accessIdParam, 10);
const userId = req.userId!;
if (isNaN(snippetId) || isNaN(accessId)) {
return res.status(400).json({ error: "Invalid ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
await createCurrentRbacAccessRepository().revokeSnippetAccess(
accessId,
snippetId,
);
res.json({ success: true, message: "Snippet access revoked" });
} catch (error) {
databaseLogger.error("Failed to revoke snippet access", error, {
operation: "revoke_snippet_access",
userId,
});
res.status(500).json({ error: "Failed to revoke access" });
}
},
);
/**
* @openapi
* /rbac/snippet/{id}/access:
* get:
* summary: Get snippet access list
* description: Retrieves the list of users and roles with access to a snippet.
* tags:
* - RBAC
*/
router.get(
"/snippet/:id/access",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const snippetId = parseInt(id, 10);
const userId = req.userId!;
if (isNaN(snippetId)) {
return res.status(400).json({ error: "Invalid snippet ID" });
}
try {
const snippet = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!snippet) {
return res.status(403).json({ error: "Not snippet owner" });
}
const accessList =
await createCurrentRbacAccessRepository().listSnippetAccess(snippetId);
res.json({ accessList });
} catch (error) {
databaseLogger.error("Failed to get snippet access list", error, {
operation: "get_snippet_access_list",
userId,
});
res.status(500).json({ error: "Failed to get access list" });
}
},
);
/**
* @openapi
* /rbac/shared-snippets:
* get:
* summary: Get shared snippets
* description: Retrieves snippets shared with the current user.
* tags:
* - RBAC
*/
router.get(
"/shared-snippets",
authenticateJWT,
async (req: AuthenticatedRequest, res: Response) => {
const userId = req.userId!;
try {
const roleIds =
await createCurrentRoleRepository().listUserRoleIds(userId);
const sharedSnippets =
await createCurrentRbacAccessRepository().listSharedSnippets(
userId,
roleIds,
);
res.json({ sharedSnippets });
} catch (error) {
databaseLogger.error("Failed to get shared snippets", error, {
operation: "get_shared_snippets",
userId,
});
res.status(500).json({ error: "Failed to get shared snippets" });
}
},
);
/**
* @openapi
* /rbac/host-access/{hostId}/auth/{protocol}:
@@ -1,82 +0,0 @@
export interface SnippetExecutionResult {
success: boolean;
output: string;
error?: string;
}
export interface SnippetHostVars {
ip?: string;
username?: string;
port?: number | string;
name?: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
function replaceVar(content: string, name: string, value?: string): string {
if (value === undefined) return content;
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
return content.replace(pattern, value);
}
/**
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
* backend are separate builds, so this is kept as a small standalone copy
* rather than a shared package for one pure function.
*/
export function resolveSnippetCommand(
content: string,
host: SnippetHostVars | null,
inputValues: Record<string, string> = {},
): string {
let resolved = content;
resolved = replaceVar(resolved, "HOST", host?.ip);
resolved = replaceVar(resolved, "USER", host?.username);
resolved = replaceVar(
resolved,
"PORT",
host?.port !== undefined ? String(host.port) : undefined,
);
resolved = replaceVar(resolved, "NAME", host?.name);
resolved = resolved.replace(
INPUT_PATTERN,
(
fullMatch,
braceDigits: string | undefined,
_label,
plainDigits: string | undefined,
) => {
const key = `INPUT_${braceDigits ?? plainDigits}`;
return key in inputValues ? inputValues[key] : fullMatch;
},
);
return resolved;
}
export function getSnippetExecutionTimeoutMs(
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
): number | undefined {
if (value === undefined || value.trim() === "") return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
return seconds * 1000;
}
export function createSnippetExecutionResult(
exitCode: number | null,
output: string,
errorOutput: string,
): SnippetExecutionResult {
const success = exitCode === 0 || (exitCode === null && !errorOutput);
return {
success,
output,
...(errorOutput ? { error: errorOutput } : {}),
};
}
@@ -1,33 +0,0 @@
export interface SnippetReorderUpdate {
id: number;
order: number;
folder?: string;
}
type SnippetReorderRequestBody = {
snippets?: unknown;
updates?: unknown;
};
export function extractSnippetReorderUpdates(
body: unknown,
): SnippetReorderUpdate[] | null {
if (!body || typeof body !== "object") {
return null;
}
const payload = body as SnippetReorderRequestBody;
// Keep accepting the legacy `updates` key so older clients do not break
// while the web and desktop helpers converge on `snippets`.
const snippetsUpdates = Array.isArray(payload.snippets)
? payload.snippets
: Array.isArray(payload.updates)
? payload.updates
: null;
if (!snippetsUpdates) {
return null;
}
return snippetsUpdates as SnippetReorderUpdate[];
}
File diff suppressed because it is too large. Load diff
@@ -15,8 +15,6 @@ import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
@@ -95,12 +93,6 @@ export function registerCoreSyncEntities(): void {
references: [CREDENTIAL_REFERENCE],
});
registerEntity(CORE_OWNER, {
type: "snippetFolders",
table: snippetFolders,
order: 40,
});
registerEntity(CORE_OWNER, {
type: "hosts",
table: hosts,
@@ -112,12 +104,6 @@ export function registerCoreSyncEntities(): void {
encryptedFields: ["ssh_data"],
});
registerEntity(CORE_OWNER, {
type: "snippets",
table: snippets,
order: 60,
});
registerEntity(CORE_OWNER, {
type: "dashboardServiceLinks",
table: dashboardServiceLinks,
@@ -12,10 +12,10 @@ import {
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
createCurrentSnippetRepository,
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
interface UserPasswordResetRoutesDeps {
authManager: AuthManager;
@@ -73,9 +73,11 @@ export async function resetUserPassword(
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentSnippetRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
await createCurrentCredentialRepository().deleteByUserId(userId);
// A plugin holding this user's data (snippets and anything else keyed by
// refUser()) wipes it on this topic, since the user row itself survives.
pluginEvents.emit(TOPICS.userDataWiped, { userId });
const { UserKeyManager } = await import("../../utils/user-keys.js");
await UserKeyManager.getInstance().rotateUserDEK(userId);
@@ -60,7 +60,7 @@ export function validateDefaultsJson(value: string): boolean {
* /user-preferences:
* get:
* summary: Get preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are legacy fields, kept here read-only for backward compatibility. The authoritative copy is GET /host-sidebar/preferences.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are legacy fields, kept here read-only for backward compatibility; the authoritative copy is GET /host-sidebar/preferences. foldersCollapsed is likewise legacy and read-only; its authoritative copy is the snippets plugin's own user settings.
* tags:
* - User Preferences
* responses:
@@ -160,7 +160,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /user-preferences:
* put:
* summary: Update preferences for the current user
* description: showHostTags, hostTrayOnClick, compactHostView, statusColorScheme and foldersCollapsed are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign.
* description: showHostTags, hostTrayOnClick, compactHostView and statusColorScheme are no longer accepted here -- they moved to PUT /host-sidebar/preferences as part of the sidebar redesign. foldersCollapsed is no longer accepted either, now that it is a snippets plugin user setting.
* tags:
* - User Preferences
* requestBody:
@@ -259,11 +259,12 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
rdpDefaults?: string | null;
terminalMacros?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme,
// foldersCollapsed are no longer writable here -- they moved to
// /host-sidebar/preferences as of the sidebar redesign. The columns stay
// in the table (read once as a migration seed by that route) but this
// endpoint silently ignores them if a stale client still sends them.
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
// sidebar redesign. The columns stay in the table (read once as a
// migration seed by that route) but this endpoint silently ignores them if
// a stale client still sends them. foldersCollapsed similarly moved out,
// to the snippets plugin's own user settings.
const updates: UserPreferenceUpdate = {
updatedAt: new Date().toISOString(),
+15 -2
View File
@@ -135,7 +135,11 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
open.add(dispose);
connection.client.once("close", () => open.delete(dispose));
return { ...connection, dispose };
return {
...connection,
host: connection.host as unknown as PluginSshHost,
dispose,
};
} catch (error) {
await audit("ssh_connect", describeHost(host), {
success: false,
@@ -156,6 +160,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
return {
client: connection.client as never,
jumpClient: connection.jumpClient as never,
host: connection.host,
dispose: connection.dispose,
};
},
@@ -201,7 +206,15 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
};
open.add(dispose);
client.once("close", () => open.delete(dispose));
return { client: client as never, jumpClient: null, dispose };
// A jump chain ends at a forwarding client, not a single resolved SSH
// host: report the caller's own host if it gave one, else the last hop.
const host: PluginSshHost = chainOptions?.forHost ?? {
id: jumpHosts[jumpHosts.length - 1]?.hostId ?? 0,
ip: "",
port: 22,
username: "",
};
return { client: client as never, jumpClient: null, host, dispose };
},
poolKey,
+16 -1
View File
@@ -288,7 +288,12 @@ export function createPluginContext(
// write through them. The capability and the audit line are the record
// that it did. See the header of this file.
const schema = await import("../database/db/schema.js");
return { users: schema.users, hosts: schema.hosts };
return {
users: schema.users,
hosts: schema.hosts,
roles: schema.roles,
userRoles: schema.userRoles,
};
},
{ action: "db_refs" },
);
@@ -353,6 +358,16 @@ export function createPluginContext(
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
recordTombstone: async (userId, entityType, syncId) => {
if (!syncId) return;
const { createCurrentSyncTombstoneRepository } =
await import("../database/repositories/factory.js");
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
},
},
kv: {
+7
View File
@@ -40,6 +40,13 @@ export const TOPICS = {
hostDeleted: "host.deleted",
/** Someone logged in over SSH to a host. */
hostLogin: "host.login",
/**
* A user's encrypted data was wiped because their DEK could not be
* recovered on password reset. The user row itself survives, unlike
* user.deleted, so a plugin with a refUser() cascade still needs this:
* the row is not gone, only unrecoverable, and should go with it.
*/
userDataWiped: "user.data_wiped",
} as const;
export type EventListener = (payload: unknown) => void;
@@ -38,10 +38,6 @@ describe("RbacAccessRepository", () => {
INSERT INTO ssh_data (
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password');
INSERT INTO snippets (id, user_id, name, content)
VALUES
(99, 'owner-1', 'deploy', 'echo deploy'),
(100, 'owner-1', 'rollback', 'echo rollback');
INSERT INTO host_access (
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
)
@@ -49,12 +45,6 @@ describe("RbacAccessRepository", () => {
(1, 42, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
(2, 42, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z'),
(5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
INSERT INTO snippet_access (
id, snippet_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
)
VALUES
(3, 99, 'user-1', NULL, 'admin', 'view', NULL, '2026-06-26T00:00:00.000Z'),
(4, 99, NULL, 7, 'admin', 'view', '2026-06-27T00:00:00.000Z', '2026-06-26T01:00:00.000Z');
INSERT INTO shared_host_secrets (
id, host_access_id, target_user_id, protocol, source_type, original_credential_id, encrypted_username, encrypted_auth_type
)
@@ -95,29 +85,6 @@ describe("RbacAccessRepository", () => {
]);
});
it("lists snippet access with user and role target metadata", async () => {
const repo = await createRepository();
const accessList = await repo.listSnippetAccess(99);
expect(accessList.map((access) => access.targetType)).toEqual([
"role",
"user",
]);
expect(accessList[0]).toMatchObject({
id: 4,
roleId: 7,
roleName: "ops",
grantedByUsername: "admin",
});
expect(accessList[1]).toMatchObject({
id: 3,
userId: "user-1",
username: "alice",
grantedByUsername: "admin",
});
});
it("lists shared hosts for direct and role access", async () => {
const repo = await createRepository();
@@ -253,67 +220,6 @@ describe("RbacAccessRepository", () => {
expect(writeCount).toBe(1);
});
it("lists shared snippets and preserves route-level direct-over-role behavior", async () => {
const repo = await createRepository();
const sharedSnippets = await repo.listSharedSnippets(
"user-1",
[7],
activeAccessTime,
);
expect(sharedSnippets).toHaveLength(1);
expect(sharedSnippets[0]).toMatchObject({
id: 99,
name: "deploy",
ownerUsername: "owner",
permissionLevel: "view",
});
});
it("lists visible shared snippets for the main snippets route", async () => {
const repo = await createRepository();
const sharedSnippets = await repo.listVisibleSharedSnippets(
"user-1",
[7],
activeAccessTime,
);
expect(sharedSnippets.map((snippet) => snippet.id)).toEqual([99, 99]);
expect(sharedSnippets[0]).toMatchObject({
userId: "owner-1",
name: "deploy",
content: "echo deploy",
ownerUsername: "owner",
});
});
it("finds an accessible shared snippet for direct or role access", async () => {
const repo = await createRepository();
await expect(
repo.findAccessibleSharedSnippet(99, "user-2", [7], activeAccessTime),
).resolves.toMatchObject({
id: 99,
userId: "owner-1",
name: "deploy",
content: "echo deploy",
ownerUsername: "owner",
permissionLevel: "view",
hostFilter: null,
});
await expect(
repo.findAccessibleSharedSnippet(
99,
"user-2",
[7],
"2026-06-28T00:00:00.000Z",
),
).resolves.toBeNull();
});
it("upserts host access and updates overrides", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
@@ -432,39 +338,4 @@ describe("RbacAccessRepository", () => {
expect(await repo.deleteHostAccessForUserReferences("admin")).toBe(0);
expect(writeCount).toBe(1);
});
it("upserts and revokes snippet access", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertSnippetAccess({
snippetId: 99,
targetType: "user",
targetUserId: "user-1",
grantedBy: "admin",
expiresAt: "2026-06-28T00:00:00.000Z",
});
expect(updated).toEqual({ id: 3, created: false });
expect(
(await repo.listSnippetAccess(99)).find((row) => row.id === 3),
).toMatchObject({ expiresAt: "2026-06-28T00:00:00.000Z" });
const created = await repo.upsertSnippetAccess({
snippetId: 100,
targetType: "role",
targetRoleId: 7,
grantedBy: "admin",
expiresAt: null,
});
expect(created.created).toBe(true);
await repo.revokeSnippetAccess(3, 99);
expect((await repo.listSnippetAccess(99)).map((row) => row.id)).toEqual([
4,
]);
expect(writeCount).toBe(3);
});
});
@@ -1,356 +0,0 @@
import { sql } from "drizzle-orm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SnippetRepository } from "../../../database/repositories/snippet-repository.js";
describe("SnippetRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(onWrite?: () => void): Promise<{
repository: SnippetRepository;
}> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO snippets (
id, user_id, name, content, description, folder, "order", host_filter
)
VALUES
(1, 'user-1', 'root', 'uptime', NULL, NULL, 2, NULL),
(2, 'user-1', 'deploy', 'make deploy', 'Deploy app', 'ops', 1, 'linux'),
(3, 'user-2', 'other', 'whoami', NULL, NULL, 1, NULL);
INSERT INTO snippet_folders (id, user_id, name, color, icon)
VALUES
(1, 'user-1', 'ops', '#123456', 'terminal'),
(2, 'user-1', 'db', NULL, NULL),
(3, 'user-2', 'other', NULL, NULL);
`);
return {
repository: new SnippetRepository(context, onWrite),
};
}
it("finds owned snippets only", async () => {
const { repository } = await createRepository();
await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({
id: 1,
userId: "user-1",
name: "root",
});
await expect(repository.findOwnedById("user-1", 3)).resolves.toBeNull();
await expect(repository.findOwnedById("user-1", 999)).resolves.toBeNull();
});
it("lists folders by name for a user", async () => {
const { repository } = await createRepository();
const rows = await repository.listFolders("user-1");
expect(rows.map((row) => row.name)).toEqual(["db", "ops"]);
});
it("lists export data for a user", async () => {
const { repository } = await createRepository();
const snippets = await repository.listSnippetsForExport("user-1");
const folders = await repository.listFoldersForExport("user-1");
expect(snippets.map((row) => row.name)).toEqual(["root", "deploy"]);
expect(folders.map((row) => row.name)).toEqual(["db", "ops"]);
});
it("lists owned snippets for route merging", async () => {
const { repository } = await createRepository();
const rows = await repository.listOwnedSnippets("user-1");
expect(rows.map((row) => row.name)).toEqual(["root", "deploy"]);
});
it("reorders snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await repository.reorderSnippets("user-1", [
{ id: 1, order: 9, folder: " ops " },
{ id: 999, order: 1 },
]);
await expect(repository.findOwnedById("user-1", 1)).resolves.toMatchObject({
order: 9,
folder: "ops",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("creates snippets with the next folder order", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const created = await repository.createSnippet("user-1", {
name: " new ",
content: " echo ok ",
description: " desc ",
folder: "ops",
hostFilter: { os: "linux" },
});
expect(created).toMatchObject({
userId: "user-1",
name: "new",
content: "echo ok",
description: "desc",
folder: "ops",
order: 2,
hostFilter: JSON.stringify({ os: "linux" }),
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("updates snippets and returns the original row for audit names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.updateSnippet("user-1", 2, {
name: " deploy new ",
content: " make deploy2 ",
description: null,
folder: null,
order: 7,
hostFilter: null,
});
const missing = await repository.updateSnippet("user-1", 3, {
name: "nope",
});
expect(result?.existing).toMatchObject({ name: "deploy" });
expect(result?.updated).toMatchObject({
name: "deploy new",
content: "make deploy2",
description: null,
folder: null,
order: 7,
hostFilter: null,
});
expect(missing).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes snippets and returns the deleted row for audit names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const deleted = await repository.deleteSnippet("user-1", 2);
const missing = await repository.deleteSnippet("user-1", 3);
expect(deleted).toMatchObject({ id: 2, name: "deploy" });
expect(missing).toBeNull();
await expect(repository.findOwnedById("user-1", 2)).resolves.toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes all snippets and folders for a user", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await expect(repository.deleteByUserId("user-1")).resolves.toEqual({
snippetsDeleted: 2,
foldersDeleted: 2,
});
expect(
await adapter!.query(sql`SELECT id FROM snippets ORDER BY id`),
).toEqual([{ id: 3 }]);
expect(
await adapter!.query(sql`SELECT id FROM snippet_folders ORDER BY id`),
).toEqual([{ id: 3 }]);
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("bulk imports folders and snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.bulkImport(
"user-1",
[
{
name: " new snippet ",
content: " echo hi ",
description: " desc ",
folder: " new folder ",
hostFilter: "linux",
},
{ name: "", content: "bad" },
{ name: "deploy", content: "skip", folder: "ops" },
],
[
{ name: " new folder ", color: " #fff ", icon: " star " },
{ name: "ops" },
{ name: "" },
],
false,
);
expect(result).toEqual({
snippetsImported: 1,
snippetsSkipped: 1,
snippetsUpdated: 0,
foldersImported: 1,
foldersSkipped: 1,
failed: 2,
errors: [
"Folder missing name",
"Snippet 2: name and content are required",
],
});
await expect(repository.findOwnedById("user-1", 4)).resolves.toMatchObject({
name: "new snippet",
content: "echo hi",
description: "desc",
folder: "new folder",
order: 0,
hostFilter: "linux",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("bulk import overwrites existing snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const result = await repository.bulkImport(
"user-1",
[
{
name: "deploy",
content: "make deploy v2",
folder: "ops",
order: 5,
hostFilter: "prod",
},
],
undefined,
true,
);
expect(result).toMatchObject({
snippetsImported: 0,
snippetsSkipped: 0,
snippetsUpdated: 1,
failed: 0,
});
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
content: "make deploy v2",
order: 5,
hostFilter: "prod",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("creates folders and rejects duplicate names", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const created = await repository.createFolder(
"user-1",
" new ",
" #fff ",
" star ",
);
const duplicate = await repository.createFolder(
"user-1",
"ops",
null,
null,
);
expect(created).toMatchObject({
userId: "user-1",
name: "new",
color: "#fff",
icon: "star",
});
expect(duplicate).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("updates folder metadata", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
const updated = await repository.updateFolderMetadata(
"user-1",
"ops",
" #abc ",
undefined,
);
const missing = await repository.updateFolderMetadata(
"user-1",
"missing",
null,
null,
);
expect(updated).toMatchObject({
name: "ops",
color: "#abc",
icon: "terminal",
});
expect(missing).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("renames folders and attached snippets", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await expect(
repository.renameFolder("user-1", "missing", "new"),
).resolves.toEqual({ status: "missing" });
await expect(
repository.renameFolder("user-1", "ops", "db"),
).resolves.toEqual({
status: "conflict",
});
await expect(
repository.renameFolder("user-1", "ops", "deploys"),
).resolves.toEqual({ status: "renamed" });
await expect(repository.listFolders("user-1")).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ name: "deploys" })]),
);
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
folder: "deploys",
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
it("deletes folders and moves snippets to the root", async () => {
const onWrite = vi.fn();
const { repository } = await createRepository(onWrite);
await repository.deleteFolder("user-1", "ops");
expect(
(await repository.listFolders("user-1")).map((row) => row.name),
).toEqual(["db"]);
await expect(repository.findOwnedById("user-1", 2)).resolves.toMatchObject({
folder: null,
});
expect(onWrite).toHaveBeenCalledTimes(1);
});
});
@@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createSnippetExecutionResult,
getSnippetExecutionTimeoutMs,
resolveSnippetCommand,
} from "../../../database/routes/snippets-execution.js";
describe("snippet execution", () => {
it("treats stderr as diagnostic output when the command succeeds", () => {
expect(createSnippetExecutionResult(0, "done\n", "warning\n")).toEqual({
success: true,
output: "done\n",
error: "warning\n",
});
});
it("uses the exit code to report command failure", () => {
expect(createSnippetExecutionResult(1, "", "failed\n")).toEqual({
success: false,
output: "",
error: "failed\n",
});
});
it("preserves the previous fallback when no exit code is available", () => {
expect(createSnippetExecutionResult(null, "done\n", "")).toEqual({
success: true,
output: "done\n",
});
expect(createSnippetExecutionResult(null, "", "failed\n").success).toBe(
false,
);
});
it("disables the command timeout by default", () => {
expect(getSnippetExecutionTimeoutMs(undefined)).toBeUndefined();
});
it("converts a configured timeout from seconds to milliseconds", () => {
expect(getSnippetExecutionTimeoutMs("45")).toBe(45_000);
});
it.each(["", "0", "-1", "invalid"])(
"ignores invalid timeout value %j",
(value) => {
expect(getSnippetExecutionTimeoutMs(value)).toBeUndefined();
},
);
});
describe("resolveSnippetCommand", () => {
const host = { ip: "10.0.0.5", username: "root", port: 22, name: "web-01" };
it("substitutes host variables per target host", () => {
expect(
resolveSnippetCommand("ssh $USER@$HOST -p $PORT # $NAME", host),
).toBe("ssh root@10.0.0.5 -p 22 # web-01");
});
it("supports brace syntax for host variables", () => {
expect(resolveSnippetCommand("ping ${HOST}", host)).toBe("ping 10.0.0.5");
});
it("substitutes input placeholders from inputValues", () => {
expect(
resolveSnippetCommand("nc -zv $HOST ${INPUT_1:Port}", host, {
INPUT_1: "8080",
}),
).toBe("nc -zv 10.0.0.5 8080");
});
it("leaves host variables literal when no host context is given", () => {
expect(resolveSnippetCommand("ping $HOST", null)).toBe("ping $HOST");
});
it("leaves input placeholders literal when no value was supplied", () => {
expect(resolveSnippetCommand("echo $INPUT_1", null)).toBe("echo $INPUT_1");
});
});
@@ -1,20 +0,0 @@
import fs from "fs";
import path from "path";
import { describe, expect, it } from "vitest";
describe("snippet execution host access", () => {
it("uses the shared-host-aware resolver", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../../database/routes/snippets.ts"),
"utf8",
);
const executeRoute = source.slice(
source.indexOf('router.post(\n "/execute"'),
);
expect(executeRoute).toContain(
"const host = await resolveHostById(parseInt(hostId), userId)",
);
expect(executeRoute).not.toContain("host.userId !== userId");
});
});
@@ -33,8 +33,6 @@ describe("isValidEntityType", () => {
"hosts",
"sshCredentials",
"sshFolders",
"snippets",
"snippetFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
@@ -88,7 +86,7 @@ describe("stripWritePayload", () => {
it("does not mutate the original payload object", () => {
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
stripWritePayload("snippets", payload);
stripWritePayload("sshFolders", payload);
expect(payload).toEqual({
id: 1,
userId: "user-1",
@@ -6,6 +6,7 @@ const calls = vi.hoisted(() => ({
deletedFor: [] as string[],
rotatedFor: [] as string[],
legacyWrapsDeletedFor: [] as string[],
dataWipedEvents: [] as Array<{ userId: string }>,
}));
function deletingRepo(label: string) {
@@ -29,11 +30,19 @@ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentSnippetRepository: deletingRepo("snippets"),
createCurrentHostRepository: deletingRepo("hosts"),
createCurrentCredentialRepository: deletingRepo("credentials"),
}));
vi.mock("../../../plugins/events.js", () => ({
pluginEvents: {
emit: (_topic: string, payload: { userId: string }) => {
calls.dataWipedEvents.push(payload);
},
},
TOPICS: { userDataWiped: "user.data_wiped" },
}));
vi.mock("../../../utils/user-keys.js", () => ({
UserKeyManager: {
getInstance: () => ({
@@ -65,6 +74,7 @@ beforeEach(() => {
calls.deletedFor = [];
calls.rotatedFor = [];
calls.legacyWrapsDeletedFor = [];
calls.dataWipedEvents = [];
});
describe("resetUserPassword", () => {
@@ -110,10 +120,10 @@ describe("resetUserPassword", () => {
"bookmarks:user-1",
"activity:user-1",
"alerts:user-1",
"snippets:user-1",
"hosts:user-1",
"credentials:user-1",
]);
expect(calls.dataWipedEvents).toEqual([{ userId: "user-1" }]);
expect(calls.rotatedFor).toEqual(["user-1"]);
expect(calls.legacyWrapsDeletedFor).toEqual(["user-1"]);
expect(
+8 -1
View File
@@ -60,6 +60,8 @@ vi.mock("../../utils/database-save-trigger.js", () => ({
vi.mock("../../database/db/schema.js", () => ({
users: { marker: "users" },
hosts: { marker: "hosts" },
roles: { marker: "roles" },
userRoles: { marker: "userRoles" },
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
@@ -195,7 +197,12 @@ describe("ctx.db capability", () => {
const refs = (await ctx.db.refs()) as Record<string, unknown>;
expect(Object.keys(refs).sort()).toEqual(["hosts", "users"]);
expect(Object.keys(refs).sort()).toEqual([
"hosts",
"roles",
"userRoles",
"users",
]);
});
});
@@ -384,8 +384,18 @@ describe("shipped manifests keep their permission ids", () => {
expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]);
});
it("snippets", () => {
expect(idsFor("snippets")).toEqual([
"snippets.view",
"snippets.create",
"snippets.edit",
"snippets.delete",
"snippets.share",
]);
});
it("a role holding the old ids still validates once registered", () => {
for (const pluginId of ["ai", "automations", "tailscale"]) {
for (const pluginId of ["ai", "automations", "tailscale", "snippets"]) {
const ids = idsFor(pluginId);
registerPluginPermissions({
group: pluginId,
@@ -402,6 +412,8 @@ describe("shipped manifests keep their permission ids", () => {
"automations.run",
"automations.view",
"tailscale.devices.view",
"snippets.view",
"snippets.share",
];
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
@@ -96,19 +96,26 @@ describe("core sync entities", () => {
registerCoreSyncEntities();
});
// The frozen array also lists networkTopology and snippets/snippetFolders,
// now registered by the network-topology and snippets plugins rather than
// core.
const PLUGIN_OWNED_TYPES = new Set([
"networkTopology",
"snippets",
"snippetFolders",
]);
it("registers every core-owned entity the Electron client knows about", () => {
// The frozen array also lists networkTopology, which is now registered
// by the network-topology plugin rather than core.
const coreTypes = new Set(listEntityTypes());
for (const type of SYNCED_ENTITY_TYPES) {
if (type === "networkTopology") continue;
if (PLUGIN_OWNED_TYPES.has(type)) continue;
expect(coreTypes.has(type), type).toBe(true);
}
});
it("keeps the dependency order the frozen Electron array encodes", () => {
expect(listEntityTypes()).toEqual(
[...SYNCED_ENTITY_TYPES].filter((type) => type !== "networkTopology"),
[...SYNCED_ENTITY_TYPES].filter((type) => !PLUGIN_OWNED_TYPES.has(type)),
);
});
+1 -1
View File
@@ -75,7 +75,7 @@ const ADMIN_TARGET_USER_HEADER = "x-admin-target-user";
const IMPERSONATION_PATH_ALLOWLIST = [
/^\/host\/db\//,
/^\/credentials(\/|$)/,
/^\/snippets(\/|$)/,
/^\/plugin-api\/snippets(\/|$)/,
];
class AuthManager {
+1 -12
View File
@@ -34,17 +34,6 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"hosts.share",
],
},
{
group: "snippets",
labelKey: "admin.rolePermissions.groups.snippets",
permissions: [
"snippets.view",
"snippets.create",
"snippets.edit",
"snippets.delete",
"snippets.share",
],
},
{
group: "credentials",
labelKey: "admin.rolePermissions.groups.credentials",
@@ -183,6 +172,6 @@ export const SYSTEM_ROLE_DEFAULTS = {
},
user: {
description: "Regular user",
permissions: ["hosts.*", "snippets.*", "credentials.*"],
permissions: ["hosts.*", "credentials.*"],
},
} as const;