Merge pull request #366 from Portabase/fix/storage-encryption

fix: storage-encryption
This commit is contained in:
Charles GTE
2026-07-11 09:05:45 +02:00
committed by GitHub
27 changed files with 691 additions and 188 deletions
+1 -1
View File
@@ -33,5 +33,5 @@ keywords:
- web-ui
- agent
license: Apache-2.0
version: 1.22.2
version: 1.22.4
date-released: '2026-03-02'
@@ -2,7 +2,7 @@ import { PageParams } from "@/types/next";
import { notFound, redirect } from "next/navigation";
import { Page } from "@/features/layout/components/page";
import { db } from "@/db";
import { eq, and, inArray } from "drizzle-orm";
import { eq, and, inArray, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { getOrganizationProjectDatabases } from "@/db/services/project";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
@@ -49,38 +49,22 @@ export default async function RoutePage(
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true,
},
},
logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true,
},
orderBy: (r, { desc }) => [desc(r.createdAt)],
});
//const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length);
const availableBackups = backups.filter((b) => !b.deletedAt).length;
const availableBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
isNull(drizzleDb.schemas.backup.deletedAt),
),
)
.then((rows) => rows.length);
const successfulBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
@@ -93,6 +77,17 @@ export default async function RoutePage(
)
.then((rows) => rows.length);
const isAlreadyRestore = await db
.select({ count: drizzleDb.schemas.restoration.id })
.from(drizzleDb.schemas.restoration)
.where(
and(
eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
eq(drizzleDb.schemas.restoration.status, "waiting"),
),
)
.then((rows) => rows.length > 0);
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
@@ -109,8 +104,6 @@ export default async function RoutePage(
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
//const isMember = activeMember?.role === "member";
return (
<Page>
<LogsModalProvider>
@@ -121,8 +114,6 @@ export default async function RoutePage(
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
+49 -2
View File
@@ -12,12 +12,20 @@ import {logger} from "@/lib/logger";
import {isUUID} from "@/utils/text";
import {StorageInput} from "@/features/storages/types";
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
import {getMasterServerKeyContent} from "@/features/agents/actions/keys.action";
import {encryptStorages, isAgentVersionAtLeast, MIN_AGENT_VERSION_STORAGE_ENC} from "@/utils/status-crypto";
const log = logger.child({module: "api/agent/status/helpers"});
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
const databasesResponse = [];
const masterKeyResult = await getMasterServerKeyContent();
const masterKey = Buffer.isBuffer(masterKeyResult) ? masterKeyResult : null;
if (!masterKey) {
log.error({name: "handleDatabases"}, "Master key unavailable; storages will be sent in plaintext");
}
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null, backupSize: number | null) => ({
generatedId: database.agentDatabaseId,
dbms: database.dbms,
@@ -89,7 +97,9 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
const storages = await getDatabaseStorageChannels(databaseCreated.id)
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null));
const entry = formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null);
applyStorageEncryption(entry, body.version, masterKey, agent.id);
databasesResponse.push(entry);
}
} else {
@@ -209,7 +219,9 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize));
const entry = formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize);
applyStorageEncryption(entry, body.version, masterKey, agent.id);
databasesResponse.push(entry);
}
}
return databasesResponse;
@@ -277,3 +289,38 @@ async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatab
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
}
function applyStorageEncryption(
entry: Record<string, any>,
version: string | undefined,
masterKey: Buffer | null,
agentId: string,
): void {
if (!masterKey) return;
if (!Array.isArray(entry.storages) || entry.storages.length === 0) return;
if (!isAgentVersionAtLeast(version, MIN_AGENT_VERSION_STORAGE_ENC)) {
log.warn(
{
name: "applyStorageEncryption",
agentId,
agentVersion: version ?? "unknown",
requiredVersion: MIN_AGENT_VERSION_STORAGE_ENC,
},
`\n============================================================\n` +
` ⚠️ OUTDATED AGENT — STORAGE CREDENTIALS SENT UNENCRYPTED\n` +
` Agent ${agentId} reports v${version ?? "unknown"} (< required v${MIN_AGENT_VERSION_STORAGE_ENC}).\n` +
` Update this agent to v${MIN_AGENT_VERSION_STORAGE_ENC}+ to encrypt storage credentials in transit.\n` +
`============================================================`,
);
return;
}
try {
const ciphertext = encryptStorages(entry.storages, masterKey);
entry.storages_ciphertext = ciphertext;
entry.storages_encrypted = true;
entry.storages = [];
} catch (err) {
log.error({error: err, name: "applyStorageEncryption"}, "Storage encryption failed; sending plaintext");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.22.2",
"version": "1.22.4",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
+38 -11
View File
@@ -9,6 +9,8 @@ import {
SortingState,
ColumnFiltersState,
getFilteredRowModel,
PaginationState,
OnChangeFn,
} from "@tanstack/react-table";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
@@ -42,6 +44,13 @@ interface DataTableProps<TData, TValue> {
};
highlightRow?: (row: TData) => boolean;
selectedActions?: (rows: TData[]) => ReactNode;
manualPagination?: boolean;
rowCount?: number;
paginationState?: { pageIndex: number; pageSize: number };
onPaginationChange?: OnChangeFn<PaginationState>;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
isFetching?: boolean;
}
@@ -56,10 +65,19 @@ export function DataTable<TData, TValue>({
emptyButton,
highlightRow,
selectedActions,
manualPagination = false,
rowCount,
paginationState,
onPaginationChange,
sorting: controlledSorting,
onSortingChange: controlledOnSortingChange,
isFetching = false,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const sorting = controlledSorting ?? internalSorting;
const setSorting = controlledOnSortingChange ?? setInternalSorting;
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
@@ -96,7 +114,7 @@ export function DataTable<TData, TValue>({
data,
columns: finalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
@@ -106,10 +124,15 @@ export function DataTable<TData, TValue>({
autoResetPageIndex: false,
autoResetExpanded: false,
enableRowSelection: true,
manualPagination,
manualSorting: manualPagination,
rowCount: manualPagination ? rowCount : undefined,
onPaginationChange: manualPagination ? onPaginationChange : undefined,
state: {
sorting,
columnFilters,
rowSelection,
...(manualPagination && paginationState ? { pagination: paginationState } : {}),
},
});
const router = useRouter();
@@ -194,18 +217,22 @@ export function DataTable<TData, TValue>({
selected.
</div>
)}
{enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
{enablePagination && (manualPagination ? (rowCount ?? 0) > 0 : table.getFilteredRowModel().rows.length >= 1) && (
<TablePagination
table={table}
maxVisiblePages={paginationOptions?.pageVisible}
pageSizeOptions={(() => {
const rowCount = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCount);
const nextSize = allSizes.find((size) => size > rowCount);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()}
pageSizeOptions={
manualPagination
? paginationOptions.pageSize
: (() => {
const rowCountLocal = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCountLocal);
const nextSize = allSizes.find((size) => size > rowCountLocal);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()
}
className={paginationOptions.className}
/>
)}
+2
View File
@@ -142,10 +142,12 @@ export type BackupWith = Backup & {
restorations?: Restoration[] | null;
storages?: BackupStorage[] | null;
logs?: JobLog[] | null;
hasLogs?: boolean;
};
export type RestorationWith = Restoration & {
logs?: JobLog[] | null;
hasLogs?: boolean;
};
@@ -2,9 +2,8 @@
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {db} from "@/db";
import {and, eq, isNull} from "drizzle-orm";
import {and, count, eq, inArray, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
@@ -31,33 +30,50 @@ export const getDatabaseDataAction = userAction
}
});
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[];
const [totalRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, databaseId));
const totalBackups = totalRow?.count ?? 0;
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
}) as RestorationWith[];
const [availableRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
isNull(drizzleDb.schemas.backup.deletedAt),
));
const availableBackups = availableRow?.count ?? 0;
const [successRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
eq(drizzleDb.schemas.backup.status, "success"),
));
const successfulBackups = successRow?.count ?? 0;
const totalBackups = backups.length;
const availableBackups = backups.filter(b => !b.deletedAt).length;
const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const [activeBackupRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]),
));
const isAlreadyBackup = (activeBackupRow?.count ?? 0) > 0;
const [activeRestoreRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.restoration)
.where(and(
eq(drizzleDb.schemas.restoration.databaseId, databaseId),
eq(drizzleDb.schemas.restoration.status, "waiting"),
));
const isAlreadyRestore = (activeRestoreRow?.count ?? 0) > 0;
// @ts-ignore
let activeOrganizationChannels = [];
// @ts-ignore
@@ -71,11 +87,8 @@ export const getDatabaseDataAction = userAction
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
}
return {
database,
backups,
restorations,
// @ts-ignore
activeOrganizationChannels,
// @ts-ignore
@@ -85,6 +98,8 @@ export const getDatabaseDataAction = userAction
availableBackups,
successRate
},
health: database ? await getHealthLast12hLogs({ id: database.id }) : []
isAlreadyRestore,
isAlreadyBackup,
health: database ? await getHealthLast12hLogs({id: database.id}) : []
};
});
});
@@ -0,0 +1,120 @@
"use server";
import { userAction } from "@/lib/safe-actions/actions";
import { db } from "@/db";
import { and, count, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { BackupWith, RestorationWith } from "@/db/schema/07_database";
import {
FetchBackupsSchema,
FetchRestorationsSchema,
} from "@/features/database/actions/backup-list.schema";
export const fetchBackupsAction = userAction
.schema(FetchBackupsSchema)
.action(async ({ parsedInput }) => {
const { databaseId, page, pageSize, filter } = parsedInput;
const offset = (page - 1) * pageSize;
const deletedCondition =
filter === "available"
? isNull(drizzleDb.schemas.backup.deletedAt)
: filter === "deleted"
? isNotNull(drizzleDb.schemas.backup.deletedAt)
: undefined;
const where = and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
deletedCondition,
);
const [totalResult] = await db
.select({ count: count() })
.from(drizzleDb.schemas.backup)
.where(where);
const data = (await db.query.backup.findMany({
where,
with: {
restorations: true,
storages: {
with: {
storageChannel: true,
},
},
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
limit: pageSize,
offset,
})) as BackupWith[];
const backupIds = data.map((b) => b.id);
const backupsWithLogs = backupIds.length
? await db
.select({ backupId: drizzleDb.schemas.jobLog.backupId })
.from(drizzleDb.schemas.jobLog)
.where(inArray(drizzleDb.schemas.jobLog.backupId, backupIds))
.groupBy(drizzleDb.schemas.jobLog.backupId)
: [];
const loggedBackupIds = new Set(backupsWithLogs.map((l) => l.backupId));
for (const b of data) {
b.hasLogs = loggedBackupIds.has(b.id);
}
const total = totalResult?.count ?? 0;
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
};
});
export const fetchRestorationsAction = userAction
.schema(FetchRestorationsSchema)
.action(async ({ parsedInput }) => {
const { databaseId, page, pageSize } = parsedInput;
const offset = (page - 1) * pageSize;
const where = eq(drizzleDb.schemas.restoration.databaseId, databaseId);
const [totalResult] = await db
.select({ count: count() })
.from(drizzleDb.schemas.restoration)
.where(where);
const data = (await db.query.restoration.findMany({
where,
orderBy: (r, { desc }) => [desc(r.createdAt)],
limit: pageSize,
offset,
})) as RestorationWith[];
const restorationIds = data.map((r) => r.id);
const restorationsWithLogs = restorationIds.length
? await db
.select({ restorationId: drizzleDb.schemas.jobLog.restorationId })
.from(drizzleDb.schemas.jobLog)
.where(inArray(drizzleDb.schemas.jobLog.restorationId, restorationIds))
.groupBy(drizzleDb.schemas.jobLog.restorationId)
: [];
const loggedRestorationIds = new Set(
restorationsWithLogs.map((l) => l.restorationId),
);
for (const r of data) {
r.hasLogs = loggedRestorationIds.has(r.id);
}
const total = totalResult?.count ?? 0;
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
};
});
@@ -0,0 +1,18 @@
import { z } from "zod";
export const FetchBackupsSchema = z.object({
databaseId: z.string(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
filter: z.enum(["available", "deleted"]).optional(),
sorting: z.any().optional(),
});
export type FetchBackupsSchema = z.infer<typeof FetchBackupsSchema>;
export const FetchRestorationsSchema = z.object({
databaseId: z.string(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
sorting: z.any().optional(),
});
export type FetchRestorationsSchema = z.infer<typeof FetchRestorationsSchema>;
@@ -101,6 +101,12 @@ export const BackupActionsForm = ({
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["restorations", backup.databaseId],
});
router.refresh();
if (action === "download") {
const url = inner.value;
@@ -121,6 +127,9 @@ export const BackupActionsForm = ({
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", backup.databaseId],
});
router.refresh();
closeModal();
} else {
@@ -144,6 +153,9 @@ export const BackupActionsForm = ({
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", backup.databaseId],
});
router.refresh();
closeModal();
} else {
@@ -29,6 +29,9 @@ export const BackupButton = (props: BackupButtonProps) => {
queryClient.invalidateQueries({
queryKey: ["database-data", props.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", props.databaseId],
});
router.refresh();
} else {
toast.error(backup?.serverError || "Failed to create backup.");
@@ -112,8 +112,7 @@ export function backupColumns(
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
return <LogsModalTrigger backupId={row.original.id} hasLogs={row.original.hasLogs}/>;
},
},
{
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
@@ -12,6 +12,7 @@ export const AdvancedCronSelect = ({
value,
defaultValue,
onValueChange,
onValidityChange,
}: {
id: string;
label: string;
@@ -20,11 +21,21 @@ export const AdvancedCronSelect = ({
value: string;
defaultValue: string;
onValueChange: (value: string) => void;
onValidityChange?: (valid: boolean) => void;
}) => {
const [isAdvanced, setIsAdvanced] = useState(false);
const [customValue, setCustomValue] = useState(defaultValue || value);
const [customValue, setCustomValue] = useState(value || defaultValue);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setCustomValue(value || defaultValue);
}, [value, defaultValue]);
useEffect(() => {
const valid = !isAdvanced || isValidCronPart(type, customValue);
onValidityChange?.(valid);
}, [isAdvanced, customValue, type, onValidityChange]);
const handleBlur = () => {
if (customValue.trim() === "") {
setIsAdvanced(false);
@@ -46,7 +57,7 @@ export const AdvancedCronSelect = ({
// @ts-ignore
id={id}
className="col-span-4"
value={defaultValue}
value={value}
onValueChange={(value: string) => {
if (value === "advanced") {
setIsAdvanced(true);
@@ -2,7 +2,7 @@ import {AdvancedCronSelect} from "@/features/database/components/cron-advanced-s
import {updateDatabaseBackupPolicyAction} from "@/features/database/actions/cron.action";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {useCallback, useState} from "react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
@@ -14,10 +14,17 @@ export type CronInputProps = {
};
export const CronInput = ({database, onSuccess}: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const [cron, setCron] = useState<string>(database.backupPolicy ?? "0 0 * * *");
const [fieldValidity, setFieldValidity] = useState<Record<string, boolean>>({});
const queryClient = useQueryClient();
const router = useRouter();
const setFieldValid = useCallback((id: string) => (valid: boolean) => {
setFieldValidity((prev) => (prev[id] === valid ? prev : {...prev, [id]: valid}));
}, []);
const hasInvalidField = Object.values(fieldValidity).some((valid) => !valid);
const updateBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
onSuccess: () => {
@@ -48,38 +55,42 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
<AdvancedCronSelect
id="minute"
label="Minute"
options={Array.from({length: 60}, (_, i) => String(i).padStart(2, "0"))}
options={Array.from({length: 60}, (_, i) => String(i))}
type="minute"
value={cron.split(" ")[0]}
defaultValue={cron.split(" ")[0]}
onValueChange={(value) => handleChangeCron("minute", value)}
onValidityChange={setFieldValid("minute")}
/>
<AdvancedCronSelect
id="hour"
label="Hour"
options={Array.from({length: 24}, (_, i) => String(i).padStart(2, "0"))}
options={Array.from({length: 24}, (_, i) => String(i))}
type="hour"
value={cron.split(" ")[1]}
defaultValue={cron.split(" ")[1]}
onValueChange={(value) => handleChangeCron("hour", value)}
onValidityChange={setFieldValid("hour")}
/>
<AdvancedCronSelect
id="day-of-month"
label="Day of Month"
options={Array.from({length: 31}, (_, i) => String(i + 1).padStart(2, "0"))}
options={Array.from({length: 31}, (_, i) => String(i + 1))}
type="day-of-month"
value={cron.split(" ")[2]}
defaultValue={cron.split(" ")[2]}
onValueChange={(value) => handleChangeCron("day-of-month", value)}
onValidityChange={setFieldValid("day-of-month")}
/>
<AdvancedCronSelect
id="month"
label="Month"
options={["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]}
options={Array.from({length: 12}, (_, i) => String(i + 1))}
type="month"
value={cron.split(" ")[3]}
defaultValue={cron.split(" ")[3]}
onValueChange={(value) => handleChangeCron("month", value)}
onValidityChange={setFieldValid("month")}
/>
<AdvancedCronSelect
id="day-of-week"
@@ -89,6 +100,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
value={cron.split(" ")[4]}
defaultValue={cron.split(" ")[4]}
onValueChange={(value) => handleChangeCron("day-of-week", value)}
onValidityChange={setFieldValid("day-of-week")}
/>
<Separator/>
<div className="grid gap-2">
@@ -113,6 +125,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
onClick={async () => {
await handleUpdateCron(cron);
}}
disabled={hasInvalidField || updateBackupPolicy.isPending}
>
Save cron
</Button>
@@ -13,23 +13,22 @@ import {toast} from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization";
import {deleteBackupAction} from "@/features/database/actions/backup-actions.action";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useServerDataTable} from "@/hooks/use-server-data-table";
import {fetchBackupsAction} from "@/features/database/actions/backup-list.action";
import type {FetchBackupsSchema} from "@/features/database/actions/backup-list.schema";
type DatabaseBackupListProps = {
isAlreadyRestore: boolean;
settings: Setting;
database: DatabaseWith;
backups: BackupWith[];
activeMember: MemberWithUser
activeMember: MemberWithUser;
}
export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
const items = [
{label: "Deleted", value: "deleted"},
{label: "Available", value: "available"},
]
];
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
const [isActionsOpen, setIsActionsOpen] = useState(false);
@@ -39,20 +38,19 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
return backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember);
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
const filteredBackups = useMemo(() => {
if (!props.backups) return [];
return props.backups.filter(backup => {
if (selectedFilters.length > 0) {
const selectedValues = selectedFilters.map(f => f.value);
const status = backup.deletedAt != null ? "deleted" : "available";
if (!selectedValues.includes(status)) return false;
}
return true;
});
}, [props.backups, selectedFilters]);
const filter = useMemo<"available" | "deleted" | undefined>(() => {
const values = selectedFilters.map((f) => f.value);
if (values.length === 1) return values[0] as "available" | "deleted";
return undefined;
}, [selectedFilters]);
const {data, tableProps, isFetching, isLoading} = useServerDataTable<BackupWith, FetchBackupsSchema>({
fetchAction: fetchBackupsAction,
queryKey: ["backups", props.database.id],
extraParams: {databaseId: props.database.id, filter},
initialPageSize: 20,
refetchInterval: 4000,
});
const handleSelectFilter = (item: FilterItem) => {
setSelectedFilters(prev =>
@@ -64,29 +62,26 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
const clearFilters = () => setSelectedFilters([]);
const mutationDeleteBackups = useMutation({
mutationFn: async (backups: Backup[]) => {
const results = await Promise.all(
backups.map(async (backup) => {
if (backup.deletedAt == null || backup.status == "ongoing") {
const backupDeleted = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
})
});
return {
success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success
? backupDeleted?.data?.actionSuccess?.message
// @ts-ignore
: restoration?.data?.actionError.message,
: "Failed to delete backup.",
};
}
return {
success: true,
message: `Already deleted this backup (ref: ${backup.id}).`,
}
};
})
);
results.forEach((result) => {
@@ -96,7 +91,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
toast.error(result.message);
}
});
queryClient.invalidateQueries({queryKey: ["database-data", props.database.id]});
queryClient.invalidateQueries({queryKey: ["backups", props.database.id]});
},
});
@@ -106,8 +101,10 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
<DataTable
enableSelect={!isMember}
columns={columns}
data={filteredBackups}
data={data}
enablePagination
isFetching={isFetching || isLoading}
{...tableProps}
selectedActions={(rows) => (
<>
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
@@ -156,9 +153,8 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
/>
</div>
</div>
</>
)}
/>
)
}
);
};
@@ -2,7 +2,7 @@
import {DatabaseBackupActionsModal} from "@/features/database/components/backup-actions-modal";
import {DatabaseTabs} from "@/features/database/components/database-tabs";
import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database";
import {DatabaseWith} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/features/database/components/backup-modal-context";
import {DatabaseKpi} from "@/features/database/components/database-kpi";
@@ -27,8 +27,6 @@ import {LogsModal} from "@/features/logs/components/logs-modal";
export type DatabaseContentProps = {
settings: Setting;
backups: BackupWith[];
restorations: RestorationWith[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
@@ -53,14 +51,11 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels:
props.activeOrganizationStorageChannels,
@@ -69,16 +64,16 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups: props.availableBackups,
successRate: props.successRate,
},
isAlreadyRestore: props.isAlreadyRestore,
isAlreadyBackup: false,
health: props.databaseHealthLogs
},
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
refetchInterval: 4000,
});
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels =
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels =
@@ -90,10 +85,8 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
successRate: props.successRate,
};
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some(
(b) => b.status === "waiting" || b.status === "ongoing",
);
const isAlreadyRestore = data?.isAlreadyRestore ?? props.isAlreadyRestore;
const isAlreadyBackup = data?.isAlreadyBackup ?? false;
const isMember = props.activeMember.role === "member";
@@ -166,8 +159,6 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
@@ -4,18 +4,19 @@ import {restoreColumns} from "@/features/database/components/restore-columns";
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import {MoreHorizontal, Trash2} from "lucide-react";
import {Restoration} from "@/db/schema/07_database";
import {Restoration, RestorationWith} from "@/db/schema/07_database";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {deleteRestoreAction} from "@/features/database/actions/restore.action";
import {toast} from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useMemo, useState} from "react";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useServerDataTable} from "@/hooks/use-server-data-table";
import {fetchRestorationsAction} from "@/features/database/actions/backup-list.action";
import type {FetchRestorationsSchema} from "@/features/database/actions/backup-list.schema";
type DatabaseRestoreListProps = {
isAlreadyRestore: boolean;
restorations: Restoration[];
activeMember: MemberWithUser;
databaseId: string;
}
@@ -28,6 +29,14 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
return restoreColumns(props.isAlreadyRestore, props.activeMember);
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
const {data, tableProps, isFetching, isLoading} = useServerDataTable<RestorationWith, FetchRestorationsSchema>({
fetchAction: fetchRestorationsAction,
queryKey: ["restorations", props.databaseId],
extraParams: {databaseId: props.databaseId},
initialPageSize: 20,
refetchInterval: 4000,
});
const mutationDeleteRestorations = useMutation({
mutationFn: async (restorations: Restoration[]) => {
const results = await Promise.all(
@@ -42,8 +51,6 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
// @ts-ignore
: restorationDeleted?.data?.actionError.message,
};
})
);
results.forEach((result) => {
@@ -53,18 +60,19 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
toast.error(result.message);
}
});
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
queryClient.invalidateQueries({queryKey: ["restorations", props.databaseId]});
},
});
const isMember = props.activeMember.role === "member";
return (
<DataTable
enableSelect={!isMember}
columns={columns}
data={props.restorations}
data={data}
enablePagination
isFetching={isFetching || isLoading}
{...tableProps}
selectedActions={(rows) => (
<>
{!isMember && (
@@ -84,7 +92,7 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
>Actions</ButtonWithLoading>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<ButtonWithConfirm
<ButtonWithConfirm
onConfirm={() => {
mutationDeleteRestorations.mutate(rows)
setIsActionsOpen(false);
@@ -110,5 +118,5 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
</>
)}
/>
)
}
);
};
@@ -2,8 +2,8 @@
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {useSearchParams} from "next/navigation";
import {DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
import {DatabaseBackupList} from "@/features/database/components/database-backup-list";
import {DatabaseRestoreList} from "@/features/database/components/database-restore-list";
@@ -11,8 +11,6 @@ import {MemberWithUser} from "@/db/schema/03_organization";
export type DatabaseTabsProps = {
settings: Setting,
backups: BackupWith[],
restorations: Restoration[],
isAlreadyRestore: boolean,
database: DatabaseWith,
activeMember: MemberWithUser
@@ -21,24 +19,27 @@ export type DatabaseTabsProps = {
export const backupOnly = ["redis", "valkey"];
export const DatabaseTabs = (props: DatabaseTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "backup";
// eslint-disable-next-line react-hooks/set-state-in-effect
setTab(newTab);
}, [searchParams]);
const onPopState = () => {
const params = new URLSearchParams(window.location.search);
setTab(params.get("tab") ?? "backup");
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
setTab(value);
const params = new URLSearchParams(window.location.search);
params.set("tab", value);
window.history.pushState(null, "", `?${params.toString()}`);
};
const isBackupOnly = backupOnly.some((type) => props.database.dbms === type)
const isBackupOnly = backupOnly.some((type) => props.database.dbms === type);
return (
<>
@@ -47,7 +48,6 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
isAlreadyRestore={props.isAlreadyRestore}
settings={props.settings}
database={props.database}
backups={props.backups}
activeMember={props.activeMember}
/>
:
@@ -61,22 +61,18 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
isAlreadyRestore={props.isAlreadyRestore}
settings={props.settings}
database={props.database}
backups={props.backups}
activeMember={props.activeMember}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="restore">
<DatabaseRestoreList
isAlreadyRestore={props.isAlreadyRestore}
restorations={props.restorations}
activeMember={props.activeMember}
databaseId={props.database.id}
/>
</TabsContent>
</Tabs>
}
</>
);
};
@@ -41,6 +41,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
queryClient.invalidateQueries({queryKey: ["backups", database.id]});
router.refresh();
} else {
toast.error(inner?.actionError?.message);
@@ -50,6 +51,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
toast.error("An error occurred while upload in the backup");
} finally {
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
queryClient.invalidateQueries({queryKey: ["backups", database.id]});
setIsProcessing(false);
}
},
@@ -62,8 +62,7 @@ export function restoreColumns(
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
return <LogsModalTrigger restorationId={row.original.id} hasLogs={row.original.hasLogs}/>;
},
},
{
@@ -86,6 +85,9 @@ export function restoreColumns(
queryClient.invalidateQueries({
queryKey: ["database-data", rowData.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["restorations", rowData.databaseId],
});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
@@ -105,6 +107,9 @@ export function restoreColumns(
queryClient.invalidateQueries({
queryKey: ["database-data", rowData.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["restorations", rowData.databaseId],
});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
@@ -0,0 +1,32 @@
"use server";
import { userAction } from "@/lib/safe-actions/actions";
import { z } from "zod";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { JobLog } from "@/db/schema/17_job-log";
export const fetchJobLogsAction = userAction
.schema(
z.object({
backupId: z.string().optional(),
restorationId: z.string().optional(),
}),
)
.action(async ({ parsedInput }): Promise<JobLog[]> => {
const { backupId, restorationId } = parsedInput;
if (backupId) {
return (await db.query.jobLog.findMany({
where: eq(drizzleDb.schemas.jobLog.backupId, backupId),
orderBy: (l, { asc }) => [asc(l.loggedAt)],
})) as JobLog[];
}
if (restorationId) {
return (await db.query.jobLog.findMany({
where: eq(drizzleDb.schemas.jobLog.restorationId, restorationId),
orderBy: (l, { asc }) => [asc(l.loggedAt)],
})) as JobLog[];
}
return [];
});
@@ -17,7 +17,7 @@ export const LogsModalProvider = ({children}: { children: ReactNode }) => {
const [logs, setLogs] = useState<JobLog[]>([]);
const openModal = (newLogs: JobLog[]) => {
setLogs(newLogs);
setLogs(newLogs ?? []);
setOpen(true);
};
@@ -1,19 +1,35 @@
import {FileText} from "lucide-react";
import {JobLog} from "@/db/schema/17_job-log";
import {FileText, Loader2} from "lucide-react";
import {useState} from "react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {useLogsModal} from "@/features/logs/components/logs-modal-context";
import {fetchJobLogsAction} from "@/features/logs/actions/job-logs.action";
export type LogsModalTriggerProps = {
logs: JobLog[]
backupId?: string;
restorationId?: string;
hasLogs?: boolean;
}
export const LogsModalTrigger = ({logs}: LogsModalTriggerProps) => {
export const LogsModalTrigger = ({backupId, restorationId, hasLogs}: LogsModalTriggerProps) => {
const {openModal} = useLogsModal();
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
const result = await fetchJobLogsAction({backupId, restorationId});
openModal(result?.data ?? []);
} catch {
toast.error("Failed to load logs.");
} finally {
setIsLoading(false);
}
};
return (
<Button disabled={logs.length == 0} variant="outline" size="sm" onClick={()=> {
openModal(logs);
}}>
<FileText />
<Button disabled={!hasLogs || isLoading} variant="outline" size="sm" onClick={handleClick}>
{isLoading ? <Loader2 className="animate-spin"/> : <FileText/>}
</Button>
)
}
);
};
+11 -5
View File
@@ -29,11 +29,17 @@ export const LogsModal = () => {
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div>
{logs.map((entry: JobLog) => (
<LogRow key={entry.id} entry={entry} />
))}
</div>
{logs.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
No logs available
</div>
) : (
<div>
{logs.map((entry: JobLog) => (
<LogRow key={entry.id} entry={entry} />
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
+106
View File
@@ -0,0 +1,106 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { PaginationState, SortingState } from "@tanstack/react-table";
import { useEffect, useState } from "react";
interface PaginatedResult<TData> {
data: TData[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
[key: string]: any;
};
}
interface UseServerDataTableProps<TData, TParams> {
fetchAction: any;
queryKey: any[];
extraParams?: Partial<TParams>;
initialPageSize?: number;
initialSorting?: SortingState;
enabled?: boolean;
refetchInterval?: number | false;
}
const EMPTY_ARRAY: any[] = [];
export function useServerDataTable<TData, TParams>(
props: UseServerDataTableProps<TData, TParams>,
) {
const {
fetchAction,
queryKey,
extraParams,
initialPageSize = 20,
initialSorting = [],
enabled = true,
refetchInterval = false,
} = props;
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: initialPageSize,
});
const [sorting, setSorting] = useState<SortingState>(initialSorting);
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}, [sorting]);
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}, [JSON.stringify(extraParams)]);
const query = useQuery({
queryKey: [
...queryKey,
pagination.pageIndex,
pagination.pageSize,
sorting,
JSON.stringify(extraParams),
],
queryFn: async () => {
const response = await fetchAction({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sorting,
...extraParams,
} as any);
const result = response?.data as PaginatedResult<TData> | undefined;
if (!result || !Array.isArray(result.data)) {
throw new Error("Failed to fetch paginated data");
}
return result;
},
placeholderData: keepPreviousData,
enabled,
gcTime: 2 * 60 * 1000,
staleTime: 30 * 1000,
refetchInterval,
refetchOnWindowFocus: true,
retry: 1,
});
return {
data: (query.data?.data as TData[]) || EMPTY_ARRAY,
rowCount: query.data?.meta?.total || 0,
pagination,
onPaginationChange: setPagination,
sorting,
onSortingChange: setSorting,
tableProps: {
manualPagination: true as const,
rowCount: query.data?.meta?.total || 0,
paginationState: pagination,
onPaginationChange: setPagination,
sorting,
onSortingChange: setSorting,
},
meta: query.data?.meta,
isLoading: query.isPending,
isFetching: query.isFetching,
refetch: query.refetch,
};
}
+46 -9
View File
@@ -1,10 +1,47 @@
const cronBounds: Record<string, [number, number]> = {
minute: [0, 59],
hour: [0, 23],
"day-of-month": [1, 31],
month: [1, 12],
"day-of-week": [0, 6],
};
const isNumberInRange = (value: string, min: number, max: number): boolean => {
if (!/^\d+$/.test(value)) return false;
const n = Number(value);
return n >= min && n <= max;
};
const isRangeOrNumber = (value: string, min: number, max: number): boolean => {
const range = value.match(/^(\d+)-(\d+)$/);
if (range) {
const start = Number(range[1]);
const end = Number(range[2]);
return isNumberInRange(range[1], min, max) && isNumberInRange(range[2], min, max) && start <= end;
}
return isNumberInRange(value, min, max);
};
export const isValidCronPart = (type: string, value: string): boolean => {
const regexMap: Record<string, RegExp> = {
minute: /^(\*|([0-5]?\d)|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
hour: /^(\*|([01]?\d|2[0-3])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
"day-of-month": /^(\*|([1-9]|[12]\d|3[01])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
month: /^(\*|([1-9]|1[0-2])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
"day-of-week": /^(\*|[0-6]|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
};
return regexMap[type]?.test(value) ?? false;
};
const bounds = cronBounds[type];
if (!bounds) return false;
const [min, max] = bounds;
return value.split(",").every((rawPart) => {
const part = rawPart.trim();
if (part === "") return false;
const step = part.match(/^(.+)\/(\d+)$/);
if (step) {
const base = step[1];
const stepValue = Number(step[2]);
if (!/^\d+$/.test(step[2]) || stepValue < 1 || stepValue > max) return false;
if (base === "*") return true;
return isRangeOrNumber(base, min, max);
}
if (part === "*") return true;
return isRangeOrNumber(part, min, max);
});
};
+50
View File
@@ -0,0 +1,50 @@
import crypto from "node:crypto";
/**
* Minimum agent version (its own CARGO_PKG_VERSION) that understands the
* encrypted storages envelope. Older agents receive legacy plaintext.
*/
export const MIN_AGENT_VERSION_STORAGE_ENC = "1.17.0";
/**
* Encrypt the storages payload with AES-256-GCM using the raw 32-byte master
* key. Wire layout: base64( iv(12) ciphertext authTag(16) ).
*/
export function encryptStorages(storages: unknown, masterKey: Buffer): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", masterKey, iv);
const plaintext = Buffer.from(JSON.stringify(storages), "utf-8");
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, ciphertext, tag]).toString("base64");
}
/**
* Numeric major.minor.patch comparison. Returns false when either version is
* missing or cannot be parsed as three non-negative integers (fail safe).
*/
export function isAgentVersionAtLeast(
reported: string | undefined,
min: string,
): boolean {
const parse = (v: string): [number, number, number] | null => {
const core = v.trim().split(/[-+]/)[0];
const parts = core.split(".");
if (parts.length !== 3) return null;
if (!parts.every((p) => /^\d+$/.test(p))) return null;
const nums = parts.map((p) => Number(p));
if (nums.some((n) => !Number.isInteger(n) || n < 0)) return null;
return [nums[0], nums[1], nums[2]];
};
if (!reported) return false;
const a = parse(reported);
const b = parse(min);
if (!a || !b) return false;
for (let i = 0; i < 3; i++) {
if (a[i] > b[i]) return true;
if (a[i] < b[i]) return false;
}
return true;
}