Protect launcher admin tools

This commit is contained in:
Andraxion 2026-06-27 00:51:20 -04:00
parent fad065c429
commit ab1dfbf029
4 changed files with 384 additions and 151 deletions

View file

@ -17,6 +17,7 @@ const DEFAULTS = {
modelBaseUrl: process.env.REQUEST_ANALYZER_MODEL_BASE_URL || "",
model: process.env.REQUEST_ANALYZER_MODEL || "",
apiKey: process.env.REQUEST_ANALYZER_API_KEY || process.env.DEEPSEEK_API_KEY || "",
adminPassword: process.env.REQUEST_ANALYZER_ADMIN_PASSWORD || process.env.LAUNCHER_ADMIN_PASSWORD || "",
limit: Number(process.env.REQUEST_ANALYZER_LIMIT || 5),
promoteThreshold: Number(process.env.REQUEST_ANALYZER_PROMOTE_THRESHOLD || 0.85),
maxTokens: Math.max(512, Number(process.env.REQUEST_ANALYZER_MAX_TOKENS || 4000)),
@ -57,12 +58,14 @@ Environment variables:
REQUEST_ANALYZER_MODEL_BASE_URL
REQUEST_ANALYZER_MODEL
REQUEST_ANALYZER_API_KEY
REQUEST_ANALYZER_ADMIN_PASSWORD
REQUEST_ANALYZER_MAX_TOKENS
REQUEST_ANALYZER_THINKING
REQUEST_ANALYZER_LIMIT
REQUEST_ANALYZER_INTERVAL_MS
REQUEST_ANALYZER_PROMOTE_THRESHOLD
DEEPSEEK_API_KEY
LAUNCHER_ADMIN_PASSWORD
Notes:
- DeepSeek uses ${DEFAULT_DEEPSEEK_BASE_URL}/chat/completions.
@ -194,6 +197,17 @@ async function fetchJson(url, init = {}) {
return response.json();
}
function buildAdminHeaders(config, baseHeaders = {}) {
const nextHeaders = {
...baseHeaders,
};
const adminPassword = String(config?.adminPassword || "").trim();
if (adminPassword) {
nextHeaders["x-worldshaper-admin-password"] = adminPassword;
}
return nextHeaders;
}
function tokenize(value) {
return String(value || "")
.toLowerCase()
@ -498,9 +512,9 @@ async function getLauncherRequests(config) {
async function patchLauncherRequest(config, requestId, body) {
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}`), {
method: "PATCH",
headers: {
headers: buildAdminHeaders(config, {
"Content-Type": "application/json",
},
}),
body: JSON.stringify(body),
});
}
@ -508,9 +522,9 @@ async function patchLauncherRequest(config, requestId, body) {
async function processLauncherRequestAnalysis(config, requestId, body) {
return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}/process-analysis`), {
method: "POST",
headers: {
headers: buildAdminHeaders(config, {
"Content-Type": "application/json",
},
}),
body: JSON.stringify(body),
});
}

View file

@ -10,6 +10,38 @@ const __dirname = path.dirname(__filename);
const app = express();
const port = Number(process.env.PORT) || 5180;
const host = process.env.HOST || "0.0.0.0";
const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim();
function isLauncherAdminProtectionEnabled() {
return Boolean(launcherAdminPassword);
}
function readLauncherAdminPasswordCandidate(req) {
const headerValue = req.get("x-worldshaper-admin-password");
if (String(headerValue || "").trim()) {
return String(headerValue || "").trim();
}
return String(req.body?.password || "").trim();
}
function requireLauncherAdminAccess(req, res) {
if (!isLauncherAdminProtectionEnabled()) {
res.status(503).json({
error: "Launcher admin access is not configured on the server.",
adminConfigured: false,
});
return false;
}
const submittedPassword = readLauncherAdminPasswordCandidate(req);
if (!submittedPassword || submittedPassword !== launcherAdminPassword) {
res.status(401).json({
error: "Admin access denied.",
adminConfigured: true,
});
return false;
}
return true;
}
function resolveContentRoot() {
const envPath = String(process.env.CONTENT_ROOT || "").trim();
@ -2610,6 +2642,9 @@ app.get("/api/types", (_req, res) => {
});
app.get("/api/debug/paths", (_req, res) => {
if (!requireLauncherAdminAccess(_req, res)) {
return;
}
const contentFiles = Object.fromEntries(
Object.entries(contentMap).map(([type, entry]) => {
const fullPath = path.join(contentRoot, entry.file);
@ -2637,7 +2672,37 @@ app.get("/api/debug/paths", (_req, res) => {
});
});
app.get("/api/debug/recent-saves", (_req, res) => {
app.post("/api/admin/auth-check", (req, res) => {
if (!isLauncherAdminProtectionEnabled()) {
res.status(503).json({
ok: false,
accessGranted: false,
adminConfigured: false,
error: "Launcher admin access is not configured on the server.",
});
return;
}
const accessGranted = readLauncherAdminPasswordCandidate(req) === launcherAdminPassword;
if (!accessGranted) {
res.status(401).json({
ok: false,
accessGranted: false,
adminConfigured: true,
error: "Admin access denied.",
});
return;
}
res.json({
ok: true,
accessGranted: true,
adminConfigured: true,
});
});
app.get("/api/debug/recent-saves", (req, res) => {
if (!requireLauncherAdminAccess(req, res)) {
return;
}
res.json({
ok: true,
contentRoot,
@ -2701,6 +2766,9 @@ app.post("/api/launcher-requests", (req, res) => {
app.patch("/api/launcher-requests/:requestId", (req, res) => {
const requestId = String(req.params.requestId || "").trim();
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try {
const payload = readLauncherRequestsPayload();
const index = payload.requests.findIndex((entry) => entry.id === requestId);
@ -2745,6 +2813,9 @@ app.patch("/api/launcher-requests/:requestId", (req, res) => {
app.delete("/api/launcher-requests/:requestId", (req, res) => {
const requestId = String(req.params.requestId || "").trim();
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try {
const payload = readLauncherRequestsPayload();
const existing = payload.requests.find((entry) => entry.id === requestId);
@ -2775,6 +2846,9 @@ app.delete("/api/launcher-requests/:requestId", (req, res) => {
app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => {
const requestId = String(req.params.requestId || "").trim();
const action = String(req.body?.action || "").trim().toLowerCase();
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try {
const payload = readLauncherRequestsPayload();
const index = payload.requests.findIndex((entry) => entry.id === requestId);
@ -2866,7 +2940,10 @@ app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => {
}
});
app.post("/api/launcher-requests/process-pending", (_req, res) => {
app.post("/api/launcher-requests/process-pending", (req, res) => {
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try {
const result = launchQueuedRequestAnalysis("manual-api-trigger");
res.json({

View file

@ -95,6 +95,13 @@ type ProcessPendingPayload = {
pid?: number;
};
type AdminAuthPayload = {
ok?: boolean;
accessGranted?: boolean;
adminConfigured?: boolean;
error?: string;
};
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
async function resolveDefaultWorldId(): Promise<string> {
@ -122,6 +129,25 @@ async function fetchJsonOrThrow<T>(input: RequestInfo | URL, init?: RequestInit)
return response.json() as Promise<T>;
}
function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit {
const normalizedPassword = String(password || "").trim();
if (!normalizedPassword) {
return {
...(headers || {}),
};
}
return {
...(headers || {}),
"x-worldshaper-admin-password": normalizedPassword,
};
}
function isAdminAccessError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("admin access denied")
|| text.includes("admin access is not configured");
}
function formatRequestTimestamp(value: string): string {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
@ -281,6 +307,11 @@ function WorldshaperLauncher() {
const [requestFilter, setRequestFilter] = useState("all");
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
const [adminPanelOpen, setAdminPanelOpen] = useState(false);
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
const [adminPassword, setAdminPassword] = useState("");
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
const [adminPasswordError, setAdminPasswordError] = useState("");
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [logsError, setLogsError] = useState("");
@ -329,19 +360,37 @@ function WorldshaperLauncher() {
async function loadRecentSaveEvents(): Promise<void> {
setLogsLoading(true);
try {
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves");
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves", {
headers: buildAdminHeaders(adminPassword),
});
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
setLogsError("");
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
}
setLogsError(String(nextError || "Failed to load admin logs."));
} finally {
setLogsLoading(false);
}
}
async function verifyAdminPassword(password: string): Promise<void> {
const payload = await fetchJsonOrThrow<AdminAuthPayload>("/api/admin/auth-check", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ password }),
});
if (!payload.accessGranted) {
throw new Error(String(payload.error || "Admin access denied."));
}
}
async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise<void> {
await loadRequests({ silent: options?.silentRequests === true });
if (options?.includeLogs) {
if (options?.includeLogs && adminAccessGranted && adminPassword) {
await loadRecentSaveEvents();
}
}
@ -351,11 +400,11 @@ function WorldshaperLauncher() {
}, []);
useEffect(() => {
if (!adminPanelOpen) {
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return;
}
void loadRecentSaveEvents();
}, [adminPanelOpen]);
}, [adminPanelOpen, adminAccessGranted, adminPassword]);
useEffect(() => {
if (activeBoardTab !== "requests") {
@ -371,11 +420,13 @@ function WorldshaperLauncher() {
} catch {
// Keep the current list visible during background refresh failures.
}
if (!adminPanelOpen) {
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
return;
}
try {
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves");
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves", {
headers: buildAdminHeaders(adminPassword),
});
if (!cancelled) {
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
}
@ -390,7 +441,7 @@ function WorldshaperLauncher() {
cancelled = true;
window.clearInterval(intervalId);
};
}, [activeBoardTab, adminPanelOpen]);
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
async function handleLaunch(): Promise<void> {
setError("");
@ -436,11 +487,11 @@ function WorldshaperLauncher() {
setRequestDraftOpen(false);
setRequestsError("");
setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled.");
if (adminPanelOpen) {
if (adminPanelOpen && adminAccessGranted) {
void loadRecentSaveEvents();
}
window.setTimeout(() => {
void refreshAdminData({ includeLogs: adminPanelOpen, silentRequests: true });
void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true });
}, 3500);
} catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to save request."));
@ -449,6 +500,45 @@ function WorldshaperLauncher() {
}
}
async function handleAdminPanelToggle(): Promise<void> {
setRequestDraftOpen(false);
setRequestsError("");
setLogsError("");
if (adminPanelOpen) {
setAdminPanelOpen(false);
setAdminNotice("");
return;
}
setAdminPanelOpen(true);
setAdminPasswordError("");
if (adminAccessGranted && adminPassword) {
await refreshAdminData({ includeLogs: true, silentRequests: true });
}
}
async function handleAdminUnlock(): Promise<void> {
const submittedPassword = adminPasswordDraft.trim();
if (!submittedPassword) {
setAdminPasswordError("Enter the admin password to continue.");
return;
}
setAdminAuthSubmitting(true);
setAdminPasswordError("");
try {
await verifyAdminPassword(submittedPassword);
setAdminPassword(submittedPassword);
setAdminAccessGranted(true);
setAdminNotice("Admin access granted.");
await refreshAdminData({ includeLogs: true, silentRequests: true });
} catch (nextError: unknown) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError(String(nextError || "Failed to unlock the admin panel."));
} finally {
setAdminAuthSubmitting(false);
}
}
function handleToggleExpandedRequest(requestId: string): void {
setExpandedRequestIds((current) => (
current.includes(requestId)
@ -466,6 +556,7 @@ function WorldshaperLauncher() {
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
method: "DELETE",
headers: buildAdminHeaders(adminPassword),
});
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError("");
@ -475,6 +566,11 @@ function WorldshaperLauncher() {
void loadRecentSaveEvents();
}
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setRequestsError(String(nextError || "Failed to delete request."));
} finally {
setRequestMutatingId("");
@ -486,6 +582,7 @@ function WorldshaperLauncher() {
try {
const payload = await fetchJsonOrThrow<ProcessPendingPayload>("/api/launcher-requests/process-pending", {
method: "POST",
headers: buildAdminHeaders(adminPassword),
});
if (payload.launched) {
setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`);
@ -508,6 +605,11 @@ function WorldshaperLauncher() {
}, 4200);
}
} catch (nextError: unknown) {
if (isAdminAccessError(nextError)) {
setAdminAccessGranted(false);
setAdminPassword("");
setAdminPasswordError("Admin access expired. Enter the password again.");
}
setLogsError(String(nextError || "Failed to trigger the queue worker."));
} finally {
setQueueTriggering(false);
@ -660,27 +762,27 @@ function WorldshaperLauncher() {
<div className="changelog-splash-meta">
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
</div>
</div>
<div className="launcher-request-controls">
<div className="launcher-request-toolbar">
<div className="launcher-request-hero-actions">
<div className="launcher-request-toolbar-buttons">
<button
type="button"
className="launcher-primary-btn"
onClick={() => setRequestDraftOpen((value) => !value)}
onClick={() => {
setAdminPanelOpen(false);
setRequestDraftOpen((value) => !value);
}}
disabled={requestSubmitting}
>
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
{requestDraftOpen && !adminPanelOpen ? "Hide Request Form" : "Add New Request"}
</button>
<button
type="button"
className={`launcher-secondary-btn ${adminPanelOpen ? "is-active" : ""}`}
onClick={() => setAdminPanelOpen((value) => !value)}
onClick={() => void handleAdminPanelToggle()}
>
{adminPanelOpen ? "Hide Admin Panel" : "Admin Panel"}
</button>
</div>
</div>
<label className="launcher-request-filter">
<span className="launcher-request-filter-label">Filter</span>
<select
@ -699,8 +801,46 @@ function WorldshaperLauncher() {
</select>
</label>
</div>
</div>
{adminPanelOpen ? (
<section className="launcher-request-admin-panel">
{!adminAccessGranted ? (
<div className="launcher-request-admin-unlock">
<div className="launcher-request-admin-kicker">Protected Tools</div>
<h3 className="launcher-request-admin-title">Admin Access Required</h3>
<p className="launcher-request-admin-copy">
Enter the admin password to manage deletions, run the queue worker, and read request logs.
</p>
<label className="launcher-request-admin-field">
<span className="launcher-request-filter-label">Password</span>
<input
type="password"
className="launcher-request-filter-select"
value={adminPasswordDraft}
onChange={(event) => setAdminPasswordDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleAdminUnlock();
}
}}
placeholder="Enter admin password"
/>
</label>
<div className="launcher-request-admin-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleAdminUnlock()}
disabled={adminAuthSubmitting}
>
{adminAuthSubmitting ? "Unlocking..." : "Unlock Admin Panel"}
</button>
</div>
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
</div>
) : (
<>
<div className="launcher-request-admin-head">
<div>
<div className="launcher-request-admin-kicker">Moderation Tools</div>
@ -735,6 +875,7 @@ function WorldshaperLauncher() {
</button>
</div>
{adminNotice ? <p className="launcher-request-admin-notice">{adminNotice}</p> : null}
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
{logsError ? <p className="launcher-request-error">{logsError}</p> : null}
<div className="launcher-request-admin-grid">
<section className="launcher-request-admin-card">
@ -795,9 +936,11 @@ function WorldshaperLauncher() {
</div>
</section>
</div>
</>
)}
</section>
) : null}
{requestDraftOpen ? (
{requestDraftOpen && !adminPanelOpen ? (
<section className="launcher-request-composer">
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
What should be added or improved?
@ -833,6 +976,7 @@ function WorldshaperLauncher() {
</div>
</section>
) : null}
{!adminPanelOpen ? (
<div className="launcher-request-list">
{requestsLoading ? (
<section className="launcher-request-entry">
@ -909,9 +1053,10 @@ function WorldshaperLauncher() {
);
}) : null}
</div>
) : null}
<div className="changelog-splash-footer">
<div className="changelog-splash-footnote">
Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs live in the admin panel.
Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs stay behind protected admin access.
</div>
</div>
</div>

View file

@ -255,27 +255,12 @@ body {
padding-right: 4px;
}
.launcher-request-controls {
position: sticky;
top: 0;
z-index: 3;
.launcher-request-hero-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px;
border: 1px solid #365782;
border-radius: 12px;
background:
linear-gradient(180deg, rgba(16, 29, 56, 0.96) 0%, rgba(12, 22, 43, 0.96) 100%);
box-shadow:
0 10px 24px rgba(3, 8, 18, 0.26),
inset 0 0 0 1px rgba(10, 16, 32, 0.14);
}
.launcher-request-toolbar {
display: flex;
justify-content: flex-start;
gap: 12px;
align-items: end;
margin-top: 16px;
}
.launcher-request-toolbar-buttons {
@ -329,6 +314,17 @@ body {
inset 0 0 0 1px rgba(10, 16, 32, 0.14);
}
.launcher-request-admin-unlock {
display: grid;
gap: 12px;
max-width: 420px;
}
.launcher-request-admin-field {
display: grid;
gap: 4px;
}
.launcher-request-admin-head {
display: flex;
justify-content: space-between;
@ -1918,8 +1914,9 @@ button.danger:not(:disabled):hover {
flex-direction: column;
}
.launcher-request-controls {
.launcher-request-hero-actions {
grid-template-columns: 1fr;
align-items: stretch;
}
.launcher-request-admin-head,