Format the repository with Prettier

CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.

conduit-bringup-artifacts is added to .prettierignore instead. Those
files are captured bring-up reports, not maintained sources; reflowing
them would only churn a record of what happened.
This commit is contained in:
hibna
2026-08-02 21:08:12 +03:00
parent d0d3a58907
commit c1adb94abb
59 changed files with 1516 additions and 1364 deletions
+36 -34
View File
@@ -18,10 +18,7 @@ import { AppError } from './lib/errors.js';
const app = Fastify({
logger: {
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
},
});
@@ -46,39 +43,44 @@ await app.register(authPlugin);
await app.register(socketPlugin);
// Error handler
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
error: error.name,
message: error.message,
code: error.code,
});
}
app.setErrorHandler(
(
error: Error & { validation?: unknown; statusCode?: number; code?: string },
_request,
reply,
) => {
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
error: error.name,
message: error.message,
code: error.code,
});
}
// Fastify validation errors
if (error.validation) {
return reply.code(400).send({
error: 'Validation Error',
message: error.message,
});
}
// Fastify validation errors
if (error.validation) {
return reply.code(400).send({
error: 'Validation Error',
message: error.message,
});
}
// Rate limit errors
if (error.statusCode === 429) {
return reply.code(429).send({
error: 'Too Many Requests',
message: 'Rate limit exceeded, please try again later',
});
}
// Rate limit errors
if (error.statusCode === 429) {
return reply.code(429).send({
error: 'Too Many Requests',
message: 'Rate limit exceeded, please try again later',
});
}
app.log.error(error);
return reply.code(error.statusCode ?? 500).send({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: error.message,
});
});
app.log.error(error);
return reply.code(error.statusCode ?? 500).send({
error: 'Internal Server Error',
message:
process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
});
},
);
// Routes
app.get('/api/health', async () => {
+5 -11
View File
@@ -23,7 +23,9 @@ function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
}
function getArtifactAccessTtlSeconds(): number {
const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS);
const raw = Number(
process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS,
);
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
return Math.floor(raw);
}
@@ -100,11 +102,7 @@ export async function ensurePrivatePluginBucket(): Promise<string> {
}
}
throw toCdnAppError(
error,
'Failed to fetch CDN plugin bucket',
'CDN_BUCKET_READ_FAILED',
);
throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED');
}
}
@@ -192,10 +190,6 @@ export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<s
return new URL(resolvedUrl, config.baseUrl).toString();
} catch (error) {
throw toCdnAppError(
error,
'Failed to get temporary CDN access URL',
'CDN_ACCESS_URL_FAILED',
);
throw toCdnAppError(error, 'Failed to get temporary CDN access URL', 'CDN_ACCESS_URL_FAILED');
}
}
+6 -4
View File
@@ -662,15 +662,17 @@ export async function daemonSetPowerState(
action: PowerAction,
options: DaemonPowerOptions = {},
): Promise<void> {
const stopTimeoutSeconds = Number(options.stopTimeoutSeconds) > 0
? Math.floor(Number(options.stopTimeoutSeconds))
: 0;
const stopTimeoutSeconds =
Number(options.stopTimeoutSeconds) > 0 ? Math.floor(Number(options.stopTimeoutSeconds)) : 0;
// The daemon waits out the shutdown before replying, so the RPC deadline has
// to outlive the game's own budget (ARK saves its world for minutes).
const rpcTimeoutMs =
action === 'stop' || action === 'restart'
? Math.min(Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), MAX_POWER_RPC_TIMEOUT_MS)
? Math.min(
Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS),
MAX_POWER_RPC_TIMEOUT_MS,
)
: POWER_RPC_TIMEOUT_MS;
const client = createClient(node);
+5 -19
View File
@@ -1,9 +1,5 @@
import type { FastifyInstance } from 'fastify';
import {
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from './daemon.js';
/**
* Some game images run a SteamCMD `app_update ... validate` on every container
@@ -168,21 +164,14 @@ export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[]
}
/** The managed file a request path refers to, or `null` if it is not managed. */
export function managedConfigFileFor(
gameSlug: string,
path: string,
): ManagedConfigFile | null {
export function managedConfigFileFor(gameSlug: string, path: string): ManagedConfigFile | null {
const normalized = normalizePath(path);
return (
managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null
);
return managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null;
}
export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean {
const normalized = fileName.trim();
return managedConfigFilesForGame(gameSlug).some(
(file) => file.shadowFileName === normalized,
);
return managedConfigFilesForGame(gameSlug).some((file) => file.shadowFileName === normalized);
}
/**
@@ -371,10 +360,7 @@ export function sustainManagedConfigsAfterStart(
stableRounds = drifted ? 0 : stableRounds + 1;
if (
stableRounds >= REQUIRED_STABLE_ROUNDS &&
Date.now() - startedAt >= MIN_WATCH_MS
) {
if (stableRounds >= REQUIRED_STABLE_ROUNDS && Date.now() - startedAt >= MIN_WATCH_MS) {
return;
}
}
+4 -1
View File
@@ -45,7 +45,10 @@ export async function getOrgMembership(
* Check if the user has a specific permission in the organization.
* Super admins always have all permissions.
*/
export function hasPermission(membership: OrgMember | 'super_admin', permission: Permission): boolean {
export function hasPermission(
membership: OrgMember | 'super_admin',
permission: Permission,
): boolean {
if (membership === 'super_admin') return true;
// Check custom permission overrides first
+1 -4
View File
@@ -1,10 +1,7 @@
/**
* Compute the next run time for a scheduled task.
*/
export function computeNextRun(
scheduleType: string,
scheduleData: Record<string, unknown>,
): Date {
export function computeNextRun(scheduleType: string, scheduleData: Record<string, unknown>): Date {
const now = new Date();
switch (scheduleType) {
+16 -21
View File
@@ -171,10 +171,7 @@ function readWorkflowId(value: unknown): string | null {
return id;
}
function normalizeWorkflow(
gameSlug: string,
workflow: GameAutomationRule,
): GameAutomationRule {
function normalizeWorkflow(gameSlug: string, workflow: GameAutomationRule): GameAutomationRule {
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
if (workflow.id === 'cs2-write-default-server-config') {
@@ -237,9 +234,7 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[]
}
const existingIds = new Set(
raw
.map(readWorkflowId)
.filter((workflowId): workflowId is string => workflowId !== null),
raw.map(readWorkflowId).filter((workflowId): workflowId is string => workflowId !== null),
);
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
@@ -247,7 +242,9 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[]
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
}
return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow));
return [...configured, ...missingDefaults].map((workflow) =>
normalizeWorkflow(gameSlug, workflow),
);
}
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
@@ -386,9 +383,7 @@ interface DirectoryAssetCandidate {
function extractNumberParts(value: string): number[] {
const matches = value.match(/\d+/g);
if (!matches) return [];
return matches
.map((part) => Number.parseInt(part, 10))
.filter((num) => Number.isFinite(num));
return matches.map((part) => Number.parseInt(part, 10)).filter((num) => Number.isFinite(num));
}
function compareNumberPartsDesc(a: number[], b: number[]): number {
@@ -431,7 +426,9 @@ function extractDirectoryCandidates(
try {
const resolvedUrl = new URL(href, indexUrl);
const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '');
const filename = decodeURIComponent(
resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '',
);
if (!filename || !assetPattern.test(filename)) continue;
candidates.push({
@@ -611,7 +608,8 @@ async function executeGitHubReleaseExtract(
);
}
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const maxBytes =
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
const files = await extractArtifactFiles(
artifact,
@@ -651,7 +649,8 @@ async function executeHttpDirectoryExtract(
action: ServerAutomationHttpDirectoryExtractAction,
): Promise<void> {
const selectedAsset = await resolveLatestDirectoryAsset(action);
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const maxBytes =
Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
const files = await extractArtifactFiles(
artifact,
@@ -701,9 +700,7 @@ async function executeInsertBeforeLine(
const skipIfExists = action.skipIfExists !== false;
if (skipIfExists) {
const existsRegex = action.existsPattern
? new RegExp(action.existsPattern, 'i')
: null;
const existsRegex = action.existsPattern ? new RegExp(action.existsPattern, 'i') : null;
const alreadyExists = lines.some((line) =>
existsRegex ? existsRegex.test(line) : line === action.line,
@@ -777,9 +774,7 @@ async function executeAction(
case 'write_file': {
const payload =
action.encoding === 'base64'
? Buffer.from(action.data, 'base64')
: action.data;
action.encoding === 'base64' ? Buffer.from(action.data, 'base64') : action.data;
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
app.log.info(
@@ -847,7 +842,7 @@ export async function runServerAutomationEvent(
if (
runOnce &&
!context.force &&
await hasMarker(context.node, context.serverUuid, context.event, workflow.id)
(await hasMarker(context.node, context.serverUuid, context.event, workflow.id))
) {
result.workflowsSkipped += 1;
app.log.info(
+4 -2
View File
@@ -18,7 +18,8 @@ export default fp(async (app: FastifyInstance) => {
const db = createDb(databaseUrl);
app.decorate('db', db);
await db.execute(sql.raw(`
await db.execute(
sql.raw(`
CREATE TABLE IF NOT EXISTS server_databases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
@@ -32,7 +33,8 @@ export default fp(async (app: FastifyInstance) => {
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`));
`),
);
await db.execute(
sql.raw(
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
+8 -9
View File
@@ -59,9 +59,8 @@ export default fp(async (app: FastifyInstance) => {
};
io.use((socket, next) => {
const token = typeof socket.handshake.auth?.token === 'string'
? socket.handshake.auth.token
: null;
const token =
typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : null;
if (!token) {
next(new Error('Unauthorized'));
@@ -102,9 +101,10 @@ export default fp(async (app: FastifyInstance) => {
};
socket.on('server:console:join', async (payload: unknown) => {
const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string'
? ((payload as { serverId: string }).serverId)
: '';
const serverId =
typeof (payload as { serverId?: unknown })?.serverId === 'string'
? (payload as { serverId: string }).serverId
: '';
if (!serverId) {
socket.emit('server:console:output', { line: '[error] Invalid server id' });
return;
@@ -202,9 +202,8 @@ export default fp(async (app: FastifyInstance) => {
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
const command = typeof body.command === 'string' ? body.command.trim() : '';
const requestId = typeof body.requestId === 'string' && body.requestId.trim()
? body.requestId.trim()
: null;
const requestId =
typeof body.requestId === 'string' && body.requestId.trim() ? body.requestId.trim() : null;
if (!serverId || !orgId || !command) {
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
+273 -253
View File
@@ -86,10 +86,7 @@ function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
return parsed;
}
function parseJsonArrayUploadFile(
file: UploadJsonFile | null,
fieldName: string,
): unknown[] {
function parseJsonArrayUploadFile(file: UploadJsonFile | null, fieldName: string): unknown[] {
if (!file) return [];
let rawValue = file.data.toString('utf8');
@@ -115,8 +112,10 @@ function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
if (typeof rawValue !== 'string') return undefined;
const normalized = rawValue.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on')
return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off')
return false;
return undefined;
}
@@ -124,7 +123,8 @@ function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase();
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized;
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable')
return normalized;
}
return 'stable';
}
@@ -228,10 +228,7 @@ export default async function adminRoutes(app: FastifyInstance) {
// GET /api/admin/games
app.get('/games', async () => {
const gameList = await app.db
.select()
.from(games)
.orderBy(games.name);
const gameList = await app.db.select().from(games).orderBy(games.name);
return { data: gameList };
});
@@ -271,20 +268,24 @@ export default async function adminRoutes(app: FastifyInstance) {
});
// PATCH /api/admin/games/:gameId
app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => {
const { gameId } = request.params as { gameId: string };
const body = request.body as Record<string, unknown>;
app.patch(
'/games/:gameId',
{ schema: { ...GameIdParamSchema, ...UpdateGameSchema } },
async (request) => {
const { gameId } = request.params as { gameId: string };
const body = request.body as Record<string, unknown>;
const [updated] = await app.db
.update(games)
.set({ ...body, updatedAt: new Date() })
.where(eq(games.id, gameId))
.returning();
const [updated] = await app.db
.update(games)
.set({ ...body, updatedAt: new Date() })
.where(eq(games.id, gameId))
.returning();
if (!updated) throw AppError.notFound('Game not found');
if (!updated) throw AppError.notFound('Game not found');
return updated;
});
return updated;
},
);
// === Nodes (global view) ===
@@ -581,49 +582,56 @@ export default async function adminRoutes(app: FastifyInstance) {
};
});
app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => {
const { pluginId } = request.params as { pluginId: string };
const body = request.body as {
name?: string;
slug?: string;
description?: string;
source?: 'manual' | 'spiget';
isGlobal?: boolean;
};
app.patch(
'/plugins/:pluginId',
{ schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } },
async (request) => {
const { pluginId } = request.params as { pluginId: string };
const body = request.body as {
name?: string;
slug?: string;
description?: string;
source?: 'manual' | 'spiget';
isGlobal?: boolean;
};
const existing = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!existing) throw AppError.notFound('Plugin not found');
const existing = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!existing) throw AppError.notFound('Plugin not found');
const nextSlug = body.slug !== undefined
? toSlug(body.slug)
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
const nextSlug =
body.slug !== undefined
? toSlug(body.slug)
: body.name !== undefined
? toSlug(body.name)
: existing.slug;
if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid');
const duplicate = await app.db.query.plugins.findFirst({
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
});
if (duplicate && duplicate.id !== existing.id) {
throw AppError.conflict('Plugin slug already exists for this game');
}
const duplicate = await app.db.query.plugins.findFirst({
where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)),
});
if (duplicate && duplicate.id !== existing.id) {
throw AppError.conflict('Plugin slug already exists for this game');
}
const [updated] = await app.db
.update(plugins)
.set({
name: body.name ?? existing.name,
slug: nextSlug,
description: body.description ?? existing.description,
source: body.source ?? existing.source,
isGlobal: body.isGlobal ?? existing.isGlobal,
updatedAt: new Date(),
})
.where(eq(plugins.id, existing.id))
.returning();
const [updated] = await app.db
.update(plugins)
.set({
name: body.name ?? existing.name,
slug: nextSlug,
description: body.description ?? existing.description,
source: body.source ?? existing.source,
isGlobal: body.isGlobal ?? existing.isGlobal,
updatedAt: new Date(),
})
.where(eq(plugins.id, existing.id))
.returning();
if (!updated) throw AppError.notFound('Plugin not found');
return updated;
});
if (!updated) throw AppError.notFound('Plugin not found');
return updated;
},
);
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
const { pluginId } = request.params as { pluginId: string };
@@ -642,216 +650,231 @@ export default async function adminRoutes(app: FastifyInstance) {
return { plugin, releases };
});
app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => {
const { pluginId } = request.params as { pluginId: string };
app.post(
'/plugins/:pluginId/releases/upload',
{ schema: PluginIdParamSchema },
async (request, reply) => {
const { pluginId } = request.params as { pluginId: string };
const plugin = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!plugin) throw AppError.notFound('Plugin not found');
const plugin = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!plugin) throw AppError.notFound('Plugin not found');
if (!request.isMultipart()) {
throw AppError.badRequest('Content-Type must be multipart/form-data');
}
if (!request.isMultipart()) {
throw AppError.badRequest('Content-Type must be multipart/form-data');
}
const fields: Record<string, unknown> = {};
const files: UploadArtifactFile[] = [];
let installSchemaFile: UploadJsonFile | null = null;
let configTemplatesFile: UploadJsonFile | null = null;
const relativePathQueue: string[] = [];
const fields: Record<string, unknown> = {};
const files: UploadArtifactFile[] = [];
let installSchemaFile: UploadJsonFile | null = null;
let configTemplatesFile: UploadJsonFile | null = null;
const relativePathQueue: string[] = [];
for await (const part of request.parts()) {
if (part.type === 'file') {
if (part.fieldname === 'installSchemaFile') {
const data = await part.toBuffer();
if (data.length > 0) {
installSchemaFile = {
filename: part.filename || 'install-schema.json',
data,
};
for await (const part of request.parts()) {
if (part.type === 'file') {
if (part.fieldname === 'installSchemaFile') {
const data = await part.toBuffer();
if (data.length > 0) {
installSchemaFile = {
filename: part.filename || 'install-schema.json',
data,
};
}
continue;
}
continue;
}
if (part.fieldname === 'configTemplatesFile') {
const data = await part.toBuffer();
if (data.length > 0) {
configTemplatesFile = {
filename: part.filename || 'config-templates.json',
data,
};
if (part.fieldname === 'configTemplatesFile') {
const data = await part.toBuffer();
if (data.length > 0) {
configTemplatesFile = {
filename: part.filename || 'config-templates.json',
data,
};
}
continue;
}
continue;
}
const fallbackName = `artifact-${files.length + 1}.bin`;
const queuedPath = relativePathQueue.shift();
const relativePath = normalizeRelativePath(
queuedPath ?? part.filename ?? '',
fallbackName,
);
const data = await part.toBuffer();
if (data.length === 0) continue;
files.push({ relativePath, data });
const fallbackName = `artifact-${files.length + 1}.bin`;
const queuedPath = relativePathQueue.shift();
const relativePath = normalizeRelativePath(
queuedPath ?? part.filename ?? '',
fallbackName,
);
const data = await part.toBuffer();
if (data.length === 0) continue;
files.push({ relativePath, data });
} else {
if (part.fieldname === 'relativePath') {
const raw = typeof part.value === 'string' ? part.value : '';
relativePathQueue.push(raw);
continue;
}
fields[part.fieldname] = part.value;
}
}
if (files.length === 0) {
throw AppError.badRequest('At least one file is required');
}
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
if (!version) {
throw AppError.badRequest('version is required');
}
const channel = parseReleaseChannel(fields.channel);
const destination =
typeof fields.destination === 'string' && fields.destination.trim().length > 0
? fields.destination.trim()
: null;
const changelog =
typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
? fields.changelog
: null;
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
const installSchema = parseJsonArrayInput(
fields.installSchema,
installSchemaFile,
'installSchema',
);
const configTemplates = parseJsonArrayInput(
fields.configTemplates,
configTemplatesFile,
'configTemplates',
);
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
const shouldZip = files.length > 1 || hasNestedPaths;
let artifactType: 'file' | 'zip';
let artifactContent: Buffer;
let uploadFileName: string;
let releaseFileName: string | null;
if (shouldZip) {
artifactType = 'zip';
artifactContent = await zipArtifacts(files);
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
? suggestedName
: `${suggestedName}.zip`;
releaseFileName = null;
} else {
if (part.fieldname === 'relativePath') {
const raw = typeof part.value === 'string' ? part.value : '';
relativePathQueue.push(raw);
continue;
artifactType = 'file';
const [singleFile] = files;
if (!singleFile) {
throw AppError.badRequest('No artifact file received');
}
fields[part.fieldname] = part.value;
}
}
if (files.length === 0) {
throw AppError.badRequest('At least one file is required');
}
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
if (!version) {
throw AppError.badRequest('version is required');
}
const channel = parseReleaseChannel(fields.channel);
const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0
? fields.destination.trim()
: null;
const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
? fields.changelog
: null;
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema');
const configTemplates = parseJsonArrayInput(
fields.configTemplates,
configTemplatesFile,
'configTemplates',
);
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
const shouldZip = files.length > 1 || hasNestedPaths;
let artifactType: 'file' | 'zip';
let artifactContent: Buffer;
let uploadFileName: string;
let releaseFileName: string | null;
if (shouldZip) {
artifactType = 'zip';
artifactContent = await zipArtifacts(files);
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
? suggestedName
: `${suggestedName}.zip`;
releaseFileName = null;
} else {
artifactType = 'file';
const [singleFile] = files;
if (!singleFile) {
throw AppError.badRequest('No artifact file received');
artifactContent = singleFile.data;
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
uploadFileName = rawFileName || originalName;
releaseFileName = uploadFileName;
}
artifactContent = singleFile.data;
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
uploadFileName = rawFileName || originalName;
releaseFileName = uploadFileName;
}
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
pluginId: plugin.id,
pluginSlug: plugin.slug,
releaseVersion: version,
uploadedBy: request.user.sub,
uploadMode: shouldZip ? 'archive' : 'single',
sourceFileCount: files.length,
});
const [created] = await app.db
.insert(pluginReleases)
.values({
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
pluginId: plugin.id,
version,
channel,
artifactType,
artifactUrl: uploaded.artifactPointer,
destination,
fileName: releaseFileName,
changelog,
installSchema,
configTemplates,
isPublished,
createdByUserId: request.user.sub,
})
.returning();
pluginSlug: plugin.slug,
releaseVersion: version,
uploadedBy: request.user.sub,
uploadMode: shouldZip ? 'archive' : 'single',
sourceFileCount: files.length,
});
return reply.code(201).send({
release: created,
artifact: {
bucket: uploaded.bucket,
fileId: uploaded.file.id,
storedName: uploaded.file.storedName,
originalName: uploaded.file.originalName,
pointer: uploaded.artifactPointer,
},
});
});
const [created] = await app.db
.insert(pluginReleases)
.values({
pluginId: plugin.id,
version,
channel,
artifactType,
artifactUrl: uploaded.artifactPointer,
destination,
fileName: releaseFileName,
changelog,
installSchema,
configTemplates,
isPublished,
createdByUserId: request.user.sub,
})
.returning();
app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => {
const { pluginId } = request.params as { pluginId: string };
const body = request.body as {
version: string;
channel?: 'stable' | 'beta' | 'alpha';
artifactType?: 'file' | 'zip';
artifactUrl: string;
destination?: string;
fileName?: string;
changelog?: string;
installSchema?: unknown[];
configTemplates?: unknown[];
isPublished?: boolean;
cloneFromReleaseId?: string;
};
return reply.code(201).send({
release: created,
artifact: {
bucket: uploaded.bucket,
fileId: uploaded.file.id,
storedName: uploaded.file.storedName,
originalName: uploaded.file.originalName,
pointer: uploaded.artifactPointer,
},
});
},
);
const plugin = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!plugin) throw AppError.notFound('Plugin not found');
app.post(
'/plugins/:pluginId/releases',
{ schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } },
async (request, reply) => {
const { pluginId } = request.params as { pluginId: string };
const body = request.body as {
version: string;
channel?: 'stable' | 'beta' | 'alpha';
artifactType?: 'file' | 'zip';
artifactUrl: string;
destination?: string;
fileName?: string;
changelog?: string;
installSchema?: unknown[];
configTemplates?: unknown[];
isPublished?: boolean;
cloneFromReleaseId?: string;
};
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
if (body.cloneFromReleaseId) {
baseRelease = await app.db.query.pluginReleases.findFirst({
where: and(
eq(pluginReleases.id, body.cloneFromReleaseId),
eq(pluginReleases.pluginId, pluginId),
),
}) ?? null;
if (!baseRelease) {
throw AppError.notFound('Clone source release not found');
const plugin = await app.db.query.plugins.findFirst({
where: eq(plugins.id, pluginId),
});
if (!plugin) throw AppError.notFound('Plugin not found');
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
if (body.cloneFromReleaseId) {
baseRelease =
(await app.db.query.pluginReleases.findFirst({
where: and(
eq(pluginReleases.id, body.cloneFromReleaseId),
eq(pluginReleases.pluginId, pluginId),
),
})) ?? null;
if (!baseRelease) {
throw AppError.notFound('Clone source release not found');
}
}
}
const [created] = await app.db
.insert(pluginReleases)
.values({
pluginId,
version: body.version,
channel: body.channel ?? baseRelease?.channel ?? 'stable',
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
artifactUrl: body.artifactUrl,
destination: body.destination ?? baseRelease?.destination ?? null,
fileName: body.fileName ?? baseRelease?.fileName ?? null,
changelog: body.changelog ?? baseRelease?.changelog ?? null,
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
createdByUserId: request.user.sub,
})
.returning();
const [created] = await app.db
.insert(pluginReleases)
.values({
pluginId,
version: body.version,
channel: body.channel ?? baseRelease?.channel ?? 'stable',
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
artifactUrl: body.artifactUrl,
destination: body.destination ?? baseRelease?.destination ?? null,
fileName: body.fileName ?? baseRelease?.fileName ?? null,
changelog: body.changelog ?? baseRelease?.changelog ?? null,
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
createdByUserId: request.user.sub,
})
.returning();
return reply.code(201).send(created);
});
return reply.code(201).send(created);
},
);
app.patch(
'/plugins/:pluginId/releases/:releaseId',
@@ -901,10 +924,7 @@ export default async function adminRoutes(app: FastifyInstance) {
// GET /api/admin/nodes
app.get('/nodes', async () => {
const nodeList = await app.db
.select()
.from(nodes)
.orderBy(nodes.createdAt);
const nodeList = await app.db.select().from(nodes).orderBy(nodes.createdAt);
return { data: nodeList };
});
+17 -7
View File
@@ -90,10 +90,14 @@ export const ReleaseInstallFieldSchema = Type.Object({
description: Type.Optional(Type.String({ maxLength: 1000 })),
required: Type.Optional(Type.Boolean()),
defaultValue: Type.Optional(Type.Any()),
options: Type.Optional(Type.Array(Type.Object({
label: Type.String({ minLength: 1, maxLength: 255 }),
value: Type.String({ minLength: 1, maxLength: 255 }),
}))),
options: Type.Optional(
Type.Array(
Type.Object({
label: Type.String({ minLength: 1, maxLength: 255 }),
value: Type.String({ minLength: 1, maxLength: 255 }),
}),
),
),
min: Type.Optional(Type.Number()),
max: Type.Optional(Type.Number()),
pattern: Type.Optional(Type.String({ maxLength: 500 })),
@@ -107,7 +111,9 @@ export const ReleaseTemplateSchema = Type.Object({
const ImportPluginReleasePayloadSchema = Type.Object({
version: Type.String({ minLength: 1, maxLength: 100 }),
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
channel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
artifactUrl: Type.String({ format: 'uri' }),
destination: Type.Optional(Type.String({ minLength: 1 })),
@@ -138,7 +144,9 @@ export const ImportPluginsSchema = {
export const CreatePluginReleaseSchema = {
body: Type.Object({
version: Type.String({ minLength: 1, maxLength: 100 }),
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
channel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
artifactUrl: Type.String({ format: 'uri' }),
destination: Type.Optional(Type.String({ minLength: 1 })),
@@ -154,7 +162,9 @@ export const CreatePluginReleaseSchema = {
export const UpdatePluginReleaseSchema = {
body: Type.Object({
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
channel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
destination: Type.Optional(Type.String({ minLength: 1 })),
+1 -4
View File
@@ -6,10 +6,7 @@ export default async function gameRoutes(app: FastifyInstance) {
// GET /api/games
app.get('/', async () => {
const gameList = await app.db
.select()
.from(games)
.orderBy(games.name);
const gameList = await app.db.select().from(games).orderBy(games.name);
return { data: gameList };
});
+18 -22
View File
@@ -18,9 +18,8 @@ function extractCdnWebhookSecret(request: FastifyRequest): string | null {
return byHeader.trim();
}
const authHeader = typeof request.headers.authorization === 'string'
? request.headers.authorization
: undefined;
const authHeader =
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined;
return extractBearerToken(authHeader);
}
@@ -30,9 +29,7 @@ async function requireDaemonToken(
request: FastifyRequest,
): Promise<{ id: string }> {
const token = extractBearerToken(
typeof request.headers.authorization === 'string'
? request.headers.authorization
: undefined,
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
);
if (!token) {
@@ -69,14 +66,14 @@ export default async function internalRoutes(app: FastifyInstance) {
}
const body = request.body as Record<string, unknown> | undefined;
const eventType = typeof body?.eventType === 'string'
? body.eventType
: (typeof body?.type === 'string' ? body.type : 'unknown');
const eventType =
typeof body?.eventType === 'string'
? body.eventType
: typeof body?.type === 'string'
? body.type
: 'unknown';
request.log.info(
{ eventType, payload: body },
'Received CDN plugin webhook event',
);
request.log.info({ eventType, payload: body }, 'Received CDN plugin webhook event');
return reply.code(202).send({ accepted: true });
},
@@ -98,11 +95,13 @@ export default async function internalRoutes(app: FastifyInstance) {
})
.from(scheduledTasks)
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
.where(and(
eq(servers.nodeId, node.id),
eq(scheduledTasks.isActive, true),
lte(scheduledTasks.nextRunAt, now),
));
.where(
and(
eq(servers.nodeId, node.id),
eq(scheduledTasks.isActive, true),
lte(scheduledTasks.nextRunAt, now),
),
);
return {
tasks: dueTasks.map((task) => ({
@@ -139,10 +138,7 @@ export default async function internalRoutes(app: FastifyInstance) {
})
.from(scheduledTasks)
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
.where(and(
eq(scheduledTasks.id, taskId),
eq(servers.nodeId, node.id),
));
.where(and(eq(scheduledTasks.id, taskId), eq(servers.nodeId, node.id)));
if (!task) {
throw AppError.notFound('Scheduled task not found');
+1 -3
View File
@@ -23,9 +23,7 @@ export default async function daemonNodeRoutes(app: FastifyInstance) {
// POST /api/nodes/heartbeat
app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => {
const token = extractBearerToken(
typeof request.headers.authorization === 'string'
? request.headers.authorization
: undefined,
typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined,
);
if (!token) {
+46 -38
View File
@@ -94,28 +94,32 @@ export default async function nodeRoutes(app: FastifyInstance) {
});
// PATCH /api/organizations/:orgId/nodes/:nodeId
app.patch('/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
app.patch(
'/:nodeId',
{ schema: { ...NodeParamSchema, ...UpdateNodeSchema } },
async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const body = request.body as Record<string, unknown>;
const body = request.body as Record<string, unknown>;
const [updated] = await app.db
.update(nodes)
.set({ ...body, updatedAt: new Date() })
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
.returning();
const [updated] = await app.db
.update(nodes)
.set({ ...body, updatedAt: new Date() })
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
.returning();
if (!updated) throw AppError.notFound('Node not found');
if (!updated) throw AppError.notFound('Node not found');
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.update',
metadata: { nodeId, ...body },
});
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.update',
metadata: { nodeId, ...body },
});
return updated;
});
return updated;
},
);
// DELETE /api/organizations/:orgId/nodes/:nodeId
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
@@ -244,30 +248,34 @@ export default async function nodeRoutes(app: FastifyInstance) {
});
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
app.post('/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
app.post(
'/:nodeId/allocations',
{ schema: { ...NodeParamSchema, ...CreateAllocationSchema } },
async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const { ip, ports } = request.body as { ip: string; ports: number[] };
const { ip, ports } = request.body as { ip: string; ports: number[] };
const values = ports.map((port) => ({
nodeId,
ip,
port,
}));
const values = ports.map((port) => ({
nodeId,
ip,
port,
}));
const created = await app.db
.insert(allocations)
.values(values)
.onConflictDoNothing()
.returning();
const created = await app.db
.insert(allocations)
.values(values)
.onConflictDoNothing()
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'allocation.create',
metadata: { nodeId, ip, ports },
});
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'allocation.create',
metadata: { nodeId, ip, ports },
});
return reply.code(201).send({ data: created });
});
return reply.code(201).send({ data: created });
},
);
}
+93 -80
View File
@@ -174,104 +174,117 @@ export default async function organizationRoutes(app: FastifyInstance) {
});
// POST /api/organizations/:orgId/members — invite by email
app.post('/:orgId/members', { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, async (request, reply) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'org.members');
app.post(
'/:orgId/members',
{ schema: { ...OrgIdParamSchema, ...AddMemberSchema } },
async (request, reply) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'org.members');
const { email, role } = request.body as { email: string; role: 'admin' | 'user' };
const { email, role } = request.body as { email: string; role: 'admin' | 'user' };
const user = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) throw AppError.notFound('User with this email not found');
const user = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) throw AppError.notFound('User with this email not found');
const existing = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, user.id),
),
});
if (existing) throw AppError.conflict('User is already a member');
const existing = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, user.id),
),
});
if (existing) throw AppError.conflict('User is already a member');
const [member] = await app.db
.insert(organizationMembers)
.values({
const [member] = await app.db
.insert(organizationMembers)
.values({
organizationId: orgId,
userId: user.id,
role,
})
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
userId: user.id,
role,
})
.returning();
action: 'member.add',
metadata: { userId: user.id, email, role },
});
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.add',
metadata: { userId: user.id, email, role },
});
return reply.code(201).send(member);
});
return reply.code(201).send(member);
},
);
// PATCH /api/organizations/:orgId/members/:memberId
app.patch('/:orgId/members/:memberId', { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, async (request) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
app.patch(
'/:orgId/members/:memberId',
{ schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } },
async (request) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
const body = request.body as { role?: 'admin' | 'user'; customPermissions?: Record<string, boolean> };
const body = request.body as {
role?: 'admin' | 'user';
customPermissions?: Record<string, boolean>;
};
const [updated] = await app.db
.update(organizationMembers)
.set(body)
.where(and(
eq(organizationMembers.id, memberId),
eq(organizationMembers.organizationId, orgId),
))
.returning();
const [updated] = await app.db
.update(organizationMembers)
.set(body)
.where(
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
)
.returning();
if (!updated) throw AppError.notFound('Member not found');
if (!updated) throw AppError.notFound('Member not found');
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.update',
metadata: { memberId, ...body },
});
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.update',
metadata: { memberId, ...body },
});
return updated;
});
return updated;
},
);
// DELETE /api/organizations/:orgId/members/:memberId
app.delete('/:orgId/members/:memberId', { schema: MemberIdParamSchema }, async (request, reply) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
app.delete(
'/:orgId/members/:memberId',
{ schema: MemberIdParamSchema },
async (request, reply) => {
const { orgId, memberId } = request.params as { orgId: string; memberId: string };
await requirePermission(request, orgId, 'org.members');
const member = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.id, memberId),
eq(organizationMembers.organizationId, orgId),
),
});
if (!member) throw AppError.notFound('Member not found');
const member = await app.db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.id, memberId),
eq(organizationMembers.organizationId, orgId),
),
});
if (!member) throw AppError.notFound('Member not found');
// Cannot remove org owner
const org = await app.db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
});
if (org && member.userId === org.ownerId) {
throw AppError.badRequest('Cannot remove the organization owner');
}
// Cannot remove org owner
const org = await app.db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
});
if (org && member.userId === org.ownerId) {
throw AppError.badRequest('Cannot remove the organization owner');
}
await app.db
.delete(organizationMembers)
.where(and(
eq(organizationMembers.id, memberId),
eq(organizationMembers.organizationId, orgId),
));
await app.db
.delete(organizationMembers)
.where(
and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)),
);
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.remove',
metadata: { memberId, userId: member.userId },
});
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'member.remove',
metadata: { memberId, userId: member.userId },
});
return reply.code(204).send();
});
return reply.code(204).send();
},
);
}
+6 -9
View File
@@ -100,7 +100,10 @@ export default async function backupRoutes(app: FastifyInstance) {
completedBackup = updated ?? completedBackup;
} catch (error) {
request.log.error({ error, serverId, backupId: backup.id }, 'Failed to create backup on daemon');
request.log.error(
{ error, serverId, backupId: backup.id },
'Failed to create backup on daemon',
);
await app.db.delete(backups).where(eq(backups.id, backup.id));
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
}
@@ -140,10 +143,7 @@ export default async function backupRoutes(app: FastifyInstance) {
backup.cdnPath,
);
} catch (error) {
request.log.error(
{ error, serverId, backupId },
'Failed to restore backup on daemon',
);
request.log.error({ error, serverId, backupId }, 'Failed to restore backup on daemon');
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
}
@@ -200,10 +200,7 @@ export default async function backupRoutes(app: FastifyInstance) {
try {
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
} catch (error) {
request.log.error(
{ error, serverId, backupId },
'Failed to delete backup on daemon',
);
request.log.error({ error, serverId, backupId }, 'Failed to delete backup on daemon');
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
}
+31 -11
View File
@@ -66,7 +66,12 @@ export default async function configRoutes(app: FastifyInstance) {
};
await requirePermission(request, orgId, 'config.read');
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
const { game, server, node, configFile } = await getServerConfig(
app,
orgId,
serverId,
configIndex,
);
let raw = '';
try {
@@ -79,8 +84,15 @@ export default async function configRoutes(app: FastifyInstance) {
}
} catch (error) {
if (!isMissingConfigFileError(error)) {
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED');
app.log.error(
{ error, serverId, path: configFile.path },
'Failed to read config file from daemon',
);
throw new AppError(
502,
'Failed to read config file from daemon',
'DAEMON_CONFIG_READ_FAILED',
);
}
}
@@ -119,7 +131,12 @@ export default async function configRoutes(app: FastifyInstance) {
const { entries } = request.body as { entries: { key: string; value: string }[] };
await requirePermission(request, orgId, 'config.write');
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
const { game, server, node, configFile } = await getServerConfig(
app,
orgId,
serverId,
configIndex,
);
const managedFile = managedConfigFileFor(game.slug, configFile.path);
@@ -135,8 +152,15 @@ export default async function configRoutes(app: FastifyInstance) {
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
} catch (error) {
if (!isMissingConfigFileError(error)) {
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED');
app.log.error(
{ error, serverId, path: configFile.path },
'Failed to read existing config before write',
);
throw new AppError(
502,
'Failed to read existing config file',
'DAEMON_CONFIG_READ_FAILED',
);
}
}
@@ -156,11 +180,7 @@ export default async function configRoutes(app: FastifyInstance) {
}
}
const content = serializeConfig(
entries,
configFile.parser as ConfigParser,
originalContent,
);
const content = serializeConfig(entries, configFile.parser as ConfigParser, originalContent);
if (managedFile) {
await writeManagedConfig(node, server.uuid, managedFile, content);
+168 -160
View File
@@ -99,180 +99,188 @@ export default async function databaseRoutes(app: FastifyInstance) {
return { data: databases };
});
app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.update');
app.post(
'/',
{ schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } },
async (request, reply) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.update');
const body = request.body as { name: string; password?: string };
const name = body.name.trim();
if (!name) {
throw AppError.badRequest('Database name is required');
}
const body = request.body as { name: string; password?: string };
const name = body.name.trim();
if (!name) {
throw AppError.badRequest('Database name is required');
}
const server = await getServerContext(app, orgId, serverId);
const server = await getServerContext(app, orgId, serverId);
let managedDatabase;
try {
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
name,
password: body.password,
serverUuid: server.uuid,
});
} catch (error) {
request.log.error(
{ error, orgId, serverId, serverUuid: server.uuid },
'Failed to provision node-local MySQL database',
);
throw new AppError(
502,
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
'MANAGED_MYSQL_CREATE_FAILED',
);
}
try {
const [created] = await app.db
.insert(serverDatabases)
.values({
serverId,
let managedDatabase;
try {
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
name,
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
password: managedDatabase.password,
host: managedDatabase.host,
port: managedDatabase.port,
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
password: body.password,
serverUuid: server.uuid,
});
} catch (error) {
request.log.error(
{ error, orgId, serverId, serverUuid: server.uuid },
'Failed to provision node-local MySQL database',
);
throw new AppError(
502,
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
'MANAGED_MYSQL_CREATE_FAILED',
);
}
try {
const [created] = await app.db
.insert(serverDatabases)
.values({
serverId,
name,
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
password: managedDatabase.password,
host: managedDatabase.host,
port: managedDatabase.port,
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
})
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.database.create',
metadata: {
name: created!.name,
databaseName: created!.databaseName,
username: created!.username,
},
});
return reply.code(201).send(created);
} catch (error) {
try {
await daemonDeleteDatabase(buildNodeConnection(server), {
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
});
} catch (cleanupError) {
request.log.error(
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
'Failed to roll back node-local MySQL database after panel insert failure',
);
}
request.log.error(
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
'Failed to persist managed MySQL database metadata',
);
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
}
},
);
app.patch(
'/:databaseId',
{ schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } },
async (request) => {
const { orgId, serverId, databaseId } = request.params as {
databaseId: string;
orgId: string;
serverId: string;
};
await requirePermission(request, orgId, 'server.update');
const body = request.body as { name?: string; password?: string };
const [current] = await app.db
.select({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
createdAt: serverDatabases.createdAt,
updatedAt: serverDatabases.updatedAt,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(serverDatabases)
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(
and(
eq(serverDatabases.id, databaseId),
eq(serverDatabases.serverId, serverId),
eq(servers.organizationId, orgId),
),
);
if (!current) {
throw AppError.notFound('Database not found');
}
const nextName = body.name === undefined ? undefined : body.name.trim();
if (body.name !== undefined && !nextName) {
throw AppError.badRequest('Database name is required');
}
const nextPassword = body.password?.trim();
if (!nextName && !nextPassword) {
return current;
}
if (nextPassword) {
try {
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
password: nextPassword,
username: current.username,
});
} catch (error) {
request.log.error(
{ error, orgId, serverId, databaseId, username: current.username },
'Failed to rotate node-local MySQL password',
);
throw new AppError(
502,
daemonErrorMessage(error, 'Failed to rotate database password'),
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
);
}
}
const patch: Record<string, unknown> = {
updatedAt: new Date(),
};
if (nextName) patch.name = nextName;
if (nextPassword) patch.password = nextPassword;
const [updated] = await app.db
.update(serverDatabases)
.set(patch)
.where(eq(serverDatabases.id, databaseId))
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.database.create',
action: 'server.database.update',
metadata: {
name: created!.name,
databaseName: created!.databaseName,
username: created!.username,
databaseId,
updatedName: nextName ?? undefined,
passwordRotated: Boolean(nextPassword),
},
});
return reply.code(201).send(created);
} catch (error) {
try {
await daemonDeleteDatabase(buildNodeConnection(server), {
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
});
} catch (cleanupError) {
request.log.error(
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
'Failed to roll back node-local MySQL database after panel insert failure',
);
}
request.log.error(
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
'Failed to persist managed MySQL database metadata',
);
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
}
});
app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => {
const { orgId, serverId, databaseId } = request.params as {
databaseId: string;
orgId: string;
serverId: string;
};
await requirePermission(request, orgId, 'server.update');
const body = request.body as { name?: string; password?: string };
const [current] = await app.db
.select({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
createdAt: serverDatabases.createdAt,
updatedAt: serverDatabases.updatedAt,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(serverDatabases)
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.where(
and(
eq(serverDatabases.id, databaseId),
eq(serverDatabases.serverId, serverId),
eq(servers.organizationId, orgId),
),
);
if (!current) {
throw AppError.notFound('Database not found');
}
const nextName = body.name === undefined ? undefined : body.name.trim();
if (body.name !== undefined && !nextName) {
throw AppError.badRequest('Database name is required');
}
const nextPassword = body.password?.trim();
if (!nextName && !nextPassword) {
return current;
}
if (nextPassword) {
try {
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
password: nextPassword,
username: current.username,
});
} catch (error) {
request.log.error(
{ error, orgId, serverId, databaseId, username: current.username },
'Failed to rotate node-local MySQL password',
);
throw new AppError(
502,
daemonErrorMessage(error, 'Failed to rotate database password'),
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
);
}
}
const patch: Record<string, unknown> = {
updatedAt: new Date(),
};
if (nextName) patch.name = nextName;
if (nextPassword) patch.password = nextPassword;
const [updated] = await app.db
.update(serverDatabases)
.set(patch)
.where(eq(serverDatabases.id, databaseId))
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.database.update',
metadata: {
databaseId,
updatedName: nextName ?? undefined,
passwordRotated: Boolean(nextPassword),
},
});
return updated;
});
return updated;
},
);
app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => {
const { orgId, serverId, databaseId } = request.params as {
+7 -7
View File
@@ -121,9 +121,7 @@ export default async function fileRoutes(app: FastifyInstance) {
return {
data:
requestedEncoding === 'base64'
? payload.toString('base64')
: payload.toString('utf8'),
requestedEncoding === 'base64' ? payload.toString('base64') : payload.toString('utf8'),
encoding: requestedEncoding,
mimeType,
};
@@ -196,9 +194,7 @@ export default async function fileRoutes(app: FastifyInstance) {
return [
path,
path.trim().startsWith('/')
? `/${managedFile.shadowPath}`
: managedFile.shadowPath,
path.trim().startsWith('/') ? `/${managedFile.shadowPath}` : managedFile.shadowPath,
];
});
@@ -208,7 +204,11 @@ export default async function fileRoutes(app: FastifyInstance) {
);
}
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
async function getServerContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{
serverUuid: string;
gameSlug: string;
node: DaemonNodeConnection;
+5 -1
View File
@@ -33,7 +33,11 @@ export default async function playerRoutes(app: FastifyInstance) {
});
}
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
async function getServerContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{
serverUuid: string;
node: DaemonNodeConnection;
}> {
+172 -160
View File
@@ -19,11 +19,7 @@ import {
daemonWriteFile,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
import {
searchSpigetPlugins,
getSpigetResource,
getSpigetDownloadUrl,
} from '../../lib/spiget.js';
import { searchSpigetPlugins, getSpigetResource, getSpigetDownloadUrl } from '../../lib/spiget.js';
import { resolveArtifactDownloadUrl } from '../../lib/cdn.js';
import * as unzipper from 'unzipper';
@@ -255,10 +251,20 @@ function parseBooleanLike(input: unknown): boolean | null {
}
if (typeof input === 'string') {
const normalized = input.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
if (
normalized === 'true' ||
normalized === '1' ||
normalized === 'yes' ||
normalized === 'on'
) {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
if (
normalized === 'false' ||
normalized === '0' ||
normalized === 'no' ||
normalized === 'off'
) {
return false;
}
}
@@ -338,10 +344,12 @@ function validateInstallOptions(
return normalized;
}
function chooseBestRelease<T extends {
channel: string;
isPublished: boolean;
}>(releases: T[], autoChannel: ReleaseChannel): T | null {
function chooseBestRelease<
T extends {
channel: string;
isPublished: boolean;
},
>(releases: T[], autoChannel: ReleaseChannel): T | null {
for (const release of releases) {
if (!release.isPublished) continue;
const releaseChannel = resolveChannel(release.channel);
@@ -394,11 +402,7 @@ async function getServerPluginContext(
};
}
async function getPluginForGame(
app: FastifyInstance,
pluginId: string,
gameId: string,
) {
async function getPluginForGame(app: FastifyInstance, pluginId: string, gameId: string) {
const plugin = await app.db.query.plugins.findFirst({
where: and(eq(plugins.id, pluginId), eq(plugins.gameId, gameId)),
});
@@ -422,10 +426,7 @@ async function getPluginReleaseForPlugin(
return release;
}
async function listPublishedPluginReleases(
app: FastifyInstance,
pluginId: string,
) {
async function listPublishedPluginReleases(app: FastifyInstance, pluginId: string) {
return app.db
.select()
.from(pluginReleases)
@@ -484,17 +485,16 @@ async function downloadPluginArtifact(downloadUrl: string): Promise<Buffer> {
return body;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
502,
'Unable to download plugin artifact',
'PLUGIN_DOWNLOAD_FAILED',
);
throw new AppError(502, 'Unable to download plugin artifact', 'PLUGIN_DOWNLOAD_FAILED');
} finally {
clearTimeout(timeout);
}
}
async function extractZipArtifact(buffer: Buffer, destination: string): Promise<Array<{ path: string; data: Buffer }>> {
async function extractZipArtifact(
buffer: Buffer,
destination: string,
): Promise<Array<{ path: string; data: Buffer }>> {
const archive = await unzipper.Open.buffer(buffer);
const files: Array<{ path: string; data: Buffer }> = [];
@@ -545,15 +545,13 @@ async function insertServerPluginFileRows(
): Promise<void> {
if (paths.length === 0) return;
await app.db
.insert(serverPluginFiles)
.values(
uniqPaths(paths).map((path) => ({
serverPluginId,
path,
kind,
})),
);
await app.db.insert(serverPluginFiles).values(
uniqPaths(paths).map((path) => ({
serverPluginId,
path,
kind,
})),
);
}
async function installReleaseArtifacts(
@@ -643,9 +641,7 @@ async function removeInstalledPluginFiles(
.from(serverPluginFiles)
.where(eq(serverPluginFiles.serverPluginId, installId));
const candidates = tracked.length > 0
? tracked.map((row) => row.path)
: fallbackPaths;
const candidates = tracked.length > 0 ? tracked.map((row) => row.path) : fallbackPaths;
const pathsToDelete = uniqPaths(candidates).filter((path) => !preserveSet.has(path));
@@ -673,17 +669,12 @@ async function syncInstalledPluginConfigFiles(
.select({ path: serverPluginFiles.path })
.from(serverPluginFiles)
.where(
and(
eq(serverPluginFiles.serverPluginId, installId),
eq(serverPluginFiles.kind, 'config'),
),
and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')),
);
const nextPaths = uniqPaths(configPaths);
const nextPathSet = new Set(nextPaths);
const stalePaths = tracked
.map((row) => row.path)
.filter((path) => !nextPathSet.has(path));
const stalePaths = tracked.map((row) => row.path).filter((path) => !nextPathSet.has(path));
if (stalePaths.length > 0) {
try {
@@ -704,10 +695,7 @@ async function syncInstalledPluginConfigFiles(
await app.db
.delete(serverPluginFiles)
.where(
and(
eq(serverPluginFiles.serverPluginId, installId),
eq(serverPluginFiles.kind, 'config'),
),
and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')),
);
await insertServerPluginFileRows(app, installId, nextPaths, 'config');
@@ -830,10 +818,7 @@ async function installPluginForServer(
}
const existing = await app.db.query.serverPlugins.findFirst({
where: and(
eq(serverPlugins.serverId, context.serverId),
eq(serverPlugins.pluginId, plugin.id),
),
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
});
if (existing) {
throw AppError.conflict('Plugin is already installed');
@@ -902,21 +887,22 @@ export default async function pluginRoutes(app: FastifyInstance) {
.where(eq(serverPlugins.serverId, serverId));
const pluginIds = uniqPaths(installed.map((row) => row.pluginId));
const releases = pluginIds.length > 0
? await app.db
.select({
id: pluginReleases.id,
pluginId: pluginReleases.pluginId,
version: pluginReleases.version,
channel: pluginReleases.channel,
installSchema: pluginReleases.installSchema,
isPublished: pluginReleases.isPublished,
createdAt: pluginReleases.createdAt,
})
.from(pluginReleases)
.where(inArray(pluginReleases.pluginId, pluginIds))
.orderBy(desc(pluginReleases.createdAt))
: [];
const releases =
pluginIds.length > 0
? await app.db
.select({
id: pluginReleases.id,
pluginId: pluginReleases.pluginId,
version: pluginReleases.version,
channel: pluginReleases.channel,
installSchema: pluginReleases.installSchema,
isPublished: pluginReleases.isPublished,
createdAt: pluginReleases.createdAt,
})
.from(pluginReleases)
.where(inArray(pluginReleases.pluginId, pluginIds))
.orderBy(desc(pluginReleases.createdAt))
: [];
const releasesByPlugin = new Map<string, typeof releases>();
for (const release of releases) {
@@ -929,15 +915,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
plugins: installed.map((row) => {
const releaseList = releasesByPlugin.get(row.pluginId) ?? [];
const currentRelease = row.releaseId
? releaseList.find((release) => release.id === row.releaseId) ?? null
? (releaseList.find((release) => release.id === row.releaseId) ?? null)
: null;
const currentChannel = resolveChannel(row.autoUpdateChannel);
const latestAllowed = chooseBestRelease(releaseList, currentChannel);
const updateAvailable = Boolean(
!row.isPinned &&
latestAllowed &&
row.releaseId &&
latestAllowed.id !== row.releaseId,
!row.isPinned && latestAllowed && row.releaseId && latestAllowed.id !== row.releaseId,
);
return {
@@ -1007,25 +990,31 @@ export default async function pluginRoutes(app: FastifyInstance) {
.where(eq(serverPlugins.serverId, context.serverId));
const pluginIds = catalog.map((plugin) => plugin.id);
const releaseRows = pluginIds.length > 0
? await app.db
.select({
id: pluginReleases.id,
pluginId: pluginReleases.pluginId,
version: pluginReleases.version,
channel: pluginReleases.channel,
artifactType: pluginReleases.artifactType,
artifactUrl: pluginReleases.artifactUrl,
destination: pluginReleases.destination,
fileName: pluginReleases.fileName,
installSchema: pluginReleases.installSchema,
isPublished: pluginReleases.isPublished,
createdAt: pluginReleases.createdAt,
})
.from(pluginReleases)
.where(and(inArray(pluginReleases.pluginId, pluginIds), eq(pluginReleases.isPublished, true)))
.orderBy(desc(pluginReleases.createdAt))
: [];
const releaseRows =
pluginIds.length > 0
? await app.db
.select({
id: pluginReleases.id,
pluginId: pluginReleases.pluginId,
version: pluginReleases.version,
channel: pluginReleases.channel,
artifactType: pluginReleases.artifactType,
artifactUrl: pluginReleases.artifactUrl,
destination: pluginReleases.destination,
fileName: pluginReleases.fileName,
installSchema: pluginReleases.installSchema,
isPublished: pluginReleases.isPublished,
createdAt: pluginReleases.createdAt,
})
.from(pluginReleases)
.where(
and(
inArray(pluginReleases.pluginId, pluginIds),
eq(pluginReleases.isPublished, true),
),
)
.orderBy(desc(pluginReleases.createdAt))
: [];
const releaseByPlugin = new Map<string, typeof releaseRows>();
for (const row of releaseRows) {
@@ -1034,9 +1023,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
releaseByPlugin.set(row.pluginId, list);
}
const installedByPluginId = new Map(
installedRows.map((row) => [row.pluginId, row]),
);
const installedByPluginId = new Map(installedRows.map((row) => [row.pluginId, row]));
const needle = q?.trim().toLowerCase();
const filtered = needle
@@ -1133,10 +1120,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
}
const existing = await app.db.query.plugins.findFirst({
where: and(
eq(plugins.gameId, context.gameId),
eq(plugins.slug, normalizedSlug),
),
where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, normalizedSlug)),
});
if (existing) {
throw AppError.conflict('A plugin with this slug already exists for the game');
@@ -1203,18 +1187,18 @@ export default async function pluginRoutes(app: FastifyInstance) {
const context = await getServerPluginContext(app, orgId, serverId);
const existing = await getPluginForGame(app, pluginId, context.gameId);
const nextSlug = body.slug !== undefined
? toSlug(body.slug)
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
const nextSlug =
body.slug !== undefined
? toSlug(body.slug)
: body.name !== undefined
? toSlug(body.name)
: existing.slug;
if (!nextSlug) {
throw AppError.badRequest('Plugin slug is invalid');
}
const duplicate = await app.db.query.plugins.findFirst({
where: and(
eq(plugins.gameId, context.gameId),
eq(plugins.slug, nextSlug),
),
where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, nextSlug)),
});
if (duplicate && duplicate.id !== existing.id) {
throw AppError.conflict('A plugin with this slug already exists for the game');
@@ -1338,16 +1322,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
serverId: Type.String({ format: 'uuid' }),
pluginId: Type.String({ format: 'uuid' }),
}),
body: Type.Optional(Type.Object({
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
pinVersion: Type.Optional(Type.Boolean()),
autoUpdateChannel: Type.Optional(Type.Union([
Type.Literal('stable'),
Type.Literal('beta'),
Type.Literal('alpha'),
])),
})),
body: Type.Optional(
Type.Object({
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
pinVersion: Type.Optional(Type.Boolean()),
autoUpdateChannel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
}),
),
},
},
async (request) => {
@@ -1368,7 +1352,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
const plugin = await getPluginForGame(app, pluginId, context.gameId);
const existing = await app.db.query.serverPlugins.findFirst({
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
where: and(
eq(serverPlugins.serverId, context.serverId),
eq(serverPlugins.pluginId, plugin.id),
),
});
if (existing) {
throw AppError.conflict('Plugin is already installed');
@@ -1471,7 +1458,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
},
async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { resourceId } = request.body as { resourceId: number; options?: Record<string, unknown> };
const { resourceId } = request.body as {
resourceId: number;
options?: Record<string, unknown>;
};
await requirePermission(request, orgId, 'plugin.manage');
const context = await getServerPluginContext(app, orgId, serverId);
@@ -1508,7 +1498,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
plugin = created!;
}
const releaseVersion = resource.version ? String(resource.version.id) : `spiget-${Date.now()}`;
const releaseVersion = resource.version
? String(resource.version.id)
: `spiget-${Date.now()}`;
let release = await app.db.query.pluginReleases.findFirst({
where: and(
eq(pluginReleases.pluginId, plugin.id),
@@ -1533,19 +1525,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
}
const existing = await app.db.query.serverPlugins.findFirst({
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
where: and(
eq(serverPlugins.serverId, context.serverId),
eq(serverPlugins.pluginId, plugin.id),
),
});
if (existing) {
throw AppError.conflict('Plugin is already installed');
}
const installResult = await installPluginReleaseForServer(
app,
context,
plugin,
release,
{},
);
const installResult = await installPluginReleaseForServer(app, context, plugin, release, {});
const [installed] = await app.db
.insert(serverPlugins)
@@ -1614,9 +1603,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
? normalizeAbsolutePath(filePath)
: joinAbsolutePath(pluginInstallDirectory(context.gameSlug), filePath);
let plugin = pluginId
? await getPluginForGame(app, pluginId, context.gameId)
: null;
let plugin = pluginId ? await getPluginForGame(app, pluginId, context.gameId) : null;
if (!plugin) {
const slug = toSlug(name);
@@ -1642,7 +1629,10 @@ export default async function pluginRoutes(app: FastifyInstance) {
}
const existingInstall = await app.db.query.serverPlugins.findFirst({
where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)),
where: and(
eq(serverPlugins.serverId, context.serverId),
eq(serverPlugins.pluginId, plugin.id),
),
});
if (existingInstall) {
throw AppError.conflict('Plugin is already installed');
@@ -1669,7 +1659,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
organizationId: orgId,
serverId,
action: 'plugin.install',
metadata: { pluginId: plugin.id, name: plugin.name, source: 'manual', filePath: normalizedPath },
metadata: {
pluginId: plugin.id,
name: plugin.name,
source: 'manual',
filePath: normalizedPath,
},
});
return installed;
@@ -1710,24 +1705,27 @@ export default async function pluginRoutes(app: FastifyInstance) {
.from(serverPlugins)
.innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id))
.leftJoin(pluginReleases, eq(serverPlugins.releaseId, pluginReleases.id))
.where(and(
eq(serverPlugins.id, pluginInstallId),
eq(serverPlugins.serverId, context.serverId),
));
.where(
and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)),
);
if (!installed) {
throw AppError.notFound('Plugin installation not found');
}
const fallbackPath = installed.releaseArtifactUrl
? resolveReleaseFilePath(context.gameSlug, {
id: installed.pluginId,
slug: installed.pluginSlug,
}, {
artifactUrl: installed.releaseArtifactUrl,
destination: installed.releaseDestination,
fileName: installed.releaseFileName,
})
? resolveReleaseFilePath(
context.gameSlug,
{
id: installed.pluginId,
slug: installed.pluginSlug,
},
{
artifactUrl: installed.releaseArtifactUrl,
destination: installed.releaseDestination,
fileName: installed.releaseFileName,
},
)
: pluginFilePath(context.gameSlug, {
id: installed.pluginId,
slug: installed.pluginSlug,
@@ -1809,16 +1807,16 @@ export default async function pluginRoutes(app: FastifyInstance) {
serverId: Type.String({ format: 'uuid' }),
pluginInstallId: Type.String({ format: 'uuid' }),
}),
body: Type.Optional(Type.Object({
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
pinVersion: Type.Optional(Type.Boolean()),
autoUpdateChannel: Type.Optional(Type.Union([
Type.Literal('stable'),
Type.Literal('beta'),
Type.Literal('alpha'),
])),
})),
body: Type.Optional(
Type.Object({
releaseId: Type.Optional(Type.String({ format: 'uuid' })),
options: Type.Optional(Type.Record(Type.String(), Type.Any())),
pinVersion: Type.Optional(Type.Boolean()),
autoUpdateChannel: Type.Optional(
Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]),
),
}),
),
},
},
async (request) => {
@@ -1847,7 +1845,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
autoUpdateChannel: serverPlugins.autoUpdateChannel,
})
.from(serverPlugins)
.where(and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)));
.where(
and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)),
);
if (!installed) {
throw AppError.notFound('Plugin installation not found');
@@ -1880,8 +1880,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
const nextPinned = body.pinVersion ?? installed.isPinned;
const nextAutoUpdateChannel = body.autoUpdateChannel ?? installed.autoUpdateChannel;
const hasMetadataChanges =
nextPinned !== installed.isPinned ||
nextAutoUpdateChannel !== installed.autoUpdateChannel;
nextPinned !== installed.isPinned || nextAutoUpdateChannel !== installed.autoUpdateChannel;
if (!releaseChanged && !hasOptionChanges && !hasMetadataChanges) {
throw AppError.conflict('Plugin is already on the selected release');
@@ -1936,11 +1935,24 @@ export default async function pluginRoutes(app: FastifyInstance) {
mergedOptions,
);
const newPaths = uniqPaths([...installResult.artifactPaths, ...installResult.configPaths]);
const newPaths = uniqPaths([
...installResult.artifactPaths,
...installResult.configPaths,
]);
await removeInstalledPluginFiles(app, context, installed.installId, [], newPaths);
await insertServerPluginFileRows(app, installed.installId, installResult.artifactPaths, 'artifact');
await insertServerPluginFileRows(app, installed.installId, installResult.configPaths, 'config');
await insertServerPluginFileRows(
app,
installed.installId,
installResult.artifactPaths,
'artifact',
);
await insertServerPluginFileRows(
app,
installed.installId,
installResult.configPaths,
'config',
);
nextInstallOptions = installResult.installOptions;
} else {
const configureResult = await configurePluginReleaseForServer(
+35 -29
View File
@@ -30,11 +30,7 @@ const TaskParamSchema = {
const CreateScheduleBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 255 }),
action: Type.Union([
Type.Literal('command'),
Type.Literal('power'),
Type.Literal('backup'),
]),
action: Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]),
payload: Type.String({ minLength: 1 }),
scheduleType: Type.Union([
Type.Literal('interval'),
@@ -131,34 +127,40 @@ export default async function scheduleRoutes(app: FastifyInstance) {
});
// PATCH /schedules/:taskId — update a scheduled task
app.patch('/:taskId', { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, async (request) => {
const { orgId, serverId, taskId } = request.params as {
orgId: string;
serverId: string;
taskId: string;
};
await requirePermission(request, orgId, 'schedule.manage');
app.patch(
'/:taskId',
{ schema: { ...TaskParamSchema, body: UpdateScheduleBody } },
async (request) => {
const { orgId, serverId, taskId } = request.params as {
orgId: string;
serverId: string;
taskId: string;
};
await requirePermission(request, orgId, 'schedule.manage');
const body = request.body as Record<string, unknown>;
const body = request.body as Record<string, unknown>;
const existing = await app.db.query.scheduledTasks.findFirst({
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
});
if (!existing) throw AppError.notFound('Scheduled task not found');
const existing = await app.db.query.scheduledTasks.findFirst({
where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)),
});
if (!existing) throw AppError.notFound('Scheduled task not found');
// Recompute next run if schedule changed
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
const scheduleData = (body.scheduleData as Record<string, unknown>) || (existing.scheduleData as Record<string, unknown>);
const nextRun = computeNextRun(scheduleType, scheduleData);
// Recompute next run if schedule changed
const scheduleType = (body.scheduleType as string) || existing.scheduleType;
const scheduleData =
(body.scheduleData as Record<string, unknown>) ||
(existing.scheduleData as Record<string, unknown>);
const nextRun = computeNextRun(scheduleType, scheduleData);
const [updated] = await app.db
.update(scheduledTasks)
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
.where(eq(scheduledTasks.id, taskId))
.returning();
const [updated] = await app.db
.update(scheduledTasks)
.set({ ...body, nextRunAt: nextRun, updatedAt: new Date() })
.where(eq(scheduledTasks.id, taskId))
.returning();
return updated;
});
return updated;
},
);
// DELETE /schedules/:taskId — delete a scheduled task
app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => {
@@ -223,7 +225,11 @@ export default async function scheduleRoutes(app: FastifyInstance) {
});
}
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
async function getServerContext(
app: FastifyInstance,
orgId: string,
serverId: string,
): Promise<{
serverUuid: string;
node: DaemonNodeConnection;
}> {