Files
backspace/packages/server/src/routes/settings.ts
T
Jannis Braun 8d7ba33c21 feat(settings): expose federatedRegistrationOpen in /settings/instance + /instance/info
Surfaces the federatedRegistrationOpen flag (Task 1 schema column) on the
admin settings GET/PATCH endpoints and the public /api/instance/info
endpoint. Closes the 3 deferred TypeScript errors from Task 2 by
populating the now-required InstanceAdminSettings/InstanceInfoResponse
field.

Adds smoke tests (routes/instance.test.ts, routes/settings.test.ts) that
lock in the JSON contract the Connections UI (Task 21) and admin
RegistrationPanel (Task 15) consume, plus boolean-validation coverage
for the PATCH path. Updates docs/systems/admin.md with the new field in
both InstanceAdminSettings and the public info response schema.
2026-04-28 21:01:50 +02:00

301 lines
14 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate, requireAdmin } from '../utils/auth.js';
import { config } from '../config.js';
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared';
import { STANDARD_RESOLUTIONS, STANDARD_FRAMERATES, BITRATE_MATRIX_KBPS } from '@backspace/shared/src/constants.js';
function rowToLimits(row: typeof schema.instanceSettings.$inferSelect): InstanceStreamingLimits {
return {
maxBitrateKbps: row.maxBitrateKbps,
minBitrateKbps: row.minBitrateKbps,
bitrateStepKbps: row.bitrateStepKbps,
allowedResolutions: row.allowedResolutions.split(',')
.map((s) => s.trim())
.map((s) => s === 'native' ? 'native' as const : Number(s))
.filter((v): v is number | 'native' =>
v === 'native' || (typeof v === 'number' && !isNaN(v) && (STANDARD_RESOLUTIONS as readonly number[]).includes(v))
),
allowedFramerates: row.allowedFramerates.split(',')
.map(Number)
.filter((n) => (STANDARD_FRAMERATES as readonly number[]).includes(n)),
maxResolution: row.maxResolution,
maxFramerate: row.maxFramerate,
discoveryEnabled: row.discoveryEnabled === 1,
bitrateMatrixOverrides: (() => {
const raw = row.bitrateMatrixOverrides as string | null;
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
return Object.keys(parsed).length > 0 ? parsed as Record<string, number> : null;
} catch { return null; }
})(),
allowCustomBitrate: row.allowCustomBitrate === 1,
};
}
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/settings/streaming — any authenticated user can read instance limits
app.get('/api/settings/streaming', { preHandler: authenticate }, async (_request, reply) => {
const db = getDb();
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!row) {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
}
return reply.code(200).send(rowToLimits(row));
});
// PATCH /api/settings/streaming — admin only
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
const db = getDb();
const body = request.body;
const updateData: Record<string, number | string | null> = { updatedAt: Date.now() };
if (body.maxBitrateKbps !== undefined) {
if (typeof body.maxBitrateKbps !== 'number' || body.maxBitrateKbps < 500 || body.maxBitrateKbps > 1000000) {
return reply.code(400).send({ error: 'maxBitrateKbps must be between 500 and 1000000', statusCode: 400 });
}
updateData.maxBitrateKbps = body.maxBitrateKbps;
}
if (body.minBitrateKbps !== undefined) {
if (typeof body.minBitrateKbps !== 'number' || body.minBitrateKbps < 100 || body.minBitrateKbps > 1000000) {
return reply.code(400).send({ error: 'minBitrateKbps must be between 100 and 1000000', statusCode: 400 });
}
updateData.minBitrateKbps = body.minBitrateKbps;
}
if (body.bitrateStepKbps !== undefined) {
if (typeof body.bitrateStepKbps !== 'number' || body.bitrateStepKbps < 50 || body.bitrateStepKbps > 5000) {
return reply.code(400).send({ error: 'bitrateStepKbps must be between 50 and 5000', statusCode: 400 });
}
updateData.bitrateStepKbps = body.bitrateStepKbps;
}
if (body.allowedResolutions !== undefined) {
if (!Array.isArray(body.allowedResolutions) || body.allowedResolutions.length === 0) {
return reply.code(400).send({ error: 'allowedResolutions must be a non-empty array', statusCode: 400 });
}
const invalid = body.allowedResolutions.filter((r) =>
r !== 'native' && !(STANDARD_RESOLUTIONS as readonly number[]).includes(r as number)
);
if (invalid.length > 0) {
return reply.code(400).send({ error: `Invalid resolutions: ${invalid.join(', ')}. Allowed: ${[...STANDARD_RESOLUTIONS, 'native'].join(', ')}`, statusCode: 400 });
}
// Serialize: numbers sorted ascending, 'native' always last
const nums = body.allowedResolutions.filter((r): r is number => r !== 'native').sort((a, b) => a - b);
const hasNative = body.allowedResolutions.includes('native');
updateData.allowedResolutions = [...nums, ...(hasNative ? ['native'] : [])].join(',');
}
if (body.allowedFramerates !== undefined) {
if (!Array.isArray(body.allowedFramerates) || body.allowedFramerates.length === 0) {
return reply.code(400).send({ error: 'allowedFramerates must be a non-empty array', statusCode: 400 });
}
const invalid = body.allowedFramerates.filter((f) => !(STANDARD_FRAMERATES as readonly number[]).includes(f));
if (invalid.length > 0) {
return reply.code(400).send({ error: `Invalid framerates: ${invalid.join(', ')}. Allowed: ${STANDARD_FRAMERATES.join(', ')}`, statusCode: 400 });
}
updateData.allowedFramerates = body.allowedFramerates.sort((a, b) => a - b).join(',');
}
if (body.maxResolution !== undefined) {
if (!(STANDARD_RESOLUTIONS as readonly number[]).includes(body.maxResolution)) {
return reply.code(400).send({ error: `maxResolution must be one of: ${STANDARD_RESOLUTIONS.join(', ')}`, statusCode: 400 });
}
updateData.maxResolution = body.maxResolution;
}
if (body.maxFramerate !== undefined) {
if (!(STANDARD_FRAMERATES as readonly number[]).includes(body.maxFramerate)) {
return reply.code(400).send({ error: `maxFramerate must be one of: ${STANDARD_FRAMERATES.join(', ')}`, statusCode: 400 });
}
updateData.maxFramerate = body.maxFramerate;
}
if (body.discoveryEnabled !== undefined) {
updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0;
}
if (body.allowCustomBitrate !== undefined) {
updateData.allowCustomBitrate = body.allowCustomBitrate ? 1 : 0;
}
if (body.bitrateMatrixOverrides !== undefined) {
if (body.bitrateMatrixOverrides === null) {
updateData.bitrateMatrixOverrides = null;
} else if (typeof body.bitrateMatrixOverrides !== 'object' || Array.isArray(body.bitrateMatrixOverrides)) {
return reply.code(400).send({ error: 'bitrateMatrixOverrides must be an object or null', statusCode: 400 });
} else {
// Validate each key and value
const validKeys = new Set<string>();
for (const res of STANDARD_RESOLUTIONS) {
for (const fps of STANDARD_FRAMERATES) {
validKeys.add(`${res}_${fps}`);
}
}
for (const [key, value] of Object.entries(body.bitrateMatrixOverrides)) {
if (!validKeys.has(key)) {
return reply.code(400).send({ error: `Invalid matrix key: "${key}". Keys must be {resolution}_{framerate}, e.g. "1080_60"`, statusCode: 400 });
}
if (typeof value !== 'number' || value <= 0 || value > 1000000) {
return reply.code(400).send({ error: `Invalid value for "${key}": must be a positive number up to 1000000 kbps`, statusCode: 400 });
}
}
updateData.bitrateMatrixOverrides = JSON.stringify(body.bitrateMatrixOverrides);
}
}
// Cross-field validation: min < max
const currentRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!currentRow) {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
}
const effectiveMin = (updateData.minBitrateKbps as number | undefined) ?? currentRow.minBitrateKbps;
const effectiveMax = (updateData.maxBitrateKbps as number | undefined) ?? currentRow.maxBitrateKbps;
if (effectiveMin >= effectiveMax) {
return reply.code(400).send({ error: 'minBitrateKbps must be less than maxBitrateKbps', statusCode: 400 });
}
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!updatedRow) {
return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 });
}
return reply.code(200).send(rowToLimits(updatedRow));
});
// GET /api/settings/instance — admin only, returns instance admin settings
app.get('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
const db = getDb();
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!row) {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
}
const gifKey = row.gifApiKey as string | null;
const maxUploadBytes = row.maxUploadSizeBytes ?? config.maxUploadSize;
const response: InstanceAdminSettings = {
instanceName: row.instanceName ?? 'Backspace',
registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen,
federatedRegistrationOpen: row.federatedRegistrationOpen === 1,
discoveryEnabled: row.discoveryEnabled === 1,
gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined,
gifEnabled: !!gifKey,
maxUploadSizeMb: Math.round(maxUploadBytes / (1024 * 1024)),
federationRelayEnabled: row.federationRelayEnabled === 1,
federationRelayTtlDays: row.federationRelayTtlDays,
defaultAutoRotateIntervalDays: row.defaultAutoRotateIntervalDays,
autoAcceptPeering: row.autoAcceptPeering === 1,
};
return reply.code(200).send(response);
});
// PATCH /api/settings/instance — admin only, updates instance admin settings
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
const db = getDb();
const body = request.body;
const updateData: Record<string, number | string | null> = { updatedAt: Date.now() };
if (body.instanceName !== undefined) {
if (typeof body.instanceName !== 'string' || body.instanceName.trim().length === 0 || body.instanceName.trim().length > 32) {
return reply.code(400).send({ error: 'Instance name must be 1-32 characters', statusCode: 400 });
}
updateData.instanceName = body.instanceName.trim();
}
if (body.registrationOpen !== undefined) {
updateData.registrationOpen = body.registrationOpen ? 1 : 0;
}
if (body.federatedRegistrationOpen !== undefined) {
if (typeof body.federatedRegistrationOpen !== 'boolean') {
return reply.code(400).send({ error: 'federatedRegistrationOpen must be boolean', statusCode: 400 });
}
updateData.federatedRegistrationOpen = body.federatedRegistrationOpen ? 1 : 0;
}
if (body.discoveryEnabled !== undefined) {
updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0;
}
if (body.gifApiKey !== undefined) {
// Skip masked placeholder values — the GET endpoint returns '****xxxx' for security,
// so if the client sends that back unchanged, don't corrupt the real key
if (typeof body.gifApiKey === 'string' && body.gifApiKey.startsWith('****')) {
// Masked value — ignore, keep existing key
} else {
// Allow empty string to clear the key
updateData.gifApiKey = body.gifApiKey ? body.gifApiKey.trim() : null;
}
}
if (body.maxUploadSizeMb !== undefined) {
const mb = Number(body.maxUploadSizeMb);
const MAX_MB = Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024));
if (!Number.isFinite(mb) || !Number.isInteger(mb) || mb < 1 || mb > MAX_MB) {
return reply.code(400).send({ error: `maxUploadSizeMb must be a positive integer (1 - ${MAX_MB})`, statusCode: 400 });
}
updateData.maxUploadSizeBytes = mb * 1024 * 1024;
}
if (body.federationRelayEnabled !== undefined) {
updateData.federationRelayEnabled = body.federationRelayEnabled ? 1 : 0;
}
if (body.federationRelayTtlDays !== undefined) {
const ttl = Number(body.federationRelayTtlDays);
if (isNaN(ttl) || !Number.isInteger(ttl) || ttl < 1 || ttl > 365) {
return reply.code(400).send({ error: 'federationRelayTtlDays must be an integer between 1 and 365', statusCode: 400 });
}
updateData.federationRelayTtlDays = ttl;
}
if (body.defaultAutoRotateIntervalDays !== undefined) {
const interval = Number(body.defaultAutoRotateIntervalDays);
if (isNaN(interval) || !Number.isInteger(interval) || interval < 1 || interval > 365) {
return reply.code(400).send({ error: 'defaultAutoRotateIntervalDays must be an integer between 1 and 365', statusCode: 400 });
}
updateData.defaultAutoRotateIntervalDays = interval;
}
if (body.autoAcceptPeering !== undefined) {
updateData.autoAcceptPeering = body.autoAcceptPeering ? 1 : 0;
}
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!updatedRow) {
return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 });
}
const updatedGifKey = updatedRow.gifApiKey as string | null;
const updatedMaxUploadBytes = updatedRow.maxUploadSizeBytes ?? config.maxUploadSize;
const response: InstanceAdminSettings = {
instanceName: updatedRow.instanceName ?? 'Backspace',
registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen,
federatedRegistrationOpen: updatedRow.federatedRegistrationOpen === 1,
discoveryEnabled: updatedRow.discoveryEnabled === 1,
gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined,
gifEnabled: !!updatedGifKey,
maxUploadSizeMb: Math.round(updatedMaxUploadBytes / (1024 * 1024)),
federationRelayEnabled: updatedRow.federationRelayEnabled === 1,
federationRelayTtlDays: updatedRow.federationRelayTtlDays,
defaultAutoRotateIntervalDays: updatedRow.defaultAutoRotateIntervalDays,
autoAcceptPeering: updatedRow.autoAcceptPeering === 1,
};
return reply.code(200).send(response);
});
}