add registration

This commit is contained in:
KingKDot
2026-04-19 18:24:50 -05:00
parent 86c1897406
commit d12dc04709
15 changed files with 1735 additions and 5 deletions
+133
View File
@@ -0,0 +1,133 @@
import { logger } from "./logger";
import { getConfig } from "./config";
interface UserBuildEntry {
buildTimestamps: number[];
activeBuildCount: number;
}
const buildStore = new Map<number, UserBuildEntry>();
let globalActiveBuildCount = 0;
const CLEANUP_INTERVAL = 60 * 1000;
function getPolicy() {
const cfg = getConfig().buildRateLimit;
return {
maxBuildsPerHour: Math.min(100, Math.max(1, Number(cfg.maxBuildsPerHour) || 5)),
maxConcurrentPerUser: Math.min(10, Math.max(1, Number(cfg.maxConcurrentPerUser) || 1)),
globalMaxConcurrent: Math.min(50, Math.max(1, Number(cfg.globalMaxConcurrent) || 3)),
};
}
export function canUserBuild(userId: number): {
allowed: boolean;
reason?: string;
retryAfter?: number;
} {
const policy = getPolicy();
const now = Date.now();
const hourAgo = now - 60 * 60 * 1000;
if (globalActiveBuildCount >= policy.globalMaxConcurrent) {
return {
allowed: false,
reason: `Server is at maximum concurrent builds (${policy.globalMaxConcurrent}). Please wait for a build to finish.`,
};
}
const entry = buildStore.get(userId);
if (!entry) {
return { allowed: true };
}
if (entry.activeBuildCount >= policy.maxConcurrentPerUser) {
return {
allowed: false,
reason: `You already have ${entry.activeBuildCount} build(s) running. Max concurrent per user: ${policy.maxConcurrentPerUser}.`,
};
}
const recentBuilds = entry.buildTimestamps.filter((ts) => ts > hourAgo);
if (recentBuilds.length >= policy.maxBuildsPerHour) {
const oldestRecent = Math.min(...recentBuilds);
const retryAfter = Math.ceil((oldestRecent + 60 * 60 * 1000 - now) / 1000);
return {
allowed: false,
reason: `Rate limit exceeded: ${policy.maxBuildsPerHour} builds per hour. Try again in ${retryAfter}s.`,
retryAfter,
};
}
return { allowed: true };
}
export function recordBuildStart(userId: number): void {
const now = Date.now();
const entry = buildStore.get(userId);
if (entry) {
entry.buildTimestamps.push(now);
entry.activeBuildCount++;
} else {
buildStore.set(userId, {
buildTimestamps: [now],
activeBuildCount: 1,
});
}
globalActiveBuildCount++;
logger.debug(
`[build-rate-limit] User ${userId} started build (active: ${buildStore.get(userId)!.activeBuildCount}, global: ${globalActiveBuildCount})`,
);
}
export function recordBuildEnd(userId: number): void {
const entry = buildStore.get(userId);
if (entry && entry.activeBuildCount > 0) {
entry.activeBuildCount--;
}
if (globalActiveBuildCount > 0) {
globalActiveBuildCount--;
}
logger.debug(
`[build-rate-limit] User ${userId} build ended (active: ${entry?.activeBuildCount ?? 0}, global: ${globalActiveBuildCount})`,
);
}
export function getBuildRateLimitStats(): {
globalActive: number;
perUser: Array<{ userId: number; active: number; recentCount: number }>;
} {
const hourAgo = Date.now() - 60 * 60 * 1000;
const perUser: Array<{ userId: number; active: number; recentCount: number }> = [];
for (const [userId, entry] of buildStore.entries()) {
const recentCount = entry.buildTimestamps.filter((ts) => ts > hourAgo).length;
if (entry.activeBuildCount > 0 || recentCount > 0) {
perUser.push({ userId, active: entry.activeBuildCount, recentCount });
}
}
return { globalActive: globalActiveBuildCount, perUser };
}
function cleanupExpired(): void {
const hourAgo = Date.now() - 60 * 60 * 1000;
let cleaned = 0;
for (const [userId, entry] of buildStore.entries()) {
entry.buildTimestamps = entry.buildTimestamps.filter((ts) => ts > hourAgo);
if (entry.buildTimestamps.length === 0 && entry.activeBuildCount <= 0) {
buildStore.delete(userId);
cleaned++;
}
}
if (cleaned > 0) {
logger.debug(`[build-rate-limit] Cleaned up ${cleaned} expired entries`);
}
}
setInterval(cleanupExpired, CLEANUP_INTERVAL);
+128
View File
@@ -64,6 +64,16 @@ export interface Config {
chat: {
retentionDays: number;
};
registration: {
mode: "off" | "open" | "key" | "approval";
defaultRole: "operator" | "viewer";
maxUsersTotal: number;
};
buildRateLimit: {
maxBuildsPerHour: number;
maxConcurrentPerUser: number;
globalMaxConcurrent: number;
};
}
const DEFAULT_CONFIG: Config = {
@@ -126,6 +136,16 @@ const DEFAULT_CONFIG: Config = {
chat: {
retentionDays: 30,
},
registration: {
mode: "off" as const,
defaultRole: "operator" as const,
maxUsersTotal: 0,
},
buildRateLimit: {
maxBuildsPerHour: 5,
maxConcurrentPerUser: 1,
globalMaxConcurrent: 3,
},
};
type SaveSecrets = {
@@ -459,6 +479,40 @@ export function loadConfig(): Config {
fileConfig.chat?.retentionDays ||
DEFAULT_CONFIG.chat.retentionDays,
},
registration: {
mode: (() => {
const envMode = process.env.OVERLORD_REGISTRATION_MODE;
if (envMode && ["off", "open", "key", "approval"].includes(envMode)) return envMode as Config["registration"]["mode"];
const fileMode = fileConfig.registration?.mode;
if (fileMode && ["off", "open", "key", "approval"].includes(fileMode)) return fileMode as Config["registration"]["mode"];
return DEFAULT_CONFIG.registration.mode;
})(),
defaultRole: (() => {
const envRole = process.env.OVERLORD_REGISTRATION_DEFAULT_ROLE;
if (envRole && ["operator", "viewer"].includes(envRole)) return envRole as Config["registration"]["defaultRole"];
const fileRole = fileConfig.registration?.defaultRole;
if (fileRole && ["operator", "viewer"].includes(fileRole)) return fileRole as Config["registration"]["defaultRole"];
return DEFAULT_CONFIG.registration.defaultRole;
})(),
maxUsersTotal:
Number(process.env.OVERLORD_REGISTRATION_MAX_USERS) ||
fileConfig.registration?.maxUsersTotal ||
DEFAULT_CONFIG.registration.maxUsersTotal,
},
buildRateLimit: {
maxBuildsPerHour:
Number(process.env.OVERLORD_BUILD_MAX_PER_HOUR) ||
fileConfig.buildRateLimit?.maxBuildsPerHour ||
DEFAULT_CONFIG.buildRateLimit.maxBuildsPerHour,
maxConcurrentPerUser:
Number(process.env.OVERLORD_BUILD_MAX_CONCURRENT_USER) ||
fileConfig.buildRateLimit?.maxConcurrentPerUser ||
DEFAULT_CONFIG.buildRateLimit.maxConcurrentPerUser,
globalMaxConcurrent:
Number(process.env.OVERLORD_BUILD_MAX_CONCURRENT_GLOBAL) ||
fileConfig.buildRateLimit?.globalMaxConcurrent ||
DEFAULT_CONFIG.buildRateLimit.globalMaxConcurrent,
},
};
if (saveChanged) {
@@ -697,6 +751,68 @@ export async function updateChatConfig(
return next;
}
export async function updateRegistrationConfig(
updates: Partial<Config["registration"]>,
): Promise<Config["registration"]> {
const current = getConfig();
const validModes = ["off", "open", "key", "approval"] as const;
const validRoles = ["operator", "viewer"] as const;
const next: Config["registration"] = {
...current.registration,
...updates,
};
if (!validModes.includes(next.mode as any)) {
next.mode = current.registration.mode;
}
if (!validRoles.includes(next.defaultRole as any)) {
next.defaultRole = current.registration.defaultRole;
}
next.maxUsersTotal = Math.max(0, Math.min(100000, Number(next.maxUsersTotal) || 0));
configCache = {
...current,
registration: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.registration = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateBuildRateLimitConfig(
updates: Partial<Config["buildRateLimit"]>,
): Promise<Config["buildRateLimit"]> {
const current = getConfig();
const toNumberOr = (value: unknown, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const next: Config["buildRateLimit"] = {
...current.buildRateLimit,
...updates,
};
next.maxBuildsPerHour = Math.min(100, Math.max(1, toNumberOr(next.maxBuildsPerHour, 5)));
next.maxConcurrentPerUser = Math.min(10, Math.max(1, toNumberOr(next.maxConcurrentPerUser, 1)));
next.globalMaxConcurrent = Math.min(50, Math.max(1, toNumberOr(next.globalMaxConcurrent, 3)));
configCache = {
...current,
buildRateLimit: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.buildRateLimit = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export function getExportableConfig(serverVersion: string): Record<string, unknown> {
const config = getConfig();
return {
@@ -715,6 +831,8 @@ export function getExportableConfig(serverVersion: string): Record<string, unkno
appearance: config.appearance,
plugins: config.plugins,
chat: config.chat,
registration: config.registration,
buildRateLimit: config.buildRateLimit,
};
}
@@ -778,6 +896,16 @@ export async function importFullConfig(data: Record<string, any>): Promise<{ app
applied.push("chat");
}
if (data.registration && typeof data.registration === "object") {
await updateRegistrationConfig(data.registration);
applied.push("registration");
}
if (data.buildRateLimit && typeof data.buildRateLimit === "object") {
await updateBuildRateLimitConfig(data.buildRateLimit);
applied.push("buildRateLimit");
}
if (data.auth && typeof data.auth === "object") {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
+3
View File
@@ -45,6 +45,7 @@ import { sanitizePluginId } from "./server/plugin-utils";
import { dispatchAutoScriptsForConnection } from "./server/auto-script-dispatch";
import { dispatchAutoDeploysForConnection } from "./server/auto-deploy-dispatch";
import { handleAutoDeployRoutes } from "./server/routes/auto-deploy-routes";
import { handleRegistrationRoutes } from "./server/routes/registration-routes";
import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer";
import { startBuildProcess as runBuildProcess } from "./server/build-process";
import { createHttpFetchHandler } from "./server/http-dispatch";
@@ -477,6 +478,7 @@ async function startServer() {
wsUpgrade: {
isAuthorizedAgentRequest: isAuthorizedAgent,
},
registration: {},
};
function handleBuildTagConnection(clientId: string, buildTagValue: string) {
@@ -658,6 +660,7 @@ async function startServer() {
fetch: createHttpFetchHandler({
metrics,
CORS_HEADERS,
handleRegistrationRoutes,
handleAuthRoutes,
handleNotificationsConfigRoutes,
handleAutoScriptsRoutes,
+10 -2
View File
@@ -33,9 +33,11 @@ type HttpDispatchDeps<
TPage,
TClient,
TWsUpgrade,
TRegistration
> = {
metrics: { withHttpMetrics: (fn: () => Promise<Response>) => Promise<Response> };
CORS_HEADERS: Record<string, string>;
handleRegistrationRoutes: RouteHandlerWithDeps<TRegistration>;
handleAuthRoutes: RouteHandlerWithServer<TServer>;
handleNotificationsConfigRoutes: RouteHandlerWithServerDeps<TServer, TNotificationsConfig>;
handleAutoScriptsRoutes: RouteHandler;
@@ -69,6 +71,7 @@ type HttpDispatchDeps<
page: TPage;
client: TClient;
wsUpgrade: TWsUpgrade;
registration: TRegistration;
};
};
@@ -87,6 +90,7 @@ export function createHttpFetchHandler<
TPage,
TClient,
TWsUpgrade,
TRegistration
>(
deps: HttpDispatchDeps<
TServer,
@@ -102,8 +106,9 @@ export function createHttpFetchHandler<
TAssets,
TPage,
TClient,
TWsUpgrade
>,
TWsUpgrade,
TRegistration
>
) {
return async function fetchHandler(req: Request, server: unknown): Promise<Response> {
return deps.metrics.withHttpMetrics(async () => {
@@ -114,6 +119,9 @@ export function createHttpFetchHandler<
return new Response("", { headers: deps.CORS_HEADERS });
}
const registrationResponse = await deps.handleRegistrationRoutes(req, url, deps.routeDeps.registration);
if (registrationResponse) return registrationResponse;
const authRouteResponse = await deps.handleAuthRoutes(req, url, routeServer);
if (authRouteResponse) return authRouteResponse;
@@ -10,6 +10,8 @@ import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { logger } from "../../logger";
import { normalizeClientOs } from "../deploy-utils";
import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit";
import { getConfig } from "../../config";
import path from "path";
import fs from "fs";
import { resolveRuntimeRoot } from "../runtime-paths";
@@ -44,6 +46,21 @@ export async function handleBuildRoutes(
if (req.method === "POST" && url.pathname === "/api/build/start") {
requirePermission(user, "clients:build");
if (getConfig().registration.mode !== "off") {
const rateLimitResult = canUserBuild(user.userId);
if (!rateLimitResult.allowed) {
return Response.json(
{ error: rateLimitResult.reason || "Build rate limit exceeded" },
{
status: 429,
headers: rateLimitResult.retryAfter
? { "Retry-After": String(rateLimitResult.retryAfter) }
: {},
},
);
}
}
const body = await req.json();
const {
platforms,
@@ -307,6 +324,9 @@ export async function handleBuildRoutes(
safeBoundFiles = validated;
}
const rateLimitActive = getConfig().registration.mode !== "off";
if (rateLimitActive) recordBuildStart(user.userId);
deps.startBuildProcess(buildId, {
platforms: allowedPlatforms,
serverUrl: safeServerUrl,
@@ -343,6 +363,8 @@ export async function handleBuildRoutes(
sleepSeconds: safeSleepSeconds,
boundFiles: safeBoundFiles,
iosBundleId: typeof iosBundleId === "string" && /^[a-zA-Z0-9.-]{1,128}$/.test(iosBundleId.trim()) ? iosBundleId.trim() : undefined,
}).finally(() => {
if (rateLimitActive) recordBuildEnd(user.userId);
});
return Response.json({ buildId });
@@ -1,6 +1,6 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig } from "../../config";
import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig } from "../../config";
import { getClientMetricsSummary, getClientMetricsSummaryForUser } from "../../db";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
@@ -592,5 +592,75 @@ export async function handleMiscRoutes(
return new Response("Certificate not available", { status: 404 });
}
// ── Registration settings (admin only) ──────────────────────────────
if (url.pathname === "/api/settings/registration") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 });
if (req.method === "GET") {
return Response.json({ registration: getConfig().registration }, { headers: deps.CORS_HEADERS });
}
if (req.method === "PUT") {
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const updated = await updateRegistrationConfig({
mode: body?.mode,
defaultRole: body?.defaultRole,
maxUsersTotal: body?.maxUsersTotal !== undefined ? Number(body.maxUsersTotal) : undefined,
});
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: `Updated registration settings (mode: ${updated.mode})`,
success: true,
});
return Response.json({ ok: true, registration: updated }, { headers: deps.CORS_HEADERS });
}
}
// ── Build rate limit settings (admin only) ──────────────────────────
if (url.pathname === "/api/settings/build-rate-limit") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden: Admin access required", { status: 403 });
if (req.method === "GET") {
return Response.json({ buildRateLimit: getConfig().buildRateLimit }, { headers: deps.CORS_HEADERS });
}
if (req.method === "PUT") {
let body: any = {};
try { body = await req.json(); } catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const updated = await updateBuildRateLimitConfig({
maxBuildsPerHour: body?.maxBuildsPerHour !== undefined ? Number(body.maxBuildsPerHour) : undefined,
maxConcurrentPerUser: body?.maxConcurrentPerUser !== undefined ? Number(body.maxConcurrentPerUser) : undefined,
globalMaxConcurrent: body?.globalMaxConcurrent !== undefined ? Number(body.globalMaxConcurrent) : undefined,
});
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: "Updated build rate limit settings",
success: true,
});
return Response.json({ ok: true, buildRateLimit: updated }, { headers: deps.CORS_HEADERS });
}
}
return null;
}
@@ -150,6 +150,11 @@ export async function handlePageRoutes(
return serveFile(deps, "change-password.html");
}
// ---- Registration page (always accessible, no auth needed) ----
if (url.pathname === "/register.html") {
return serveFile(deps, "register.html");
}
// ---- Static pages (table-driven) ----
for (const page of STATIC_PAGES) {
if (url.pathname !== page.path) continue;
@@ -0,0 +1,404 @@
import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { getConfig } from "../../config";
import { logger } from "../../logger";
import {
registerUser,
deleteUser,
createRegistrationKeys,
listRegistrationKeys,
claimRegistrationKey,
deleteRegistrationKey,
createPendingRegistration,
listPendingRegistrations,
approvePendingRegistration,
denyPendingRegistration,
getUserByUsername,
getTotalUserCount,
validatePasswordPolicy,
type UserRole,
} from "../../users";
interface RegRateLimitEntry {
attempts: number;
firstAttempt: number;
}
const regRateLimitStore = new Map<string, RegRateLimitEntry>();
const REG_MAX_ATTEMPTS = 5;
const REG_WINDOW_MS = 60 * 60 * 1000; // 1 hour
function isRegRateLimited(ip: string): boolean {
const entry = regRateLimitStore.get(ip);
if (!entry) return false;
const now = Date.now();
if (now - entry.firstAttempt > REG_WINDOW_MS) {
regRateLimitStore.delete(ip);
return false;
}
return entry.attempts >= REG_MAX_ATTEMPTS;
}
function recordRegAttempt(ip: string): void {
const now = Date.now();
const entry = regRateLimitStore.get(ip);
if (!entry || now - entry.firstAttempt > REG_WINDOW_MS) {
regRateLimitStore.set(ip, { attempts: 1, firstAttempt: now });
} else {
entry.attempts++;
}
}
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of regRateLimitStore.entries()) {
if (now - entry.firstAttempt > REG_WINDOW_MS) {
regRateLimitStore.delete(ip);
}
}
}, 60_000);
type RegistrationRouteDeps = {
requestIP?: (req: Request) => { address?: string } | null | undefined;
};
function getClientIp(req: Request, deps: RegistrationRouteDeps): string {
const fromDeps = deps.requestIP?.(req)?.address;
if (fromDeps) return fromDeps;
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|| req.headers.get("x-real-ip")
|| "unknown";
}
export async function handleRegistrationRoutes(
req: Request,
url: URL,
deps: RegistrationRouteDeps,
): Promise<Response | null> {
if (req.method === "GET" && url.pathname === "/api/registration/status") {
const config = getConfig();
const mode = config.registration.mode;
return Response.json({
enabled: mode !== "off",
mode: mode === "off" ? "off" : mode,
});
}
if (req.method === "POST" && url.pathname === "/api/register") {
const config = getConfig();
const regConfig = config.registration;
const ip = getClientIp(req, deps);
if (regConfig.mode === "off") {
return Response.json({ error: "Registration is disabled" }, { status: 403 });
}
if (isRegRateLimited(ip)) {
return Response.json(
{ error: "Too many registration attempts. Try again later." },
{ status: 429 },
);
}
recordRegAttempt(ip);
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const username = typeof body?.username === "string" ? body.username.trim() : "";
const password = typeof body?.password === "string" ? body.password : "";
const keyValue = typeof body?.key === "string" ? body.key.trim() : "";
if (!username || !password) {
return Response.json({ error: "Username and password are required" }, { status: 400 });
}
if (regConfig.maxUsersTotal > 0) {
const totalUsers = getTotalUserCount();
if (totalUsers >= regConfig.maxUsersTotal) {
return Response.json(
{ error: "Maximum number of registered users has been reached" },
{ status: 403 },
);
}
}
const existing = getUserByUsername(username);
if (existing) {
return Response.json({ error: "Username already exists" }, { status: 409 });
}
const defaultRole = regConfig.defaultRole as UserRole;
if (regConfig.mode === "key") {
if (!keyValue) {
return Response.json({ error: "A registration key is required" }, { status: 400 });
}
const result = await registerUser(username, password, "key", defaultRole);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const claimResult = claimRegistrationKey(keyValue, result.userId!);
if (!claimResult.success) {
deleteUser(result.userId!);
return Response.json({ error: claimResult.error }, { status: 400 });
}
logAudit({
timestamp: Date.now(),
username,
ip,
action: AuditAction.COMMAND,
details: `User registered via key (key: ${claimResult.key.key.substring(0, 8)}...)`,
success: true,
});
logger.info(`[registration] User '${username}' registered via key from ${ip}`);
return Response.json({ ok: true, message: "Account created successfully. You can now sign in." });
}
if (regConfig.mode === "approval") {
if (username.length < 3 || username.length > 32) {
return Response.json({ error: "Username must be between 3 and 32 characters" }, { status: 400 });
}
if (!/^[a-zA-Z0-9_\-]+$/.test(username)) {
return Response.json(
{ error: "Username can only contain letters, numbers, hyphens, and underscores" },
{ status: 400 },
);
}
const policyError = validatePasswordPolicy(password);
if (policyError) {
return Response.json({ error: policyError }, { status: 400 });
}
const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 10 });
const result = createPendingRegistration(username, passwordHash);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
logAudit({
timestamp: Date.now(),
username,
ip,
action: AuditAction.COMMAND,
details: `User registration pending approval`,
success: true,
});
logger.info(`[registration] Pending registration for '${username}' from ${ip}`);
return Response.json({
ok: true,
pending: true,
message: "Your registration is pending admin approval. You will be able to sign in once approved.",
});
}
if (regConfig.mode === "open") {
const result = await registerUser(username, password, "open", defaultRole);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
logAudit({
timestamp: Date.now(),
username,
ip,
action: AuditAction.COMMAND,
details: `User registered via open registration`,
success: true,
});
logger.info(`[registration] User '${username}' registered via open registration from ${ip}`);
return Response.json({ ok: true, message: "Account created successfully. You can now sign in." });
}
return Response.json({ error: "Registration is disabled" }, { status: 403 });
}
if (req.method === "GET" && url.pathname === "/api/registration/keys") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
const keys = listRegistrationKeys();
return Response.json({ keys });
}
if (req.method === "POST" && url.pathname === "/api/registration/keys") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
let body: any;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const count = Math.min(100, Math.max(1, Number(body?.count) || 1));
const label = typeof body?.label === "string" ? body.label : undefined;
const expiresInHours = typeof body?.expiresInHours === "number" && body.expiresInHours > 0
? body.expiresInHours
: undefined;
const keys = createRegistrationKeys(count, user.userId, label, expiresInHours);
const ip = getClientIp(req, deps);
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Generated ${count} registration key(s)${label ? ` (label: ${label})` : ""}`,
success: true,
});
return Response.json({ ok: true, keys });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/registration\/keys\/\d+$/)) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
const keyId = Number(url.pathname.split("/").pop());
if (!keyId || !Number.isFinite(keyId)) {
return Response.json({ error: "Invalid key ID" }, { status: 400 });
}
const deleted = deleteRegistrationKey(keyId);
if (!deleted) {
return Response.json({ error: "Key not found" }, { status: 404 });
}
const ip = getClientIp(req, deps);
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Deleted registration key ${keyId}`,
success: true,
});
return Response.json({ ok: true });
}
// ── Admin: GET /api/registration/keys/export ──────────────────────────
if (req.method === "GET" && url.pathname === "/api/registration/keys/export") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
const keys = listRegistrationKeys();
const exportData = keys.map((k) => ({
key: k.key,
label: k.label || "",
status: k.used_by ? "used" : k.expires_at && k.expires_at < Date.now() ? "expired" : "available",
created_at: new Date(k.created_at).toISOString(),
expires_at: k.expires_at ? new Date(k.expires_at).toISOString() : "never",
}));
return new Response(JSON.stringify(exportData, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="registration-keys-${Date.now()}.json"`,
},
});
}
// ── Admin: GET /api/registration/pending ──────────────────────────────
if (req.method === "GET" && url.pathname === "/api/registration/pending") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
const pending = listPendingRegistrations();
return Response.json({
pending: pending.map((p) => ({
id: p.id,
username: p.username,
requested_at: p.requested_at,
status: p.status,
})),
});
}
// ── Admin: POST /api/registration/pending/:id/approve ─────────────────
if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/approve$/)) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
const pendingId = Number(url.pathname.split("/")[4]);
if (!pendingId || !Number.isFinite(pendingId)) {
return Response.json({ error: "Invalid pending ID" }, { status: 400 });
}
const config = getConfig();
const result = await approvePendingRegistration(
pendingId,
user.userId,
config.registration.defaultRole as UserRole,
);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = getClientIp(req, deps);
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Approved pending registration #${pendingId}`,
success: true,
});
return Response.json({ ok: true, userId: result.userId });
}
// ── Admin: POST /api/registration/pending/:id/deny ────────────────────
if (req.method === "POST" && url.pathname.match(/^\/api\/registration\/pending\/\d+\/deny$/)) {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role !== "admin") return new Response("Forbidden", { status: 403 });
const pendingId = Number(url.pathname.split("/")[4]);
if (!pendingId || !Number.isFinite(pendingId)) {
return Response.json({ error: "Invalid pending ID" }, { status: 400 });
}
const result = denyPendingRegistration(pendingId, user.userId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = getClientIp(req, deps);
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Denied pending registration #${pendingId}`,
success: true,
});
return Response.json({ ok: true });
}
return null;
}
+296 -1
View File
@@ -211,6 +211,47 @@ try {
}
}
try {
db.exec(`ALTER TABLE users ADD COLUMN registered_via TEXT DEFAULT NULL`);
logger.info("[users] Added registered_via column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
db.exec(`
CREATE TABLE IF NOT EXISTS registration_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
label TEXT,
created_by INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
used_by INTEGER,
used_at INTEGER,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_registration_keys_key ON registration_keys("key")`);
db.exec(`
CREATE TABLE IF NOT EXISTS pending_registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
requested_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'denied')),
reviewed_by INTEGER,
reviewed_at INTEGER,
key_used INTEGER,
FOREIGN KEY (key_used) REFERENCES registration_keys(id)
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_pending_registrations_status ON pending_registrations(status)`);
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_registrations_username_pending ON pending_registrations(username) WHERE status = 'pending'`);
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
count: number;
};
@@ -546,7 +587,7 @@ export function resetUserFeaturePermissions(
}
}
function validatePasswordPolicy(password: string): string | null {
export function validatePasswordPolicy(password: string): string | null {
const security = getConfig().security;
const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6));
@@ -977,3 +1018,257 @@ export function getUsersForNotificationDeliveryByClient(clientId: string): UserD
canUserAccessClient(user.id, user.role, clientId),
);
}
export interface RegistrationKey {
id: number;
key: string;
label: string | null;
created_by: number;
created_at: number;
expires_at: number | null;
used_by: number | null;
used_at: number | null;
}
export interface PendingRegistration {
id: number;
username: string;
password_hash: string;
requested_at: number;
status: "pending" | "approved" | "denied";
reviewed_by: number | null;
reviewed_at: number | null;
key_used: number | null;
}
export function generateRegistrationKey(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const segments = 4;
const segmentLen = 5;
const parts: string[] = [];
for (let s = 0; s < segments; s++) {
let seg = "";
const bytes = new Uint8Array(segmentLen);
crypto.getRandomValues(bytes);
for (let i = 0; i < segmentLen; i++) {
seg += chars[bytes[i] % chars.length];
}
parts.push(seg);
}
return parts.join("-");
}
export function createRegistrationKeys(
count: number,
createdBy: number,
label?: string,
expiresInHours?: number,
): RegistrationKey[] {
const safeCount = Math.min(100, Math.max(1, count));
const now = Date.now();
const expiresAt = expiresInHours && expiresInHours > 0
? now + expiresInHours * 60 * 60 * 1000
: null;
const safeLabel = label ? String(label).slice(0, 128).trim() : null;
const stmt = db.prepare(
`INSERT INTO registration_keys ("key", label, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
);
const keys: RegistrationKey[] = [];
const tx = db.transaction(() => {
for (let i = 0; i < safeCount; i++) {
const key = generateRegistrationKey();
const result = stmt.run(key, safeLabel, createdBy, now, expiresAt);
keys.push({
id: result.lastInsertRowid as number,
key,
label: safeLabel,
created_by: createdBy,
created_at: now,
expires_at: expiresAt,
used_by: null,
used_at: null,
});
}
});
tx();
return keys;
}
export function listRegistrationKeys(): RegistrationKey[] {
return db.prepare(
`SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys ORDER BY created_at DESC`,
).all() as RegistrationKey[];
}
export function getRegistrationKeyByValue(keyValue: string): RegistrationKey | null {
const row = db.prepare(
`SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys WHERE "key" = ?`,
).get(keyValue) as RegistrationKey | undefined;
return row || null;
}
export function markRegistrationKeyUsed(keyId: number, usedByUserId: number): void {
db.prepare(`UPDATE registration_keys SET used_by = ?, used_at = ? WHERE id = ?`).run(
usedByUserId, Date.now(), keyId,
);
}
export function claimRegistrationKey(
keyValue: string,
usedByUserId: number,
): { success: true; key: RegistrationKey } | { success: false; error: string } {
const tx = db.transaction(() => {
const row = db.prepare(
`SELECT id, "key", label, created_by, created_at, expires_at, used_by, used_at FROM registration_keys WHERE "key" = ?`,
).get(keyValue) as RegistrationKey | undefined;
if (!row) return { success: false as const, error: "Invalid registration key" };
if (row.used_by !== null) return { success: false as const, error: "This registration key has already been used" };
if (row.expires_at && row.expires_at < Date.now()) return { success: false as const, error: "This registration key has expired" };
db.prepare(`UPDATE registration_keys SET used_by = ?, used_at = ? WHERE id = ? AND used_by IS NULL`).run(
usedByUserId, Date.now(), row.id,
);
return { success: true as const, key: row };
});
return tx();
}
export function deleteRegistrationKey(keyId: number): boolean {
const result = db.prepare(`DELETE FROM registration_keys WHERE id = ?`).run(keyId);
return (result.changes as number) > 0;
}
export function createPendingRegistration(
username: string,
passwordHash: string,
keyUsed?: number,
): { success: boolean; id?: number; error?: string } {
try {
const result = db.prepare(
`INSERT INTO pending_registrations (username, password_hash, requested_at, status, key_used) VALUES (?, ?, ?, 'pending', ?)`,
).run(username, passwordHash, Date.now(), keyUsed || null);
return { success: true, id: result.lastInsertRowid as number };
} catch (err: any) {
if (err.message?.includes("UNIQUE constraint")) {
return { success: false, error: "A registration with this username is already pending" };
}
return { success: false, error: err.message || "Failed to create pending registration" };
}
}
export function listPendingRegistrations(): PendingRegistration[] {
return db.prepare(
`SELECT id, username, requested_at, status, reviewed_by, reviewed_at, key_used FROM pending_registrations WHERE status = 'pending' ORDER BY requested_at ASC`,
).all() as PendingRegistration[];
}
export function getPendingRegistration(id: number): PendingRegistration | null {
const row = db.prepare(
`SELECT * FROM pending_registrations WHERE id = ?`,
).get(id) as PendingRegistration | undefined;
return row || null;
}
export async function approvePendingRegistration(
pendingId: number,
reviewedBy: number,
defaultRole: UserRole,
): Promise<{ success: boolean; userId?: number; error?: string }> {
const pending = getPendingRegistration(pendingId);
if (!pending) return { success: false, error: "Pending registration not found" };
if (pending.status !== "pending") return { success: false, error: "Registration already reviewed" };
const existing = getUserByUsername(pending.username);
if (existing) return { success: false, error: "Username already exists" };
try {
const role = defaultRole === "viewer" ? "viewer" : "operator";
const result = db.prepare(
`INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
pending.username, pending.password_hash, role, Date.now(), "registration",
"allowlist", role === "operator" ? 1 : 0, 0, "approval",
);
db.prepare(
`UPDATE pending_registrations SET status = 'approved', reviewed_by = ?, reviewed_at = ? WHERE id = ?`,
).run(reviewedBy, Date.now(), pendingId);
invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
logger.error("[users] approvePendingRegistration error:", err);
return { success: false, error: err.message || "Failed to approve registration" };
}
}
export function denyPendingRegistration(
pendingId: number,
reviewedBy: number,
): { success: boolean; error?: string } {
const pending = getPendingRegistration(pendingId);
if (!pending) return { success: false, error: "Pending registration not found" };
if (pending.status !== "pending") return { success: false, error: "Registration already reviewed" };
db.prepare(
`UPDATE pending_registrations SET status = 'denied', reviewed_by = ?, reviewed_at = ? WHERE id = ?`,
).run(reviewedBy, Date.now(), pendingId);
return { success: true };
}
export async function registerUser(
username: string,
password: string,
registeredVia: "open" | "key",
defaultRole: UserRole,
): Promise<{ success: boolean; error?: string; userId?: number }> {
if (!username || username.length < 3 || username.length > 32) {
return { success: false, error: "Username must be between 3 and 32 characters" };
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return { success: false, error: "Username can only contain letters, numbers, hyphens, and underscores" };
}
const policyError = validatePasswordPolicy(password);
if (policyError) return { success: false, error: policyError };
const existing = getUserByUsername(username);
if (existing) return { success: false, error: "Username already exists" };
const pendingExisting = db.prepare(
`SELECT id FROM pending_registrations WHERE username = ? AND status = 'pending'`,
).get(username);
if (pendingExisting) return { success: false, error: "A registration with this username is already pending" };
try {
const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 10 });
const role = defaultRole === "viewer" ? "viewer" : "operator";
const result = db.prepare(
`INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, can_build, can_upload_files, registered_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
username, passwordHash, role, Date.now(), "registration",
"allowlist", role === "operator" ? 1 : 0, 0, registeredVia,
);
invalidateNotificationDeliveryCache();
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
if (err.message?.includes("UNIQUE constraint")) {
return { success: false, error: "Username already exists" };
}
logger.error("[users] registerUser error:", err);
return { success: false, error: err.message || "Failed to register user" };
}
}
export function getTotalUserCount(): number {
const row = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
return row.count;
}