Add developer API, Prometheus metrics, and community links
This commit is contained in:
@@ -9,6 +9,8 @@ MINEGEO_ALLOWED_ORIGINS=
|
||||
# Telegram: https://t.me/<channel-or-group> (private group invite links also work).
|
||||
MINEGEO_DISCORD_URL=
|
||||
MINEGEO_TELEGRAM_URL=
|
||||
# Optional HTTPS override for the hosted community forum linked in the header/footer.
|
||||
MINEGEO_FORUM_URL=
|
||||
# Optional server-only Discord bot integration. All published parties use one channel.
|
||||
# Create a bot at https://discord.com/developers/applications and invite it to your server.
|
||||
# Channel IDs are copied with Discord Developer Mode enabled. Never use NEXT_PUBLIC_ here.
|
||||
|
||||
8
app/api/dev/keys/route.ts
Normal file
8
app/api/dev/keys/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { developerKeys } from "@/lib/developer-key-management";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const GET = developerKeys;
|
||||
export const POST = developerKeys;
|
||||
export const DELETE = developerKeys;
|
||||
export const PUT = developerKeys;
|
||||
export const PATCH = developerKeys;
|
||||
10
app/api/dev/openapi.json/route.ts
Normal file
10
app/api/dev/openapi.json/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { developerOpenApi } from "@/shared/developer-openapi";
|
||||
export const dynamic = "force-static";
|
||||
export function GET() {
|
||||
return Response.json(developerOpenApi, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
3
app/api/dev/route.ts
Normal file
3
app/api/dev/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function GET(request: Request) {
|
||||
return Response.redirect(new URL("/dev/api", request.url), 307);
|
||||
}
|
||||
17
app/api/dev/v1/[...path]/route.ts
Normal file
17
app/api/dev/v1/[...path]/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { developerApi } from "@/lib/developer-api";
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 30;
|
||||
async function handle(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
return developerApi(request, (await params).path);
|
||||
}
|
||||
export {
|
||||
handle as GET,
|
||||
handle as POST,
|
||||
handle as PATCH,
|
||||
handle as PUT,
|
||||
handle as DELETE,
|
||||
};
|
||||
16
app/api/metrics/route.ts
Normal file
16
app/api/metrics/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { admin, required } from "@/lib/supabase";
|
||||
import { metricsResponse } from "@/lib/prometheus-response";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 30;
|
||||
export function GET(request: Request) {
|
||||
return metricsResponse(request, async (tokenHash) => {
|
||||
const result = await admin().rpc("read_prometheus_metrics", {
|
||||
p_token_hash: tokenHash,
|
||||
});
|
||||
// NULL is an invalid or disabled credential, not a missing database record.
|
||||
if (result.error) required(result);
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
@@ -384,7 +384,7 @@ html[data-theme="light"] .site-cursor-dot span {
|
||||
background: #273b21;
|
||||
box-shadow: 0 0 1px #fff;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
@media (max-width: 1200px) {
|
||||
.site-header {
|
||||
height: auto;
|
||||
}
|
||||
@@ -436,7 +436,7 @@ html[data-theme="light"] .site-cursor-dot span {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
@media (max-width: 600px) {
|
||||
.header-actions {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
|
||||
225
app/dev/api/page.module.css
Normal file
225
app/dev/api/page.module.css
Normal file
@@ -0,0 +1,225 @@
|
||||
.intro {
|
||||
max-width: 780px;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 24px 0 36px;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
}
|
||||
.panel {
|
||||
padding: clamp(20px, 4vw, 32px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
min-width: 0;
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: clamp(24px, 3vw, 32px);
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(140px, 200px);
|
||||
gap: 20px;
|
||||
}
|
||||
.fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.fieldset legend {
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.scopes {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.scope {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.scope input {
|
||||
margin-top: 5px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
accent-color: var(--green);
|
||||
}
|
||||
.scope strong,
|
||||
.scope small {
|
||||
display: block;
|
||||
}
|
||||
.scope small {
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
.token {
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--green);
|
||||
border-radius: 12px;
|
||||
background: var(--panel2);
|
||||
}
|
||||
.token input {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.keyList {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
margin: 24px 0 0;
|
||||
}
|
||||
.key {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.key h3 {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
.key dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 6px 18px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.key dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.key dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.key code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice {
|
||||
padding: 14px 18px;
|
||||
border-left: 3px solid var(--green);
|
||||
background: var(--panel2);
|
||||
margin: 20px 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--text);
|
||||
border-left-color: #ed7878;
|
||||
}
|
||||
.docs {
|
||||
scroll-margin-top: 120px;
|
||||
}
|
||||
.docs h3 {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.docs p,
|
||||
.docs li {
|
||||
line-height: 1.8;
|
||||
}
|
||||
.docs ul,
|
||||
.docs ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
.docs ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.docs ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.docs a {
|
||||
color: var(--green);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.docs code {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: monospace;
|
||||
}
|
||||
.code {
|
||||
overflow-x: auto;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
max-width: 100%;
|
||||
}
|
||||
.code code {
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
.table th {
|
||||
color: var(--muted);
|
||||
}
|
||||
.table td:first-child {
|
||||
min-width: 150px;
|
||||
}
|
||||
.table td:last-child {
|
||||
min-width: 200px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.fields {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.key dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.key dd {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.code {
|
||||
padding: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
23
app/dev/api/page.tsx
Normal file
23
app/dev/api/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { DeveloperPanel } from "@/components/DeveloperPanel";
|
||||
import { user } from "@/lib/supabase";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Developer API",
|
||||
description: "Create and manage scoped MineGeo API keys.",
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DeveloperApiPage() {
|
||||
const account = await user();
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content">
|
||||
<DeveloperPanel signedIn={Boolean(account)} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { DeveloperDocumentation } from "@/components/DeveloperDocumentation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import guides from "@/shared/player-docs.json";
|
||||
import styles from "./page.module.css";
|
||||
import { getI18n } from "@/lib/i18n";
|
||||
|
||||
export const metadata = { title: "Player docs" };
|
||||
|
||||
@@ -76,9 +78,18 @@ export default async function Docs({
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const { page = "welcome" } = await searchParams;
|
||||
const guide = guides.find((item) => item.slug === page);
|
||||
const { locale } = await getI18n();
|
||||
const pages = [
|
||||
...guides,
|
||||
{
|
||||
slug: "developer-api",
|
||||
title: locale === "ru" ? "API для разработчиков" : "Developer API",
|
||||
markdown: "",
|
||||
},
|
||||
];
|
||||
const guide = pages.find((item) => item.slug === page);
|
||||
if (!guide) notFound();
|
||||
const index = guides.indexOf(guide);
|
||||
const index = pages.indexOf(guide);
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
@@ -86,7 +97,7 @@ export default async function Docs({
|
||||
<aside>
|
||||
<h2>Player docs</h2>
|
||||
<nav aria-label="Documentation pages" className={styles.menu}>
|
||||
{guides.map((item) => (
|
||||
{pages.map((item) => (
|
||||
<Link
|
||||
key={item.slug}
|
||||
href={`/docs?page=${item.slug}`}
|
||||
@@ -98,16 +109,20 @@ export default async function Docs({
|
||||
</nav>
|
||||
</aside>
|
||||
<article className={`prose ${styles.article}`}>
|
||||
{content(guide.markdown)}
|
||||
{page === "developer-api" ? (
|
||||
<DeveloperDocumentation locale={locale} />
|
||||
) : (
|
||||
content(guide.markdown)
|
||||
)}
|
||||
<div className={styles.paging}>
|
||||
{index > 0 && (
|
||||
<Link href={`/docs?page=${guides[index - 1].slug}`}>
|
||||
← {guides[index - 1].title}
|
||||
<Link href={`/docs?page=${pages[index - 1].slug}`}>
|
||||
← {pages[index - 1].title}
|
||||
</Link>
|
||||
)}
|
||||
{index < guides.length - 1 && (
|
||||
<Link href={`/docs?page=${guides[index + 1].slug}`}>
|
||||
{guides[index + 1].title} →
|
||||
{index < pages.length - 1 && (
|
||||
<Link href={`/docs?page=${pages[index + 1].slug}`}>
|
||||
{pages[index + 1].title} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
@import "tailwindcss" source(none);
|
||||
@source ".";
|
||||
@source "../components";
|
||||
.metrics-steps {
|
||||
list-style: decimal;
|
||||
padding-left: 24px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.metrics-steps li { margin: 12px 0; }
|
||||
.metrics-config { font-family: monospace; font-size: 12px; overflow-wrap: normal; white-space: pre; }
|
||||
:root {
|
||||
--bg: #0b100f;
|
||||
--panel: #121b17;
|
||||
|
||||
@@ -6,7 +6,7 @@ export default async function Review() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content">
|
||||
<main className="section page-content moderation-page">
|
||||
<span className="eyebrow">{t("THE COMMUNITY’S COMPASS")}</span>
|
||||
<h1>{t("Map moderation.")}</h1>
|
||||
<p className="lead">
|
||||
|
||||
@@ -125,6 +125,29 @@ html[lang="ru"] .mode-row h3 span {
|
||||
.auth-tabs button {
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
/* Keep catalog cards readable instead of squeezing long map names. */
|
||||
.cards-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
|
||||
}
|
||||
.map-card .card-body .map-credit {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
min-height: 0;
|
||||
-webkit-line-clamp: unset;
|
||||
}
|
||||
.map-card .card-body .eyebrow {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.search-field:focus-within {
|
||||
outline: 2px solid var(--green);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.dashboard-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dashboard-toolbar > * {
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
.site-header {
|
||||
height: auto;
|
||||
@@ -209,12 +232,38 @@ html[lang="ru"] .mode-row h3 span {
|
||||
.home-panel + .home-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
.map-card .card-body p {
|
||||
display: -webkit-box;
|
||||
}
|
||||
.map-card .card-meta span:last-child {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.moderator-settings {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.moderation-page :is(.tool-card, .draft-row) {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.moderator-setting-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.moderator-setting-row h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.moderator-setting-row p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.moderator-setting-row .button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.moderator-settings { margin-bottom: 28px; }
|
||||
.moderator-setting-row { display: flex; gap: 24px; justify-content: space-between; align-items: center; margin-top: 24px; }
|
||||
.moderator-setting-row h3 { margin: 0 0 8px; }
|
||||
.moderator-setting-row p { margin: 6px 0; }
|
||||
.moderator-setting-row .button { flex-shrink: 0; }
|
||||
@media (max-width: 600px) {
|
||||
.moderator-setting-row { align-items: stretch; flex-direction: column; }
|
||||
.moderator-setting-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function Dashboard() {
|
||||
[token, setToken] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [m, t] = await Promise.all([
|
||||
@@ -34,6 +35,7 @@ export function Dashboard() {
|
||||
]);
|
||||
setMaps(m.maps);
|
||||
setTokens(t.tokens);
|
||||
setLoaded(true);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -98,10 +100,22 @@ export function Dashboard() {
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{t(error)}
|
||||
{t(error)}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-link"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("Retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{!maps.length && (
|
||||
{!loaded && !error && (
|
||||
<p role="status" className="muted">
|
||||
{t("Loading…")}
|
||||
</p>
|
||||
)}
|
||||
{loaded && !maps.length && (
|
||||
<div className="empty-state">
|
||||
<h2>{t("A blank map is a beginning.")}</h2>
|
||||
<p>
|
||||
|
||||
219
components/DeveloperDocumentation.tsx
Normal file
219
components/DeveloperDocumentation.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
developerEndpoints,
|
||||
developerPythonExample,
|
||||
} from "@/shared/developer-docs";
|
||||
import styles from "@/app/dev/api/page.module.css";
|
||||
|
||||
export function DeveloperDocumentation({ locale }: { locale: string }) {
|
||||
const ru = locale === "ru";
|
||||
const l = (en: string, russian: string) => (ru ? russian : en);
|
||||
return (
|
||||
<section
|
||||
id="documentation"
|
||||
className={styles.docs}
|
||||
aria-labelledby="documentation-title"
|
||||
>
|
||||
<div className={styles.heading}>
|
||||
<BookOpen size={24} aria-hidden="true" />
|
||||
<h1 id="documentation-title">
|
||||
{l("API documentation", "Документация API")}
|
||||
</h1>
|
||||
</div>
|
||||
<p>
|
||||
{l(
|
||||
"Build integrations with ordinary HTTPS requests. There is no MineGeo SDK; the Python examples use the requests library.",
|
||||
"Создавайте интеграции обычными HTTPS-запросами. SDK MineGeo пока нет; примеры на Python используют библиотеку requests.",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{l("Base URL", "Базовый адрес")}</strong>:{" "}
|
||||
<code>https://minegeo.vercel.app/api/dev/v1</code>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/api/dev/openapi.json">
|
||||
{l(
|
||||
"Download the OpenAPI specification",
|
||||
"Открыть спецификацию OpenAPI",
|
||||
)}
|
||||
</a>
|
||||
</p>
|
||||
<h3>{l("1. Create a key", "1. Создайте ключ")}</h3>
|
||||
<p>
|
||||
<Link href="/dev/api">
|
||||
{l("Open the developer panel →", "Открыть панель разработчика →")}
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
{l(
|
||||
"Open the developer panel, log in, name your integration and select its permissions. Public maps are selected by default. Access to your profile, private maps and game history is separate and optional. You can have up to 5 active keys, with an expiry of 1–365 days (90 by default).",
|
||||
"Откройте панель разработчика, войдите, назовите интеграцию и выберите права. По умолчанию выбраны только общие карты. Доступ к профилю, личным картам и истории игр выдаётся отдельно и необязательно. Можно создать до 5 активных ключей со сроком действия от 1 до 365 дней (по умолчанию — 90).",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{l(
|
||||
"Store the copied key in the MINEGEO_API_KEY environment variable on your computer or server. Keep it out of source code, repositories, browser JavaScript and URLs. If it leaks, revoke it and create a replacement.",
|
||||
"Сохраните скопированный ключ в переменной окружения MINEGEO_API_KEY на компьютере или сервере. Не добавляйте его в код, репозитории, JavaScript браузера или URL. При утечке отзовите ключ и создайте новый.",
|
||||
)}
|
||||
</p>
|
||||
<h3>
|
||||
{l("2. Make a request with Python", "2. Сделайте запрос на Python")}
|
||||
</h3>
|
||||
<pre className={styles.code}>
|
||||
<code>python -m pip install requests</code>
|
||||
</pre>
|
||||
<pre className={styles.code}>
|
||||
<code>{developerPythonExample}</code>
|
||||
</pre>
|
||||
<p>
|
||||
{l(
|
||||
"Every v1 request needs Authorization: Bearer <your-key>. MineGeo developer keys start with mg_dev_. Contributor upload tokens, metrics tokens and your browser login cookie do not authenticate this API.",
|
||||
"Каждый запрос v1 требует заголовок Authorization: Bearer <ваш-ключ>. Ключи API MineGeo начинаются с mg_dev_. Токены загрузки карт, токены метрик и cookie входа в браузере не подходят для этого API.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Endpoints and permissions", "Методы и права доступа")}</h3>
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{l("GET endpoint", "Метод GET")}</th>
|
||||
<th>{l("Required permission", "Нужное право")}</th>
|
||||
<th>{l("Response", "Ответ")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{developerEndpoints.map((endpoint) => (
|
||||
<tr key={endpoint.path}>
|
||||
<td>
|
||||
<code>{endpoint.path}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{endpoint.scope}</code>
|
||||
</td>
|
||||
<td>{endpoint.description[ru ? 1 : 0]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
{l(
|
||||
"Account endpoints always refer to the owner of the API key. They cannot read another player's private data. /me returns your account ID, display name, creation date and username appearance; it does not expose your email or account role.",
|
||||
"Методы аккаунта всегда относятся к владельцу ключа API. Они не позволяют читать личные данные другого игрока. /me возвращает ID аккаунта, отображаемое имя, дату создания и оформление имени; email и роль аккаунта не передаются.",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{l(
|
||||
"Map details include metadata, map bounds and panorama count. Raw storage paths, panorama coordinates, moderation notes and game answers are excluded. Game history exposes a score only after a game is complete.",
|
||||
"Сведения о карте включают метаданные, границы карты и число панорам. Внутренние пути к файлам, координаты панорам, заметки модераторов и ответы игры не передаются. История игр содержит итоговый счёт только после завершения игры.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Pagination and search", "Страницы и поиск")}</h3>
|
||||
<p>
|
||||
{l(
|
||||
"Lists accept limit (default 20, maximum 50) and offset (default 0, maximum 10000). /maps and /me/maps also accept q (up to 80 characters) to search map names. Unknown or repeated query parameters return HTTP 400. Use pagination.nextOffset for the next request; null means the end of the supported result window. Narrow q when you reach the offset limit.",
|
||||
"Списки принимают limit (по умолчанию 20, максимум 50) и offset (по умолчанию 0, максимум 10000). /maps и /me/maps также принимают q длиной до 80 символов для поиска по названию. Неизвестные или повторяющиеся параметры возвращают HTTP 400. Используйте pagination.nextOffset для следующего запроса; null означает конец доступного диапазона. При достижении предела offset уточните q.",
|
||||
)}
|
||||
</p>
|
||||
<pre className={styles.code}>
|
||||
<code>{`{
|
||||
"maps": [],
|
||||
"pagination": {"limit": 20, "offset": 0, "total": 0, "nextOffset": null}
|
||||
}`}</code>
|
||||
</pre>
|
||||
<p>
|
||||
{l(
|
||||
"GET /maps/{id} returns {map: {...}}. GET /me returns {account: {...}}. GET /me/maps uses the maps list envelope; GET /me/games uses {games: [...], pagination: {...}}. Map url values are relative to https://minegeo.vercel.app.",
|
||||
"GET /maps/{id} возвращает {map: {...}}, GET /me — {account: {...}}. GET /me/maps использует список maps; GET /me/games — {games: [...], pagination: {...}}. Поле url карты содержит путь относительно https://minegeo.vercel.app.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Rate limits", "Ограничения запросов")}</h3>
|
||||
<p>
|
||||
{l(
|
||||
"Each key can make 60 requests per minute. All keys belonging to one account share a limit of 180 requests per minute. Creating more keys does not bypass the account limit. These limits apply to authenticated requests, including requests that fail permission or input checks.",
|
||||
"Каждый ключ может выполнять 60 запросов в минуту. У всех ключей одного аккаунта общий лимит — 180 запросов в минуту. Создание новых ключей не отменяет общий лимит. Лимиты применяются к запросам с действительным ключом, включая ошибки прав доступа и проверки параметров.",
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>RateLimit-Limit</code>:{" "}
|
||||
{l("per-key request limit", "лимит запросов ключа")}
|
||||
</li>
|
||||
<li>
|
||||
<code>RateLimit-Remaining</code>:{" "}
|
||||
{l("requests remaining for this key", "оставшиеся запросы ключа")}
|
||||
</li>
|
||||
<li>
|
||||
<code>RateLimit-Reset</code>:{" "}
|
||||
{l(
|
||||
"seconds until the current minute window resets",
|
||||
"секунды до сброса текущего минутного окна",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>X-RateLimit-Owner-Limit</code> /{" "}
|
||||
<code>X-RateLimit-Owner-Remaining</code>:{" "}
|
||||
{l("shared account quota", "общая квота аккаунта")}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
{l(
|
||||
"If you receive HTTP 429, wait for the number of seconds in Retry-After before retrying. Avoid tight retry loops. Cache catalog responses in your integration when appropriate.",
|
||||
"При ответе HTTP 429 подождите указанное в Retry-After число секунд перед повтором. Не повторяйте запросы в бесконечном быстром цикле. При необходимости кешируйте каталог в своей интеграции.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Errors", "Ошибки")}</h3>
|
||||
<pre className={styles.code}>
|
||||
<code>{`{"error":{"code":"insufficient_scope","message":"This API key does not have the required permission."}}`}</code>
|
||||
</pre>
|
||||
<ul>
|
||||
<li>
|
||||
<code>400</code> —{" "}
|
||||
{l(
|
||||
"invalid input or pagination",
|
||||
"некорректные параметры или пагинация",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>401</code> —{" "}
|
||||
{l(
|
||||
"missing, invalid, revoked or expired key",
|
||||
"ключ отсутствует, неверен, отозван или просрочен",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>403</code> —{" "}
|
||||
{l(
|
||||
"key is missing a required permission",
|
||||
"у ключа нет нужного права",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>404</code> —{" "}
|
||||
{l("map or endpoint unavailable", "карта или метод недоступны")}
|
||||
</li>
|
||||
<li>
|
||||
<code>429</code> —{" "}
|
||||
{l("request quota exhausted", "лимит запросов исчерпан")}
|
||||
</li>
|
||||
<li>
|
||||
<code>500</code> / <code>503</code> —{" "}
|
||||
{l(
|
||||
"temporary service failure; retry with a delay",
|
||||
"временная ошибка сервиса; повторите позже",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<div className={styles.notice}>
|
||||
<ShieldCheck size={20} aria-hidden="true" />
|
||||
<p>
|
||||
{l(
|
||||
"You can revoke a key at any time in the developer panel. Keys expire automatically and cannot be recovered. A new key can use different permissions; existing keys cannot gain additional permissions.",
|
||||
"Ключ можно отозвать в панели разработчика в любой момент. Ключи истекают автоматически и не подлежат восстановлению. Новый ключ может иметь другой набор прав; существующему ключу нельзя добавить права.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
496
components/DeveloperPanel.tsx
Normal file
496
components/DeveloperPanel.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Code2,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/components/I18nProvider";
|
||||
import { developerScopes } from "@/shared/developer-docs";
|
||||
import styles from "@/app/dev/api/page.module.css";
|
||||
import type { DeveloperKey, DeveloperScope } from "@/shared/developer-api";
|
||||
|
||||
type Scope = DeveloperScope;
|
||||
type Key = DeveloperKey;
|
||||
|
||||
async function request<T>(
|
||||
method = "GET",
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetch("/api/dev/keys", {
|
||||
method,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal: signal ?? AbortSignal.timeout(30000),
|
||||
headers:
|
||||
body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
typeof result.error === "string"
|
||||
? result.error
|
||||
: (result.error?.message ?? "Request failed. Please try again."),
|
||||
);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export function DeveloperPanel({ signedIn }: { signedIn: boolean }) {
|
||||
const { locale } = useI18n();
|
||||
const ru = locale === "ru";
|
||||
const l = (en: string, russian: string) => (ru ? russian : en);
|
||||
const [keys, setKeys] = useState<Key[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(signedIn);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [name, setName] = useState("");
|
||||
const [expiresDays, setExpiresDays] = useState(90);
|
||||
const [scopes, setScopes] = useState<Scope[]>(["maps:read"]);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn) return;
|
||||
const controller = new AbortController();
|
||||
void request<{ keys: Key[] }>("GET", undefined, controller.signal)
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setKeys(data.keys);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: ru
|
||||
? "Не удалось загрузить ключи API."
|
||||
: "Could not load API keys.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [signedIn, ru]);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await request<{ keys: Key[] }>();
|
||||
setKeys(data.keys);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not load API keys.", "Не удалось загрузить ключи API."),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function create(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await request<{ key: Key; token: string }>("POST", {
|
||||
name: name.trim(),
|
||||
scopes,
|
||||
expiresDays,
|
||||
});
|
||||
setToken(result.token);
|
||||
setKeys((list) => [result.key, ...list]);
|
||||
setName("");
|
||||
setMessage(
|
||||
l(
|
||||
"Key created. Copy it before leaving or hiding it.",
|
||||
"Ключ создан. Скопируйте его перед уходом со страницы или скрытием.",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not create the key.", "Не удалось создать ключ."),
|
||||
);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await request("DELETE", { id });
|
||||
setKeys((list) =>
|
||||
list.map((key) =>
|
||||
key.id === id ? { ...key, revokedAt: new Date().toISOString() } : key,
|
||||
),
|
||||
);
|
||||
setConfirmRevoke(null);
|
||||
setToken(null);
|
||||
setMessage(
|
||||
l(
|
||||
"Key revoked. Requests using it will no longer work.",
|
||||
"Ключ отозван. Запросы с ним больше не работают.",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not revoke the key.", "Не удалось отозвать ключ."),
|
||||
);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setMessage(l("Copied to clipboard.", "Скопировано в буфер обмена."));
|
||||
setError("");
|
||||
} catch {
|
||||
setError(
|
||||
l(
|
||||
"Could not copy. Select the key below and copy it manually.",
|
||||
"Не удалось скопировать. Выделите ключ ниже и скопируйте вручную.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const activeCount = keys.filter(
|
||||
(key) => !key.revokedAt && new Date(key.expiresAt).getTime() > Date.now(),
|
||||
).length;
|
||||
const date = (value: string) =>
|
||||
new Date(value).toLocaleString(ru ? "ru-RU" : "en-US");
|
||||
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<div className={styles.intro}>
|
||||
<span className="eyebrow">
|
||||
{l("BUILD WITH MINEGEO", "СОЗДАВАЙТЕ С MINEGEO")}
|
||||
</span>
|
||||
<h1>{l("Developer API.", "API для разработчиков.")}</h1>
|
||||
<p className="lead">
|
||||
{l(
|
||||
"Use MineGeo maps and your own account data in scripts and integrations. Choose exactly what each key can read.",
|
||||
"Используйте карты MineGeo и данные своего аккаунта в скриптах и интеграциях. Выберите, что именно сможет читать каждый ключ.",
|
||||
)}
|
||||
</p>
|
||||
<nav
|
||||
className={styles.tabs}
|
||||
aria-label={l("Developer sections", "Разделы для разработчиков")}
|
||||
>
|
||||
<a className="button" href="#api-keys">
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
{l("API keys", "Ключи API")}
|
||||
</a>
|
||||
<Link className="button" href="/docs?page=developer-api">
|
||||
<BookOpen size={16} aria-hidden="true" />
|
||||
{l("Documentation", "Документация")}
|
||||
</Link>
|
||||
<a className="button" href="/api/dev/openapi.json">
|
||||
<Code2 size={16} aria-hidden="true" />
|
||||
OpenAPI JSON
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section
|
||||
id="api-keys"
|
||||
className={styles.panel}
|
||||
aria-labelledby="api-keys-title"
|
||||
>
|
||||
<div className={styles.heading}>
|
||||
<KeyRound size={24} aria-hidden="true" />
|
||||
<h2 id="api-keys-title">{l("Your API keys", "Ваши ключи API")}</h2>
|
||||
</div>
|
||||
<p>
|
||||
{l(
|
||||
"Keys belong to your account. Keep them private and grant only the access your integration needs. This API is read-only: keys cannot upload, publish, change maps or use moderation tools.",
|
||||
"Ключи принадлежат вашему аккаунту. Храните их в секрете и выдавайте только необходимые права. Этот API доступен только для чтения: ключи не позволяют загружать, публиковать, изменять карты или использовать инструменты модерации.",
|
||||
)}
|
||||
</p>
|
||||
{!signedIn ? (
|
||||
<div className={styles.notice}>
|
||||
<p>
|
||||
{l(
|
||||
"Everyone can read the documentation. Log in to create and manage your own keys; no Admin permission is required.",
|
||||
"Документация доступна всем. Войдите, чтобы создавать свои ключи и управлять ими; права администратора не нужны.",
|
||||
)}
|
||||
</p>
|
||||
<Link className="button primary" href="/log-in?next=%2Fdev%2Fapi">
|
||||
{l("Log in to manage keys", "Войти для управления ключами")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p role="alert" className={`${styles.notice} ${styles.error}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className={styles.notice}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{token && (
|
||||
<div className={styles.token}>
|
||||
<h3>
|
||||
{l("Copy your new key now", "Скопируйте новый ключ сейчас")}
|
||||
</h3>
|
||||
<p>
|
||||
{l(
|
||||
"This is the only time the full key is shown. Hiding it or leaving this page removes it from view. MineGeo stores only its hash.",
|
||||
"Полный ключ показывается только один раз. После скрытия или ухода со страницы его нельзя посмотреть снова. MineGeo хранит только хеш ключа.",
|
||||
)}
|
||||
</p>
|
||||
<label className="field">
|
||||
{l("New API key", "Новый ключ API")}
|
||||
<input
|
||||
readOnly
|
||||
value={token}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.actions}>
|
||||
<button className="button" onClick={() => void copy(token)}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
{l("Copy key", "Копировать ключ")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
setToken(null);
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
{l("Hide key", "Скрыть ключ")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<p role="status">{l("Loading keys…", "Загрузка ключей…")}</p>
|
||||
)}
|
||||
{!loading && !loaded && (
|
||||
<button className="button" onClick={() => void reload()}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Try again", "Повторить")}
|
||||
</button>
|
||||
)}
|
||||
{loaded && (
|
||||
<>
|
||||
<form onSubmit={(event) => void create(event)}>
|
||||
<h3>{l("Create a key", "Создать ключ")}</h3>
|
||||
<p className="muted">
|
||||
{l(
|
||||
`${activeCount} of 5 active keys. Each key expires after the number of days you choose.`,
|
||||
`Активных ключей: ${activeCount} из 5. Срок действия каждого ключа задаётся в днях.`,
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.fields}>
|
||||
<label className="field">
|
||||
{l("Key name", "Название ключа")}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={60}
|
||||
placeholder={l(
|
||||
"My Python project",
|
||||
"Мой проект на Python",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
{l("Expires in days", "Срок действия, дней")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
required
|
||||
value={expiresDays}
|
||||
onChange={(e) => setExpiresDays(Number(e.target.value))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className={styles.fieldset} disabled={busy}>
|
||||
<legend>{l("Permissions", "Права доступа")}</legend>
|
||||
<div className={styles.scopes}>
|
||||
{developerScopes.map((scope) => (
|
||||
<label className={styles.scope} key={scope.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(scope.id)}
|
||||
onChange={(e) =>
|
||||
setScopes((selected) =>
|
||||
e.target.checked
|
||||
? [...selected, scope.id]
|
||||
: selected.filter((id) => id !== scope.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{scope.name[ru ? 1 : 0]}</strong>
|
||||
<small>
|
||||
<code>{scope.id}</code> ·{" "}
|
||||
{scope.description[ru ? 1 : 0]}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={
|
||||
busy ||
|
||||
loading ||
|
||||
scopes.length === 0 ||
|
||||
activeCount >= 5 ||
|
||||
token !== null
|
||||
}
|
||||
>
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
{busy
|
||||
? l("Working…", "Подождите…")
|
||||
: l("Create API key", "Создать ключ API")}
|
||||
</button>
|
||||
{token && (
|
||||
<p className="muted">
|
||||
{l(
|
||||
"Copy and hide the current key before creating another.",
|
||||
"Скопируйте и скройте текущий ключ перед созданием следующего.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className="button"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Refresh keys", "Обновить ключи")}
|
||||
</button>
|
||||
</div>
|
||||
{keys.length === 0 && (
|
||||
<p className={styles.notice}>
|
||||
{l(
|
||||
"You have no API keys yet.",
|
||||
"У вас пока нет ключей API.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles.keyList}>
|
||||
{keys.map((key) => {
|
||||
const expired =
|
||||
new Date(key.expiresAt).getTime() <= Date.now();
|
||||
return (
|
||||
<li key={key.id} className={styles.key}>
|
||||
<div className={styles.heading}>
|
||||
<h3>{key.name}</h3>
|
||||
<span className="badge">
|
||||
{key.revokedAt
|
||||
? l("Revoked", "Отозван")
|
||||
: expired
|
||||
? l("Expired", "Истёк")
|
||||
: l("Active", "Активен")}
|
||||
</span>
|
||||
</div>
|
||||
<code>{key.prefix}…</code>
|
||||
<dl>
|
||||
<dt>{l("Permissions", "Права")}</dt>
|
||||
<dd>{key.scopes.join(", ")}</dd>
|
||||
<dt>{l("Created", "Создан")}</dt>
|
||||
<dd>{date(key.createdAt)}</dd>
|
||||
<dt>{l("Expires", "Истекает")}</dt>
|
||||
<dd>{date(key.expiresAt)}</dd>
|
||||
<dt>{l("Last used", "Последнее использование")}</dt>
|
||||
<dd>
|
||||
{key.lastUsedAt
|
||||
? date(key.lastUsedAt)
|
||||
: l("Not used yet", "Ещё не использован")}
|
||||
</dd>
|
||||
</dl>
|
||||
{!key.revokedAt &&
|
||||
(confirmRevoke === key.id ? (
|
||||
<div>
|
||||
<p>
|
||||
{l(
|
||||
"Revoke this key? Every integration using it will lose access immediately.",
|
||||
"Отозвать этот ключ? Все интеграции с ним сразу потеряют доступ.",
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => void revoke(key.id)}
|
||||
>
|
||||
{l("Confirm revocation", "Подтвердить отзыв")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmRevoke(null)}
|
||||
>
|
||||
{l("Cancel", "Отмена")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmRevoke(key.id)}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
{l("Revoke key", "Отозвать ключ")}
|
||||
</button>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ModeratorSettings } from "./ModeratorSettings";
|
||||
import { ModeratorPrefixes } from "./ModeratorPrefixes";
|
||||
import { isAdministrator, type StaffRole } from "@/shared/permissions";
|
||||
import { DiscordIntegration } from "./DiscordIntegration";
|
||||
import { PrometheusIntegration } from "./PrometheusIntegration";
|
||||
type ReviewMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -124,6 +125,7 @@ export function Moderation() {
|
||||
{administrator && <ModeratorSettings />}
|
||||
{administrator && <ModeratorPrefixes />}
|
||||
{administrator && <DiscordIntegration />}
|
||||
{administrator && <PrometheusIntegration />}
|
||||
{role === "moderator" && (
|
||||
<p className="muted">
|
||||
{t(
|
||||
|
||||
271
components/PrometheusIntegration.tsx
Normal file
271
components/PrometheusIntegration.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity, Copy, Download } from "lucide-react";
|
||||
import { api } from "@/lib/client-api";
|
||||
import { prometheusConfig, type PrometheusStatus } from "@/shared/prometheus";
|
||||
import { grafanaDashboard } from "@/shared/grafana-dashboard";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
|
||||
export function PrometheusIntegration() {
|
||||
const { t, locale } = useI18n();
|
||||
const [status, setStatus] = useState<PrometheusStatus | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [confirm, setConfirm] = useState<"rotate" | "disable" | null>(null);
|
||||
async function load() {
|
||||
try {
|
||||
setStatus(await api<PrometheusStatus>("moderation/prometheus"));
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
async function configure(action: "rotate" | "disable") {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await api<PrometheusStatus & { token: string | null }>(
|
||||
"moderation/prometheus",
|
||||
{ action },
|
||||
);
|
||||
const { token: nextToken, ...nextStatus } = result;
|
||||
setStatus(nextStatus);
|
||||
setToken(nextToken);
|
||||
setConfirm(null);
|
||||
setMessage(
|
||||
action === "rotate"
|
||||
? "New metrics token generated. Copy the config now; it cannot be recovered later."
|
||||
: "Metrics access disabled. Existing scrape credentials no longer work.",
|
||||
);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setMessage("Copied to clipboard.");
|
||||
setError("");
|
||||
} catch {
|
||||
setError("Could not copy. Select the text and copy it manually.");
|
||||
}
|
||||
}
|
||||
function dashboard() {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(grafanaDashboard(), null, 2)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "minegeo-grafana-dashboard.json";
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
const config =
|
||||
status && token && status.endpoint.startsWith("https:")
|
||||
? prometheusConfig(status.endpoint, token)
|
||||
: "";
|
||||
return (
|
||||
<section className="tool-card" aria-labelledby="prometheus-title">
|
||||
<div className="settings-heading">
|
||||
<Activity size={24} aria-hidden="true" />
|
||||
<h2 id="prometheus-title">{t("Prometheus & Grafana")}</h2>
|
||||
</div>
|
||||
<p>
|
||||
{t(
|
||||
"Track games, maps, multiplayer, uploads, and reports in your own Grafana dashboard. Admin permission is required to manage access.",
|
||||
)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
{t(
|
||||
"Only aggregate counts are exported. Usernames, messages, emails, and map identifiers are excluded.",
|
||||
)}
|
||||
</p>
|
||||
{!status && !error && <p role="status">{t("Loading metrics setup…")}</p>}
|
||||
{status && (
|
||||
<>
|
||||
<p>
|
||||
<span className="badge">
|
||||
{status.enabled ? t("Metrics enabled") : t("Metrics disabled")}
|
||||
</span>
|
||||
</p>
|
||||
<label className="field">
|
||||
{t("Metrics endpoint")}
|
||||
<input readOnly value={status.endpoint} />
|
||||
</label>
|
||||
<button className="button" onClick={() => void copy(status.endpoint)}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
{t("Copy endpoint")}
|
||||
</button>
|
||||
<p className="muted">
|
||||
{t("Last successful scrape: {time}", {
|
||||
time: status.lastScrapedAt
|
||||
? new Date(status.lastScrapedAt).toLocaleString(
|
||||
locale === "ru" ? "ru-RU" : "en-GB",
|
||||
)
|
||||
: t("Not scraped yet"),
|
||||
})}
|
||||
</p>
|
||||
{!status.endpoint.startsWith("https:") && (
|
||||
<p className="error-message">
|
||||
{t(
|
||||
"Use the hosted HTTPS site to generate a production scrape config.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<ol className="metrics-steps">
|
||||
<li>
|
||||
{t(
|
||||
"Generate a token below, then copy the config into prometheus.yml. If scrape_configs already exists, add only the MineGeo job beneath it.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"Reload or restart Prometheus. Open its Targets page and check that the minegeo job is UP.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"In Grafana, add your Prometheus server as a data source. Import the downloaded dashboard JSON and select that data source.",
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
<p className="muted">
|
||||
{t(
|
||||
"History starts with the first scrape. Counts are cached for 30 seconds; room states are not online presence. This integration does not export page views or Vercel CPU statistics.",
|
||||
)}
|
||||
</p>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
status.enabled ? setConfirm("rotate") : void configure("rotate")
|
||||
}
|
||||
>
|
||||
{status.enabled
|
||||
? t("Rotate metrics token")
|
||||
: t("Generate metrics token")}
|
||||
</button>
|
||||
{status.enabled && (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm("disable")}
|
||||
>
|
||||
{t("Disable metrics access")}
|
||||
</button>
|
||||
)}
|
||||
<button className="button" onClick={dashboard}>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
{t("Download Grafana dashboard")}
|
||||
</button>
|
||||
<button
|
||||
className="text-link"
|
||||
disabled={busy}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("Refresh status")}
|
||||
</button>
|
||||
</div>
|
||||
{confirm && (
|
||||
<div
|
||||
className="token-box"
|
||||
role="group"
|
||||
aria-label={t("Confirm metrics access change")}
|
||||
>
|
||||
<p>
|
||||
{confirm === "rotate"
|
||||
? t(
|
||||
"Rotating immediately invalidates the old token. Update Prometheus with the new config afterward.",
|
||||
)
|
||||
: t(
|
||||
"Disabling immediately stops all authenticated scrapes. Prometheus will report this target as DOWN.",
|
||||
)}
|
||||
</p>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => void configure(confirm)}
|
||||
>
|
||||
{t("Confirm change")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm(null)}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{token && (
|
||||
<div className="token-box">
|
||||
<p>
|
||||
{t(
|
||||
"Keep this config private. The token is shown once and grants read-only access to aggregate metrics. Rotating or disabling it revokes access.",
|
||||
)}
|
||||
</p>
|
||||
{config ? (
|
||||
<>
|
||||
<label className="field">
|
||||
{t("Prometheus scrape config")}
|
||||
<textarea
|
||||
className="metrics-config"
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={config}
|
||||
rows={15}
|
||||
/>
|
||||
</label>
|
||||
<button className="button" onClick={() => void copy(config)}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
{t("Copy Prometheus config")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label className="field">
|
||||
{t("Metrics token")}
|
||||
<textarea readOnly value={token} />
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
className="text-link"
|
||||
onClick={() => {
|
||||
setToken(null);
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
{t("Hide generated credentials")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status.enabled && !token && (
|
||||
<p className="muted">
|
||||
{t(
|
||||
"The existing token cannot be retrieved. Use your saved config, or rotate the token to generate a new one.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="error-message" role="alert">
|
||||
{t(error)}
|
||||
</p>
|
||||
)}
|
||||
{message && <p role="status">{t(message)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { SiteNav } from "./SiteNav";
|
||||
import { ReportBugButton } from "./ReportBugButton";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { CommunityLinks } from "./CommunityLinks";
|
||||
import { forumUrl } from "@/lib/forum";
|
||||
export async function Header() {
|
||||
const { t } = await getI18n();
|
||||
const account = await user();
|
||||
@@ -16,7 +17,7 @@ export async function Header() {
|
||||
<header className="site-header">
|
||||
<div className="header-inner">
|
||||
<Brand />
|
||||
<SiteNav />
|
||||
<SiteNav forumUrl={forumUrl()} />
|
||||
<div className="header-actions">
|
||||
<ThemeToggle />
|
||||
<ReportBugButton />
|
||||
@@ -33,6 +34,7 @@ export async function Header() {
|
||||
}
|
||||
export async function Footer() {
|
||||
const { t } = await getI18n();
|
||||
const forum = forumUrl();
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div>
|
||||
@@ -45,6 +47,18 @@ export async function Footer() {
|
||||
<LanguageSwitcher />
|
||||
<Link href="/downloads">{t("Tools & downloads")}</Link>
|
||||
<Link href="/docs">{t("Docs")}</Link>
|
||||
{forum && (
|
||||
<a href={forum} target="_blank" rel="noopener noreferrer">
|
||||
{t("Forum")}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://minegeo.instatus.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("Status")}
|
||||
</a>
|
||||
<Link href="/leaderboard">{t("Leaderboard")}</Link>
|
||||
<Link href="/moderation">{t("Moderation")}</Link>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const links = [
|
||||
["/docs", "Docs"],
|
||||
] as const;
|
||||
|
||||
export function SiteNav() {
|
||||
export function SiteNav({ forumUrl }: { forumUrl?: string | null }) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
@@ -30,6 +30,11 @@ export function SiteNav() {
|
||||
{t(label)}
|
||||
</Link>
|
||||
))}
|
||||
{forumUrl && (
|
||||
<a href={forumUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("Forum")}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/I18nProvider";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
||||
@@ -22,7 +22,10 @@ export function StartGame({
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const starting = useRef(false);
|
||||
async function start() {
|
||||
if (starting.current) return;
|
||||
starting.current = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -36,6 +39,7 @@ export function StartGame({
|
||||
);
|
||||
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
|
||||
} catch (e) {
|
||||
starting.current = false;
|
||||
setError((e as Error).message);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -68,6 +72,7 @@ export function StartGame({
|
||||
key={key}
|
||||
aria-pressed={difficulty === key}
|
||||
className={difficulty === key ? "selected" : ""}
|
||||
disabled={loading}
|
||||
onClick={() => setDifficulty(key as Difficulty)}
|
||||
>
|
||||
<strong>{t(value.label)}</strong>
|
||||
|
||||
@@ -37,19 +37,26 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
[name, setName] = useState(""),
|
||||
[file, setFile] = useState<File | null>(null);
|
||||
const nonce = useRef<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
api<Inbox>("bug-reports")
|
||||
.then((value) => {
|
||||
if (!stopped) setInbox(value);
|
||||
})
|
||||
.catch((failure) => {
|
||||
if (!stopped) setError(failure.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stopped) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
stopped = true;
|
||||
};
|
||||
}, []);
|
||||
}, [attempt]);
|
||||
async function submit() {
|
||||
if (busy) return;
|
||||
const clientId = (nonce.current ??= crypto.randomUUID());
|
||||
@@ -109,7 +116,7 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
</span>
|
||||
<h1>{t("Found a bug?")}</h1>
|
||||
<p className="lead">{t("Tell us what happened. We’ll reply here.")}</p>
|
||||
{!inbox?.signedIn && (
|
||||
{inbox && !inbox.signedIn && (
|
||||
<p className="muted">
|
||||
{t(
|
||||
"Guest reports stay in this browser. Sign in to keep your conversation in your account.",
|
||||
@@ -120,6 +127,19 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
{error && (
|
||||
<p className="error-message" role="alert">
|
||||
{t(error)}
|
||||
{!inbox && (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-link"
|
||||
disabled={loading}
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
{t("Retry")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{inbox && !inbox.ready && (
|
||||
@@ -135,7 +155,7 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{!inbox?.signedIn && (
|
||||
{inbox && !inbox.signedIn && (
|
||||
<>
|
||||
<label htmlFor="report-name">{t("Your player name")}</label>
|
||||
<input
|
||||
@@ -199,7 +219,10 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
<small>
|
||||
{t("PNG, JPG, or WebP, up to 5 MB. Shared privately with support.")}
|
||||
</small>
|
||||
<button className="button primary" disabled={busy || !inbox?.ready}>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy || loading || !inbox?.ready}
|
||||
>
|
||||
<Send size={17} />
|
||||
{busy ? t("Sending…") : t("Send bug report")}
|
||||
</button>
|
||||
@@ -207,9 +230,9 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
</section>
|
||||
<section className="support-inbox">
|
||||
<h2>{t("Your bug reports")}</h2>
|
||||
{!inbox ? (
|
||||
{!inbox && loading ? (
|
||||
<p className="muted">{t("Loading…")}</p>
|
||||
) : inbox.reports.length === 0 ? (
|
||||
) : !inbox ? null : inbox.reports.length === 0 ? (
|
||||
<p className="muted">
|
||||
{t("Your reports and support replies will appear here.")}
|
||||
</p>
|
||||
|
||||
66
docs/bedrock-feasibility.md
Normal file
66
docs/bedrock-feasibility.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Bedrock panorama capture feasibility
|
||||
|
||||
Investigated on 12 October 2026 using Microsoft's current Creator documentation,
|
||||
Context7, and MineGeo's Fabric source.
|
||||
|
||||
## Result
|
||||
|
||||
A self-contained, supported Bedrock add-on cannot currently reproduce MineGeo's
|
||||
Java capture mod. Camera control is available, but the documented Script APIs
|
||||
do not expose the client framebuffer, a screenshot-to-PNG operation, or the
|
||||
square render target that the Java implementation uses. This is a conclusion
|
||||
from the exposed API surface, rather than an explicit promise by Microsoft that
|
||||
these capabilities will never exist.
|
||||
|
||||
No `.mcaddon` release was built or added to the downloads page: a pack that only
|
||||
turns the camera would not deliver the requested working panorama exporter.
|
||||
|
||||
## How Bedrock add-ons differ
|
||||
|
||||
Resource packs supply assets and presentation; behavior packs supply world
|
||||
behavior and can contain JavaScript modules. Pack manifests identify the packs,
|
||||
modules, versions, and dependencies. Imported packs are activated for a world.
|
||||
See [Getting started with add-ons](https://learn.microsoft.com/en-us/minecraft/creator/documents/gettingstarted?view=minecraft-bedrock-stable).
|
||||
|
||||
Bedrock gameplay scripts execute on the world/server side. Installing a local
|
||||
resource pack does not give a player a client-side scripting hook into arbitrary
|
||||
multiplayer servers. A scripted camera helper would need the host to activate
|
||||
its behavior pack. See [Multiplayer script architecture](https://learn.microsoft.com/en-us/minecraft/creator/documents/scripting/multiplayer-scripts?view=minecraft-bedrock-stable).
|
||||
|
||||
## Comparison with MineGeo's Java implementation
|
||||
|
||||
| Required capability | Existing Java implementation | Supported Bedrock add-on surface |
|
||||
| --- | --- | --- |
|
||||
| Aim six views from one position | `Capture.java` overrides render-camera yaw/pitch | Camera position, rotation and FOV controls exist |
|
||||
| Produce six 1024 x 1024 faces | Dedicated square framebuffer with six PNG writes | No documented framebuffer/image capture API |
|
||||
| Save a surface map and capture manifest | `SurfaceDataset.java` writes PNG and JSON files | World data can be inspected, but no documented equivalent PNG/file exporter |
|
||||
| Upload from an ordinary player's game | Java client performs upload | `server-net` HTTP is Dedicated Server only; not game clients or Realms |
|
||||
|
||||
Sources: [Camera API](https://learn.microsoft.com/en-us/minecraft/creator/scriptapi/minecraft/server/camera?view=minecraft-bedrock-stable),
|
||||
[Graphics API](https://learn.microsoft.com/en-us/minecraft/creator/scriptapi/minecraft/server-graphics/minecraft-server-graphics?view=minecraft-bedrock-experimental),
|
||||
[Dimension API](https://learn.microsoft.com/en-us/minecraft/creator/scriptapi/minecraft/server/dimension?view=minecraft-bedrock-stable),
|
||||
and [HTTP module restrictions](https://learn.microsoft.com/en-us/minecraft/creator/scriptapi/minecraft/server-net/minecraft-server-net).
|
||||
|
||||
The graphics module exposes lighting, atmospheric, water and color-grading
|
||||
controls; it does not expose screenshot capture. Built-in screenshots or
|
||||
resource-pack screenshot buttons should not be confused with a script API that
|
||||
returns six image files and metadata to an exporter.
|
||||
|
||||
## Plausible alternative requiring a separate implementation
|
||||
|
||||
A Windows companion could capture the game window while a host-installed
|
||||
behavior pack guides the camera through six views. The companion would own
|
||||
image processing, local files, checksums and upload. Manual screenshots are
|
||||
another possible input. Neither approach is a portable, automatic add-on with
|
||||
the Java mod's existing behavior.
|
||||
|
||||
Before releasing such a companion, an in-game prototype must establish:
|
||||
|
||||
1. Consistent square capture geometry and matching 90-degree cube-face projection.
|
||||
2. Matching face orientation, stable camera position, hidden HUD/hand and loaded terrain.
|
||||
3. A reliable link between coordinates and the captured images, plus camera restoration on cancellation.
|
||||
4. Surface-map export and explicit Bedrock support in MineGeo's dataset validation and upload processing.
|
||||
|
||||
These alternatives have not been implemented or tested in Bedrock. Existing
|
||||
Fabric sources, published Java artifacts, and the production deployment are
|
||||
unchanged by this investigation.
|
||||
69
docs/developer-api.md
Normal file
69
docs/developer-api.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# MineGeo developer API
|
||||
|
||||
The developer key panel is at `/dev/api`; `/api/dev` redirects there. The API reference is part of the existing documentation at `/docs?page=developer-api`, alongside the player guides. Any visitor can read the reference. Any signed-in player can create API keys independently of the account dashboard and without a Moderator or Admin role.
|
||||
|
||||
## Keys and permissions
|
||||
|
||||
Create a named key in the developer panel. A key is shown in full once, remains only in browser memory while the panel is open, and can be copied or hidden. Only a prefix and metadata appear in the key list. The server stores a SHA-256 hash, not the raw key. Keep keys out of source code, public repositories, browser JavaScript and URLs. Store them in a server-side environment variable such as `MINEGEO_API_KEY`.
|
||||
|
||||
An account can hold 5 active keys. Expiration is 1–365 days, default 90. Revoke keys in the panel at any time. Revocation is immediate; an expired or revoked key cannot be recovered. To change permissions, revoke and replace the key.
|
||||
|
||||
| Scope | Access |
|
||||
| -------------------- | ------------------------------------------------------------------ |
|
||||
| `maps:read` | Published catalog and published map metadata. Selected by default. |
|
||||
| `account:read` | Key owner's profile and username appearance. |
|
||||
| `account:maps:read` | Key owner's map metadata, including private maps. |
|
||||
| `account:games:read` | Key owner's game history. |
|
||||
|
||||
Version 1 is read-only. No key grants uploads, edits, publication, moderation or access to other accounts' private data. Contributor upload tokens and Prometheus metrics tokens cannot be used as developer keys.
|
||||
|
||||
## Requests
|
||||
|
||||
Base URL: `https://minegeo.vercel.app/api/dev/v1`
|
||||
|
||||
Every request requires `Authorization: Bearer <developer-key>`. Developer keys start with `mg_dev_`. Browser session cookies do not authenticate these endpoints. No MineGeo SDK is provided.
|
||||
|
||||
Install the Python HTTP library with `python -m pip install requests`, set `MINEGEO_API_KEY` in your environment, and make a request:
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
"https://minegeo.vercel.app/api/dev/v1/maps",
|
||||
headers={"Authorization": f"Bearer {os.environ['MINEGEO_API_KEY']}"},
|
||||
params={"limit": 20, "offset": 0},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
| GET path | Required scope | Response |
|
||||
| ------------ | -------------------- | ----------------------------------- |
|
||||
| `/maps` | `maps:read` | `{maps: [...], pagination: {...}}` |
|
||||
| `/maps/{id}` | `maps:read` | `{map: {...}}` |
|
||||
| `/me` | `account:read` | `{account: {...}}` |
|
||||
| `/me/maps` | `account:maps:read` | `{maps: [...], pagination: {...}}` |
|
||||
| `/me/games` | `account:games:read` | `{games: [...], pagination: {...}}` |
|
||||
|
||||
Lists accept `limit` (default 20, maximum 50) and `offset` (default 0, maximum 10000). `/maps` also accepts a map-name search in `q` (up to 80 characters). Unknown or duplicate query parameters return HTTP 400. Pagination contains `limit`, `offset`, `total` and `nextOffset`; `nextOffset: null` marks the end of the supported result window. Narrow the search if you reach the offset cap.
|
||||
|
||||
Map metadata contains `id`, `name`, `description`, `source`, `state`, `bounds`, `panoramaCount`, `createdAt`, `updatedAt` and `url`. The URL is relative to the site origin. It excludes raw storage paths, panorama coordinates and moderation notes.
|
||||
|
||||
`/me` contains `id`, `displayName`, `createdAt` and `appearance` (`nameGlow`, `glowColor`, `bugFinder`, `customPrefix`, `customPrefixIcon`). Email and account roles are excluded. Game records contain `id`, `difficulty`, `createdAt`, `currentRound`, `complete` and `score`. Score is `null` until completion; answer locations are never returned.
|
||||
|
||||
The machine-readable contract is `/api/dev/openapi.json`.
|
||||
|
||||
## Rate limits and errors
|
||||
|
||||
Requests with a valid developer key are limited to 60 per minute per key and 180 per minute shared by all keys belonging to its account. Permission and validation failures consume the authenticated quota. Creating multiple keys does not bypass the account limit.
|
||||
|
||||
- `RateLimit-Limit`: per-key limit.
|
||||
- `RateLimit-Remaining`: remaining per-key quota.
|
||||
- `RateLimit-Reset`: seconds until the current minute window resets.
|
||||
- `X-RateLimit-Reset`: reset time as a Unix timestamp.
|
||||
- `X-RateLimit-Owner-Limit` and `X-RateLimit-Owner-Remaining`: shared account quota.
|
||||
- On HTTP 429, `Retry-After` gives the minimum delay in seconds before retrying.
|
||||
|
||||
Errors use `{"error":{"code":"...","message":"..."}}`. Treat the HTTP status and code as machine-readable; messages may change. Expected statuses include 400 for invalid inputs, 401 for missing or invalid credentials (including expiry or revocation), 403 for a missing scope, 404 for unavailable maps or endpoints, 429 for quota exhaustion, and 500/503 for service failures. Honor retry delays and avoid rapid retry loops.
|
||||
58
docs/forum.md
Normal file
58
docs/forum.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# MineGeo community forum
|
||||
|
||||
Live forum: https://minegeo.discourse.group/
|
||||
|
||||
Created on 12 October 2026 under Pavel's Discourse account. The public forum
|
||||
has Announcements, Maps & creations, Looking for players, and Help & feedback
|
||||
categories, plus Discourse's default General, Site Feedback, and private Staff
|
||||
categories. The pinned welcome and three conversation starters are published.
|
||||
|
||||
The community forum uses Discourse's Free hosted plan. Players sign in with
|
||||
Discourse ID; MineGeo account credentials and permissions are not shared with it.
|
||||
Forum staff permissions are managed separately in Discourse.
|
||||
|
||||
The public destination is stored in `shared/forum.json`. Both the header and
|
||||
footer use it. An optional `MINEGEO_FORUM_URL` environment variable overrides it;
|
||||
an empty or invalid value hides the links. Only HTTPS URLs without credentials
|
||||
or custom ports are accepted. No forum API key is needed for these links.
|
||||
|
||||
## Starter welcome topic
|
||||
|
||||
Welcome to MineGeo! :D
|
||||
|
||||
Play at https://minegeo.vercel.app and find the player guide at
|
||||
https://minegeo.vercel.app/docs.
|
||||
|
||||
This is our place to share maps, find people to play with, ask for help, and
|
||||
suggest what MineGeo should do next. English and Russian are welcome.
|
||||
|
||||
- Be kind. No harassment, spam, or sharing someone's private information.
|
||||
- Share maps and screenshots you have permission to share. Credit creators.
|
||||
- Post a multiplayer room link when looking for players; say when it closes.
|
||||
- Use the site's **Report a bug** button for bugs so support can track them.
|
||||
Never post passwords, tokens, private server addresses, or other secrets here.
|
||||
- Flag posts that need staff attention instead of arguing.
|
||||
|
||||
## Starter categories
|
||||
|
||||
- **Announcements** — MineGeo news and updates.
|
||||
- **Maps & creations** — Show your worlds and share playable map links.
|
||||
- **Looking for players** — Share party links and find your next teammates.
|
||||
- **Help & feedback** — Ask questions and suggest improvements.
|
||||
|
||||
## Conversation starters
|
||||
|
||||
**Show us your Minecraft world**
|
||||
|
||||
Built a castle, a tiny village, or a questionable dirt hut? Show a screenshot
|
||||
and tell us about it. Add your published MineGeo map link if you have one.
|
||||
|
||||
**Find your next MineGeo teammates**
|
||||
|
||||
Share your room link, language, and when you're playing. Let everyone know when
|
||||
the party ends. You don't need to share your real name or contact details.
|
||||
|
||||
**What would make MineGeo more fun?**
|
||||
|
||||
Tell us one thing you'd like to see next and how you'd use it. For a broken
|
||||
feature, please use the site's Report a bug button so you can get support replies.
|
||||
77
docs/prometheus.md
Normal file
77
docs/prometheus.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Prometheus and Grafana
|
||||
|
||||
Open MineGeo's **Moderation** page while signed in as an **Admin**, then find
|
||||
**Prometheus & Grafana**. Regular Moderators cannot view or change this setup.
|
||||
No new Vercel environment variables or Supabase keys are needed.
|
||||
|
||||
## Connect Prometheus
|
||||
|
||||
1. Click **Generate metrics token**. Copy the generated configuration immediately.
|
||||
Only a token hash is stored; the raw token cannot be recovered after leaving
|
||||
the page or hiding it.
|
||||
2. Paste the configuration into your Prometheus server's `prometheus.yml`.
|
||||
If that file already has `scrape_configs:`, add only the `- job_name: minegeo`
|
||||
entry under that existing section. Do not create two `scrape_configs` keys.
|
||||
3. Validate the configuration with `promtool check config prometheus.yml`, then
|
||||
reload or restart your Prometheus service using its normal deployment process.
|
||||
4. In Prometheus, open **Status → Targets** and check that `minegeo` is **UP**.
|
||||
A successful scrape also appears in MineGeo's **Last successful scrape** field;
|
||||
use **Refresh status** to update it.
|
||||
|
||||
Prometheus must be able to reach MineGeo's public HTTPS address. The endpoint is
|
||||
`https://minegeo.vercel.app/api/metrics`, protected by an Authorization bearer
|
||||
header. Browser login cookies, plugin credentials, and URL query tokens do not
|
||||
grant access. The generated job scrapes once a minute with a 20-second timeout
|
||||
and does not forward credentials through redirects. Keep the configuration
|
||||
private; do not paste it into public Git repositories or forum posts.
|
||||
|
||||
**Rotate metrics token** revokes the previous token immediately. Copy the new
|
||||
configuration and update Prometheus afterward. **Disable metrics access** revokes
|
||||
the current token. Deleting the issuing Admin account or removing its Admin role
|
||||
also invalidates that token. Issuing another token requires a current Admin.
|
||||
|
||||
## Import the Grafana dashboard
|
||||
|
||||
1. In MineGeo, click **Download Grafana dashboard**.
|
||||
2. In Grafana, add a **Prometheus** data source pointing to your Prometheus server
|
||||
(for example, its internal service address). Grafana queries Prometheus;
|
||||
it does not use the MineGeo metrics URL as its data source.
|
||||
3. Open **Dashboards → New → Import**, upload the downloaded JSON, and choose the
|
||||
Prometheus data source when prompted.
|
||||
4. Select the MineGeo instance. Allow one or two scrapes for the first values.
|
||||
|
||||
The dashboard has 15 panels covering scrape health, registered accounts, maps,
|
||||
panoramas, solo games, multiplayer rooms, uploads, bug reports, content reports,
|
||||
Discord delivery, exporter read time, and snapshot age. It requires only the
|
||||
built-in Prometheus source and Grafana stat/time-series panels.
|
||||
|
||||
History begins when your Prometheus instance starts scraping; existing retained
|
||||
records appear in the first snapshot. Prometheus retention controls how long
|
||||
history remains. Set up Prometheus/Grafana on your own server or supported hosted
|
||||
monitoring service; Vercel runs the exporter, not the monitoring servers.
|
||||
|
||||
## Meaning and privacy of metrics
|
||||
|
||||
- All `minegeo_*` values are **gauges**, not monotonic counters. Deleted records,
|
||||
including closed bug reports removed after three days, can lower counts.
|
||||
Do not use `rate()` or `increase()` on these retained inventory gauges.
|
||||
- Games/rooms created in the last 24 hours are rolling-window gauges.
|
||||
- Solo games are complete once `current_round = 5`. Incomplete includes abandoned
|
||||
games. Room lifecycle states describe stored rooms, not currently online users.
|
||||
- Aggregates are collected in one database transaction and cached there for
|
||||
30 seconds to share work across Vercel instances. Failed scrapes return 503,
|
||||
never a fabricated all-zero dataset. Prometheus's `up` series records failures.
|
||||
- Names, IDs, emails, messages, screenshots, credentials, and map content are
|
||||
excluded. Metric labels use fixed state/difficulty values only.
|
||||
- Page views, IP addresses, browser sessions, Vercel CPU/memory, and request latency
|
||||
are not collected by this integration. Vercel Web Analytics remains separate.
|
||||
|
||||
For another installation, apply
|
||||
`supabase/migrations/20261012085217_prometheus_metrics.sql` before deployment.
|
||||
The new table has RLS and service-role-only permissions; the three RPC functions
|
||||
use `security invoker` and explicitly revoke public/anonymous/authenticated access.
|
||||
Verify SQL authorization locally with `node scripts/test-prometheus-sql.mjs` using
|
||||
the same optional PGlite runtime as the existing SQL verification scripts.
|
||||
|
||||
References: [Prometheus scrape configuration](https://prometheus.io/docs/prometheus/latest/configuration/configuration/),
|
||||
[Grafana dashboard imports](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/import-dashboards/).
|
||||
@@ -1,5 +1,16 @@
|
||||
# Deploy MineGeo
|
||||
|
||||
The scoped developer API requires `20261012124943_developer_api_keys.sql`.
|
||||
No additional environment variables are needed. The standalone panel is
|
||||
`/dev/api`, `/api/dev` redirects there, and the versioned read-only API is
|
||||
`/api/dev/v1`. See [Developer API](developer-api.md) for permissions, rate
|
||||
limits, revocation, OpenAPI and Python requests examples.
|
||||
|
||||
Admin-only Prometheus/Grafana setup requires `20261012085217_prometheus_metrics.sql`.
|
||||
No new Vercel variables are needed: Admins generate a dedicated scrape token in
|
||||
Moderation. See [Prometheus and Grafana](prometheus.md) for the copyable config,
|
||||
dashboard import, credential rotation, and metric definitions.
|
||||
|
||||
The portable Windows Electron client and release process are described in
|
||||
[Desktop client](desktop.md). Build and publish the EXE before deploying changes
|
||||
to desktop download links. Public desktop releases are included in the upload;
|
||||
@@ -25,6 +36,10 @@ changing a link. For the hosted site, add the same names as Production Config
|
||||
variables in the MineGeo Vercel project, then redeploy. These values are public
|
||||
destinations; no Discord bot token or Telegram API key is needed.
|
||||
|
||||
The hosted community forum is linked in the header and footer using
|
||||
`shared/forum.json`. `MINEGEO_FORUM_URL` can override its public HTTPS address.
|
||||
See [Community forum](forum.md) for forum ownership and starter content.
|
||||
|
||||
Discord accepts HTTPS links on `discord.gg`, `discord.com`, `www.discord.com`,
|
||||
and `discordapp.com`. Telegram accepts `t.me` and `telegram.me`, including group
|
||||
invite links. Blank, malformed, or unsupported URLs hide the corresponding
|
||||
|
||||
@@ -20,6 +20,7 @@ import { accountApi } from "./account-api";
|
||||
import { multiplayerApi } from "./multiplayer-api";
|
||||
import { bugReportApi } from "./bug-report-api";
|
||||
import { moderatorUsersApi } from "./moderator-users-api";
|
||||
import { prometheusApi } from "./prometheus-api";
|
||||
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||
import { siteSettingsSchema } from "../shared/site-settings";
|
||||
import { getSiteSettings } from "./site-settings";
|
||||
@@ -905,6 +906,10 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
}
|
||||
if (path[0] === "moderation") {
|
||||
const { id: reviewer, role } = await moderator(request);
|
||||
if (path[1] === "prometheus" && path.length === 2) {
|
||||
requireAdministrator(role);
|
||||
return await prometheusApi(request, reviewer, jsonBody);
|
||||
}
|
||||
if (path[1] === "discord" && path.length === 2) {
|
||||
requireAdministrator(role);
|
||||
if (method === "GET") return NextResponse.json(await discordStatus());
|
||||
|
||||
148
lib/developer-api.ts
Normal file
148
lib/developer-api.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import "server-only";
|
||||
import { admin, required } from "./supabase";
|
||||
import { developerResponse } from "./developer-response";
|
||||
import {
|
||||
developerPage,
|
||||
type DeveloperPagination,
|
||||
} from "@/shared/developer-api";
|
||||
import type { Database } from "@/shared/database";
|
||||
|
||||
const mapColumns =
|
||||
"id,name,description,source,state,bounds,panorama_count,created_at,updated_at" as const;
|
||||
type MapRecord = Pick<
|
||||
Database["public"]["Tables"]["datasets"]["Row"],
|
||||
| "id"
|
||||
| "name"
|
||||
| "description"
|
||||
| "source"
|
||||
| "state"
|
||||
| "bounds"
|
||||
| "panorama_count"
|
||||
| "created_at"
|
||||
| "updated_at"
|
||||
>;
|
||||
function mapView(map: MapRecord) {
|
||||
return {
|
||||
id: map.id,
|
||||
name: map.name,
|
||||
description: map.description,
|
||||
source: map.source,
|
||||
state: map.state,
|
||||
bounds: map.bounds,
|
||||
panoramaCount: map.panorama_count,
|
||||
createdAt: map.created_at,
|
||||
updatedAt: map.updated_at,
|
||||
url: `/servers/${map.id}`,
|
||||
};
|
||||
}
|
||||
async function maps(page: DeveloperPagination, owner?: string) {
|
||||
let query = admin().from("datasets").select(mapColumns, { count: "exact" });
|
||||
// Public requests cannot reveal private, rejected or banned maps by guessing IDs.
|
||||
query = owner
|
||||
? query.eq("owner_id", owner)
|
||||
: query.eq("state", "published").is("banned_at", null);
|
||||
if (page.q)
|
||||
query = query.ilike("name", `%${page.q.replace(/[\\%_]/g, "\\$&")}%`);
|
||||
const result = await query
|
||||
.order("created_at", { ascending: false })
|
||||
.order("id")
|
||||
.range(page.offset, page.offset + page.limit - 1);
|
||||
const rows = required(result);
|
||||
return {
|
||||
maps: rows.map(mapView),
|
||||
pagination: developerPage(result.count ?? 0, page),
|
||||
};
|
||||
}
|
||||
async function map(id: string) {
|
||||
const result = await admin()
|
||||
.from("datasets")
|
||||
.select(mapColumns)
|
||||
.eq("id", id)
|
||||
.eq("state", "published")
|
||||
.is("banned_at", null)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
return result.data ? mapView(result.data) : null;
|
||||
}
|
||||
async function account(owner: string) {
|
||||
const result = await admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,created_at,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.eq("id", owner)
|
||||
.maybeSingle();
|
||||
if (result.error) required(result);
|
||||
const p = result.data;
|
||||
return p
|
||||
? {
|
||||
id: p.id,
|
||||
displayName: p.display_name,
|
||||
createdAt: p.created_at,
|
||||
appearance: {
|
||||
nameGlow: p.name_glow,
|
||||
glowColor: p.glow_color,
|
||||
bugFinder: p.bug_finder,
|
||||
customPrefix: p.custom_prefix,
|
||||
customPrefixIcon: p.custom_prefix_icon,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
async function games(owner: string, page: DeveloperPagination) {
|
||||
const result = await admin()
|
||||
.from("game_sessions")
|
||||
.select("id,difficulty,created_at,current_round", { count: "exact" })
|
||||
.eq("owner_id", owner)
|
||||
.order("created_at", { ascending: false })
|
||||
.order("id")
|
||||
.range(page.offset, page.offset + page.limit - 1);
|
||||
const rows = required(result);
|
||||
const complete = rows.filter((s) => s.current_round === 5).map((s) => s.id);
|
||||
const totals = new Map<string, { score: number; count: number }>();
|
||||
if (complete.length) {
|
||||
const rounds = required(
|
||||
await admin()
|
||||
.from("game_rounds")
|
||||
.select("session_id,result")
|
||||
.in("session_id", complete),
|
||||
);
|
||||
for (const round of rounds) {
|
||||
const result = round.result as { score?: unknown } | null;
|
||||
if (
|
||||
!Number.isInteger(result?.score) ||
|
||||
Number(result?.score) < 0 ||
|
||||
Number(result?.score) > 5000
|
||||
)
|
||||
continue;
|
||||
const previous = totals.get(round.session_id) ?? { score: 0, count: 0 };
|
||||
totals.set(round.session_id, {
|
||||
score: previous.score + Number(result?.score),
|
||||
count: previous.count + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
games: rows.map((s) => ({
|
||||
id: s.id,
|
||||
difficulty: s.difficulty,
|
||||
createdAt: s.created_at,
|
||||
currentRound: s.current_round,
|
||||
complete: s.current_round === 5,
|
||||
score: totals.get(s.id)?.count === 5 ? totals.get(s.id)!.score : null,
|
||||
})),
|
||||
pagination: developerPage(result.count ?? 0, page),
|
||||
};
|
||||
}
|
||||
export function developerApi(request: Request, path: string[]) {
|
||||
return developerResponse(request, path, {
|
||||
consume: async (hash) =>
|
||||
required(
|
||||
await admin().rpc("developer_key_consume", { p_token_hash: hash }),
|
||||
),
|
||||
maps,
|
||||
map,
|
||||
account,
|
||||
games,
|
||||
});
|
||||
}
|
||||
137
lib/developer-key-management.ts
Normal file
137
lib/developer-key-management.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import "server-only";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { admin, required, user } from "./supabase";
|
||||
import { accountProfile } from "./profile";
|
||||
import { browserAccount } from "./request-security";
|
||||
import { verifyOrigin } from "./request-origin";
|
||||
import { ServiceError } from "./service-error";
|
||||
import { developerKeyInput } from "@/shared/developer-api";
|
||||
import { developerError, developerJson } from "./developer-response";
|
||||
|
||||
async function body(request: Request) {
|
||||
if (!request.headers.get("content-type")?.includes("application/json"))
|
||||
throw new ServiceError("Use application/json for key management.", 400);
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) throw new ServiceError("JSON body required.", 400);
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
while (true) {
|
||||
const next = await reader.read();
|
||||
if (next.done) break;
|
||||
bytes += next.value.byteLength;
|
||||
if (bytes > 8192) {
|
||||
await reader.cancel();
|
||||
throw new ServiceError("Request too large.", 413);
|
||||
}
|
||||
chunks.push(next.value);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw new ServiceError("Valid JSON required.", 400);
|
||||
}
|
||||
}
|
||||
export async function developerKeys(request: Request) {
|
||||
try {
|
||||
if (!["GET", "POST", "DELETE"].includes(request.method))
|
||||
return developerError(
|
||||
"method_not_allowed",
|
||||
"Use GET, POST or DELETE.",
|
||||
405,
|
||||
{ Allow: "GET, POST, DELETE" },
|
||||
);
|
||||
// API/device/metrics credentials cannot mint keys, even alongside account cookies.
|
||||
const owner = await browserAccount(request, user);
|
||||
if (request.headers.get("sec-fetch-site") === "cross-site")
|
||||
return developerError(
|
||||
"invalid_origin",
|
||||
"Use the developer panel on MineGeo.",
|
||||
403,
|
||||
);
|
||||
try {
|
||||
verifyOrigin(request);
|
||||
} catch {
|
||||
return developerError(
|
||||
"invalid_origin",
|
||||
"Request origin is not allowed.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
await accountProfile(owner);
|
||||
if (request.method === "GET") {
|
||||
const keys = required(
|
||||
await admin().rpc("developer_keys_list", { p_owner: owner.id }),
|
||||
);
|
||||
return developerJson({ keys });
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
const { id } = z
|
||||
.object({ id: z.string().uuid() })
|
||||
.strict()
|
||||
.parse(await body(request));
|
||||
const ok = required(
|
||||
await admin().rpc("developer_key_revoke", {
|
||||
p_owner: owner.id,
|
||||
p_key: id,
|
||||
}),
|
||||
);
|
||||
return ok
|
||||
? developerJson({ ok: true })
|
||||
: developerError("not_found", "API key not found.", 404);
|
||||
}
|
||||
const input = developerKeyInput.parse(await body(request));
|
||||
const allowed = required(
|
||||
await admin().rpc("check_rate", {
|
||||
p_key: `developer:create:${owner.id}`,
|
||||
p_limit: 10,
|
||||
p_seconds: 3600,
|
||||
}),
|
||||
);
|
||||
if (!allowed)
|
||||
return developerError(
|
||||
"rate_limited",
|
||||
"Too many key creations. Try again later.",
|
||||
429,
|
||||
{ "Retry-After": "3600" },
|
||||
);
|
||||
const token = `mg_dev_${randomBytes(32).toString("hex")}`;
|
||||
const result = await admin().rpc("developer_key_create", {
|
||||
p_owner: owner.id,
|
||||
p_name: input.name,
|
||||
p_scopes: input.scopes,
|
||||
p_token_hash: createHash("sha256").update(token).digest("hex"),
|
||||
p_prefix: token.slice(0, 17),
|
||||
p_expires_days: input.expiresDays,
|
||||
});
|
||||
if (
|
||||
result.error?.code === "22023" &&
|
||||
result.error.message === "Maximum 5 active API keys"
|
||||
)
|
||||
return developerError(
|
||||
"key_limit",
|
||||
"You can have at most five active API keys. Revoke an unused key first.",
|
||||
409,
|
||||
);
|
||||
const key = required(result);
|
||||
return developerJson({ key, token }, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
"Check the key name, permissions and expiry.",
|
||||
400,
|
||||
);
|
||||
if (error instanceof ServiceError)
|
||||
return developerError(
|
||||
error.status === 401 ? "sign_in_required" : "invalid_request",
|
||||
error.message,
|
||||
error.status,
|
||||
);
|
||||
return developerError(
|
||||
"service_unavailable",
|
||||
"API key management is temporarily unavailable.",
|
||||
503,
|
||||
);
|
||||
}
|
||||
}
|
||||
192
lib/developer-response.ts
Normal file
192
lib/developer-response.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
developerScopes,
|
||||
developerPagination,
|
||||
type DeveloperPagination,
|
||||
} from "../shared/developer-api";
|
||||
|
||||
const quota = z.object({
|
||||
limit: z.literal(60),
|
||||
remaining: z.number().int().min(0).max(60),
|
||||
ownerLimit: z.literal(180),
|
||||
ownerRemaining: z.number().int().min(0).max(180),
|
||||
resetAt: z.number().int().positive(),
|
||||
});
|
||||
const authorization = z.discriminatedUnion("ok", [
|
||||
quota.extend({
|
||||
ok: z.literal(true),
|
||||
keyId: z.string().uuid(),
|
||||
ownerId: z.string().uuid(),
|
||||
scopes: z.array(z.enum(developerScopes)),
|
||||
}),
|
||||
z.object({
|
||||
ok: z.literal(false),
|
||||
error: z.enum(["invalid_key", "rate_limited"]),
|
||||
limit: z.literal(60).optional(),
|
||||
remaining: z.number().optional(),
|
||||
ownerLimit: z.literal(180).optional(),
|
||||
ownerRemaining: z.number().optional(),
|
||||
resetAt: z.number().optional(),
|
||||
}),
|
||||
]);
|
||||
export type DeveloperData = {
|
||||
consume(hash: string): Promise<unknown>;
|
||||
maps(page: DeveloperPagination, owner?: string): Promise<unknown>;
|
||||
map(id: string): Promise<unknown | null>;
|
||||
account(owner: string): Promise<unknown | null>;
|
||||
games(owner: string, page: DeveloperPagination): Promise<unknown>;
|
||||
};
|
||||
export function developerJson(
|
||||
body: unknown,
|
||||
status = 200,
|
||||
extra: Record<string, string> = {},
|
||||
) {
|
||||
return Response.json(body, {
|
||||
status,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
export function developerError(
|
||||
code: string,
|
||||
message: string,
|
||||
status: number,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return developerJson({ error: { code, message } }, status, headers);
|
||||
}
|
||||
export async function developerResponse(
|
||||
request: Request,
|
||||
path: string[],
|
||||
data: DeveloperData,
|
||||
) {
|
||||
let headers: Record<string, string> = {};
|
||||
if (request.method !== "GET")
|
||||
return developerError(
|
||||
"method_not_allowed",
|
||||
"This API version supports GET requests only.",
|
||||
405,
|
||||
{ Allow: "GET" },
|
||||
);
|
||||
const match = /^Bearer (mg_dev_[a-f0-9]{64})$/.exec(
|
||||
request.headers.get("authorization") ?? "",
|
||||
);
|
||||
if (!match)
|
||||
return developerError(
|
||||
"invalid_key",
|
||||
"A valid developer Bearer key is required.",
|
||||
401,
|
||||
{ "WWW-Authenticate": "Bearer" },
|
||||
);
|
||||
try {
|
||||
const auth = authorization.parse(
|
||||
await data.consume(createHash("sha256").update(match[1]).digest("hex")),
|
||||
);
|
||||
if (!auth.ok && auth.error === "invalid_key")
|
||||
return developerError(
|
||||
"invalid_key",
|
||||
"The developer key is invalid, expired, or revoked.",
|
||||
401,
|
||||
{ "WWW-Authenticate": "Bearer" },
|
||||
);
|
||||
const validatedQuota = quota.parse(auth);
|
||||
const reset = Math.max(
|
||||
1,
|
||||
validatedQuota.resetAt - Math.floor(Date.now() / 1000),
|
||||
);
|
||||
headers = {
|
||||
"RateLimit-Limit": "60",
|
||||
"RateLimit-Remaining": String(validatedQuota.remaining),
|
||||
"RateLimit-Reset": String(reset),
|
||||
"X-RateLimit-Reset": String(validatedQuota.resetAt),
|
||||
"X-RateLimit-Owner-Limit": "180",
|
||||
"X-RateLimit-Owner-Remaining": String(validatedQuota.ownerRemaining),
|
||||
};
|
||||
if (!auth.ok)
|
||||
return developerError(
|
||||
"rate_limited",
|
||||
"Request limit reached. Retry after the indicated delay.",
|
||||
429,
|
||||
{ ...headers, "Retry-After": String(reset) },
|
||||
);
|
||||
const joined = path.join("/");
|
||||
const scope =
|
||||
path[0] === "maps"
|
||||
? "maps:read"
|
||||
: joined === "me"
|
||||
? "account:read"
|
||||
: joined === "me/maps"
|
||||
? "account:maps:read"
|
||||
: joined === "me/games"
|
||||
? "account:games:read"
|
||||
: null;
|
||||
if (!scope)
|
||||
return developerError("not_found", "Endpoint not found.", 404, headers);
|
||||
if (!auth.scopes.includes(scope))
|
||||
return developerError(
|
||||
"insufficient_scope",
|
||||
`This key requires the ${scope} permission.`,
|
||||
403,
|
||||
headers,
|
||||
);
|
||||
const url = new URL(request.url);
|
||||
let page: DeveloperPagination;
|
||||
try {
|
||||
page = developerPagination(
|
||||
url,
|
||||
joined === "maps" || joined === "me/maps",
|
||||
);
|
||||
} catch (error) {
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
(error as Error).message,
|
||||
400,
|
||||
headers,
|
||||
);
|
||||
}
|
||||
if (joined === "maps")
|
||||
return developerJson(await data.maps(page), 200, headers);
|
||||
if (path[0] === "maps" && path.length === 2) {
|
||||
if (url.search || !z.string().uuid().safeParse(path[1]).success)
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
"A valid map UUID and no query parameters are required.",
|
||||
400,
|
||||
headers,
|
||||
);
|
||||
const map = await data.map(path[1]);
|
||||
return map
|
||||
? developerJson({ map }, 200, headers)
|
||||
: developerError("not_found", "Published map not found.", 404, headers);
|
||||
}
|
||||
if (joined === "me") {
|
||||
if (url.search)
|
||||
return developerError(
|
||||
"invalid_request",
|
||||
"This endpoint has no query parameters.",
|
||||
400,
|
||||
headers,
|
||||
);
|
||||
const account = await data.account(auth.ownerId);
|
||||
return account
|
||||
? developerJson({ account }, 200, headers)
|
||||
: developerError("not_found", "Account not found.", 404, headers);
|
||||
}
|
||||
if (joined === "me/maps")
|
||||
return developerJson(await data.maps(page, auth.ownerId), 200, headers);
|
||||
if (joined === "me/games")
|
||||
return developerJson(await data.games(auth.ownerId, page), 200, headers);
|
||||
return developerError("not_found", "Endpoint not found.", 404, headers);
|
||||
} catch {
|
||||
return developerError(
|
||||
"service_unavailable",
|
||||
"The developer API is temporarily unavailable.",
|
||||
503,
|
||||
headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
16
lib/forum.ts
Normal file
16
lib/forum.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import "server-only";
|
||||
import forum from "@/shared/forum.json";
|
||||
|
||||
/** Only expose a public HTTPS destination, never embedded credentials. */
|
||||
export function forumUrl() {
|
||||
const value = process.env.MINEGEO_FORUM_URL ?? forum.url;
|
||||
if (!value.trim()) return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port)
|
||||
return null;
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
38
lib/prometheus-api.ts
Normal file
38
lib/prometheus-api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { admin, required } from "./supabase";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { prometheusAction } from "@/shared/prometheus";
|
||||
|
||||
export async function prometheusApi(
|
||||
request: NextRequest,
|
||||
reviewer: string,
|
||||
jsonBody: (request: NextRequest) => Promise<unknown>,
|
||||
) {
|
||||
const client = admin();
|
||||
const endpoint = `${requestOrigin(request, process.env.MINEGEO_URL)}/api/metrics`;
|
||||
if (request.method === "GET") {
|
||||
const status = required(
|
||||
await client.rpc("prometheus_status", { p_reviewer: reviewer }),
|
||||
);
|
||||
return NextResponse.json({ ...(status as object), endpoint });
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
const { action } = prometheusAction.parse(await jsonBody(request));
|
||||
const token =
|
||||
action === "rotate"
|
||||
? `mg_metrics_${randomBytes(32).toString("hex")}`
|
||||
: null;
|
||||
const status = required(
|
||||
await client.rpc("configure_prometheus", {
|
||||
p_reviewer: reviewer,
|
||||
p_token_hash: token
|
||||
? createHash("sha256").update(token).digest("hex")
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json({ ...(status as object), endpoint, token });
|
||||
}
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
48
lib/prometheus-response.ts
Normal file
48
lib/prometheus-response.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { formatMetrics } from "@/shared/prometheus";
|
||||
|
||||
export async function metricsResponse(
|
||||
request: Pick<Request, "headers">,
|
||||
read: (tokenHash: string) => Promise<unknown>,
|
||||
) {
|
||||
const headers = {
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
const match = /^Bearer (mg_metrics_[a-f0-9]{64})$/.exec(
|
||||
request.headers.get("authorization") ?? "",
|
||||
);
|
||||
if (!match)
|
||||
return new Response("Metrics authentication required\n", {
|
||||
status: 401,
|
||||
headers: { ...headers, "WWW-Authenticate": "Bearer" },
|
||||
});
|
||||
try {
|
||||
const start = performance.now();
|
||||
const snapshot = await read(
|
||||
createHash("sha256").update(match[1]).digest("hex"),
|
||||
);
|
||||
if (snapshot === null)
|
||||
return new Response("Metrics authentication required\n", {
|
||||
status: 401,
|
||||
headers: { ...headers, "WWW-Authenticate": "Bearer" },
|
||||
});
|
||||
const body =
|
||||
formatMetrics(snapshot) +
|
||||
"# HELP minegeo_exporter_read_duration_seconds Time to read and validate the database snapshot.\n" +
|
||||
"# TYPE minegeo_exporter_read_duration_seconds gauge\n" +
|
||||
`minegeo_exporter_read_duration_seconds ${(performance.now() - start) / 1000}\n`;
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Never return database errors, credentials, or a misleading all-zero snapshot.
|
||||
return new Response("Metrics temporarily unavailable\n", {
|
||||
status: 503,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
6
proxy.ts
6
proxy.ts
@@ -11,6 +11,12 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.redirect(destination);
|
||||
}
|
||||
let response = NextResponse.next({ request });
|
||||
// Developer requests authenticate with their own opaque key, never browser cookies.
|
||||
if (
|
||||
request.nextUrl.pathname.startsWith("/api/dev/v1/") ||
|
||||
request.nextUrl.pathname === "/api/dev/openapi.json"
|
||||
)
|
||||
return response;
|
||||
if (!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) return response;
|
||||
const client = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
|
||||
127
scripts/test-developer-keys-sql.mjs
Normal file
127
scripts/test-developer-keys-sql.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
|
||||
const db = new PGlite();
|
||||
const owner = "00000000-0000-4000-8000-000000000001";
|
||||
const other = "00000000-0000-4000-8000-000000000002";
|
||||
const deleted = "00000000-0000-4000-8000-000000000003";
|
||||
const hash = (i) => i.toString(16).padStart(64, "0");
|
||||
const rpc = async (sql, args = []) => (await db.query(sql, args)).rows[0].value;
|
||||
const create = (i, account = owner, scopes = ["maps:read"], days = 30) =>
|
||||
rpc("select public.developer_key_create($1,$2,$3,$4,$5,$6) value", [
|
||||
account, `Key ${i}`, scopes, hash(i), `mgdev_${i.toString().padStart(8, "0")}`, days,
|
||||
]);
|
||||
const consume = (i) => rpc("select public.developer_key_consume($1) value", [hash(i)]);
|
||||
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
|
||||
create schema auth; create table auth.users(id uuid primary key);
|
||||
create function auth.uid() returns uuid language sql as $$select null::uuid$$;
|
||||
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
|
||||
for (const file of (await readdir("supabase/migrations")).filter((f) => f.endsWith(".sql")).sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
await db.exec("grant select,insert,update,delete on all tables in schema public to service_role;");
|
||||
await db.query("insert into auth.users values($1),($2),($3)", [owner, other, deleted]);
|
||||
await db.query("insert into public.profiles(id,role) values($1,'contributor'),($2,'admin'),($3,'contributor')", [owner, other, deleted]);
|
||||
|
||||
// Browser Supabase sessions cannot call RPCs or read hashes/quotas directly.
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for (const sql of [
|
||||
"select token_hash from public.developer_api_keys",
|
||||
"select * from public.developer_api_owner_limits",
|
||||
`select public.developer_keys_list('${owner}')`,
|
||||
`select public.developer_key_create('${owner}','forged',array['maps:read'],'${hash(1)}','mgdev_test1234',30)`,
|
||||
`select public.developer_key_revoke('${owner}','${other}')`,
|
||||
`select public.developer_key_consume('${hash(1)}')`,
|
||||
]) await assert.rejects(db.query(sql), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
await db.exec("set role service_role");
|
||||
assert.deepEqual(await rpc("select public.developer_keys_list($1) value", [owner]), []);
|
||||
for (const scopes of [[], ["admin"], ["maps:read", null], ["account:write"], ["maps:read", "maps:read", "maps:read", "maps:read", "maps:read"]])
|
||||
await assert.rejects(create(1, owner, scopes), /Invalid API key scopes/);
|
||||
for (const days of [0, 366, null]) await assert.rejects(create(1, owner, ["maps:read"], days), /Invalid API key settings/);
|
||||
await assert.rejects(create(1, null), /Account required/);
|
||||
const first = await create(1, owner, ["maps:read", "account:read", "maps:read"]);
|
||||
assert.deepEqual(first.scopes, ["account:read", "maps:read"]);
|
||||
assert.equal(first.name, "Key 1");
|
||||
assert.ok(!JSON.stringify(first).includes(hash(1)));
|
||||
for (let i = 2; i <= 5; i++) await create(i);
|
||||
await assert.rejects(create(6), (error) => error.code === "22023" && error.message === "Maximum 5 active API keys");
|
||||
const otherKey = await create(10, other);
|
||||
assert.equal((await rpc("select public.developer_keys_list($1) value", [owner])).length, 5);
|
||||
assert.equal((await rpc("select public.developer_keys_list($1) value", [other])).length, 1);
|
||||
assert.equal(await rpc("select public.developer_key_revoke($1,$2) value", [other, first.id]), false);
|
||||
assert.equal((await consume(1)).ok, true);
|
||||
assert.equal(await rpc("select public.developer_key_revoke($1,$2) value", [owner, first.id]), true);
|
||||
assert.deepEqual(await consume(1), {ok: false, error: "invalid_key"});
|
||||
await create(6);
|
||||
await db.query("update public.developer_api_keys set expires_at=clock_timestamp()-interval '1 second' where token_hash=$1", [hash(6)]);
|
||||
assert.deepEqual(await consume(6), {ok: false, error: "invalid_key"});
|
||||
await create(7);
|
||||
for (const token of [null, "not-a-hash", hash(999)])
|
||||
assert.deepEqual(await rpc("select public.developer_key_consume($1) value", [token]), {ok:false,error:"invalid_key"});
|
||||
|
||||
// Reset fixtures to the current minute before checking precise quota boundaries.
|
||||
await db.exec("update public.developer_api_keys set rate_window=null,rate_count=0; delete from public.developer_api_owner_limits");
|
||||
for (let n = 1; n <= 60; n++) {
|
||||
const result = await consume(2);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.remaining, 60 - n);
|
||||
}
|
||||
const limited = await consume(2);
|
||||
assert.equal(limited.error, "rate_limited");
|
||||
assert.equal(limited.remaining, 0);
|
||||
assert.ok(limited.resetAt > Date.now() / 1000);
|
||||
for (const i of [3, 4]) for (let n = 0; n < 60; n++) assert.equal((await consume(i)).ok, true);
|
||||
const ownerLimited = await consume(5);
|
||||
assert.equal(ownerLimited.error, "rate_limited");
|
||||
assert.equal(ownerLimited.ownerRemaining, 0);
|
||||
assert.equal((await consume(10)).ok, true, "Another owner has an independent quota");
|
||||
const stored = (await db.query("select rate_count from public.developer_api_keys where token_hash=$1", [hash(5)])).rows[0];
|
||||
assert.equal(stored.rate_count, 0, "Rejected requests do not consume quota");
|
||||
await db.exec("update public.developer_api_keys set rate_window=date_trunc('minute',clock_timestamp())-interval '1 minute'; update public.developer_api_owner_limits set rate_window=date_trunc('minute',clock_timestamp())-interval '1 minute'");
|
||||
const nextWindow = await consume(2);
|
||||
assert.equal(nextWindow.ok, true);
|
||||
assert.equal(nextWindow.remaining, 59);
|
||||
assert.equal(nextWindow.ownerRemaining, 179);
|
||||
assert.ok((await rpc("select public.developer_keys_list($1) value", [owner])).find((k) => k.id === first.id).revokedAt);
|
||||
assert.ok((await rpc("select public.developer_keys_list($1) value", [owner])).find((k) => k.name === "Key 2").lastUsedAt);
|
||||
|
||||
// History stays bounded without losing an older but still active key.
|
||||
await db.query("update public.developer_api_keys set created_at=clock_timestamp()-interval '2 years' where token_hash=$1", [hash(2)]);
|
||||
await db.query(`insert into public.developer_api_keys(owner_id,name,prefix,token_hash,scopes,created_at,expires_at,revoked_at)
|
||||
select $1,'History '||n,'mgdev_history',lpad(to_hex(n),64,'0'),array['maps:read'],
|
||||
clock_timestamp()-make_interval(secs=>n),clock_timestamp()+interval '1 day',clock_timestamp()
|
||||
from generate_series(1000,1119) n`, [owner]);
|
||||
const bounded = await rpc("select public.developer_keys_list($1) value", [owner]);
|
||||
assert.equal(bounded.length, 100);
|
||||
assert.deepEqual(new Set(bounded.slice(0,5).map((k) => k.name)), new Set(["Key 2","Key 3","Key 4","Key 5","Key 7"]));
|
||||
assert.equal(bounded[4].name, "Key 2");
|
||||
assert.ok(bounded.slice(5).every((k) => k.revokedAt || Date.parse(k.expiresAt) <= Date.now()));
|
||||
assert.ok(bounded.every((k) => !Object.hasOwn(k,"token_hash") && !Object.hasOwn(k,"tokenHash") && !Object.hasOwn(k,"rate_count")));
|
||||
|
||||
// PGlite queues these invocations on one connection. This checks burst
|
||||
// boundaries; true multi-connection locking is provided by FOR UPDATE in SQL.
|
||||
const creationBurst = await Promise.allSettled(Array.from({length:6},(_,n) => create(20+n,deleted)));
|
||||
assert.equal(creationBurst.filter((result) => result.status === "fulfilled").length, 5);
|
||||
assert.equal(creationBurst.filter((result) => result.status === "rejected" && result.reason.code === "22023").length, 1);
|
||||
const quotaBurst = await Promise.all(Array.from({length:70},() => consume(20)));
|
||||
assert.equal(quotaBurst.filter((result) => result.ok).length, 60);
|
||||
assert.equal(quotaBurst.filter((result) => result.error === "rate_limited").length, 10);
|
||||
await db.exec("reset role");
|
||||
await db.query("delete from auth.users where id=$1", [deleted]);
|
||||
await db.exec("set role service_role");
|
||||
assert.deepEqual(await consume(20), {ok:false,error:"invalid_key"});
|
||||
await assert.rejects(create(21, deleted), /Account required/);
|
||||
await db.exec("reset role");
|
||||
await db.query("delete from public.profiles where id=$1", [other]);
|
||||
assert.equal((await db.query("select count(*) n from public.developer_api_keys where id=$1", [otherKey.id])).rows[0].n, 0);
|
||||
await db.exec("set role service_role");
|
||||
assert.deepEqual(await consume(10), {ok:false,error:"invalid_key"});
|
||||
console.log("Developer API SQL checks passed: restricted grants, validation, scope canonicalization, owner isolation, five-key cap, expiry, revocation, deleted accounts, per-key 60/min, shared-owner 180/min, rollover, bounded active-first metadata, queued burst boundaries.");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
196
scripts/test-prometheus-sql.mjs
Normal file
196
scripts/test-prometheus-sql.mjs
Normal file
@@ -0,0 +1,196 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
const db = new PGlite();
|
||||
const owner = "00000000-0000-4000-8000-000000000001",
|
||||
moderator = "00000000-0000-4000-8000-000000000002",
|
||||
player = "00000000-0000-4000-8000-000000000003";
|
||||
const oldHash = "a".repeat(64),
|
||||
newHash = "b".repeat(64);
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
|
||||
create schema auth; create table auth.users(id uuid primary key);
|
||||
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
|
||||
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
|
||||
for (const file of (await readdir("supabase/migrations"))
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
// Supabase's default service_role table grants are not installed in PGlite.
|
||||
await db.exec(
|
||||
"grant select,insert,update,delete on all tables in schema public to service_role;",
|
||||
);
|
||||
await db.query("insert into auth.users values($1),($2),($3)", [
|
||||
owner,
|
||||
moderator,
|
||||
player,
|
||||
]);
|
||||
await db.query(
|
||||
"insert into public.profiles(id,role) values($1,'admin'),($2,'moderator'),($3,'contributor')",
|
||||
[owner, moderator, player],
|
||||
);
|
||||
const dataset = (
|
||||
await db.query(
|
||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||
values($1,'Private map title','plugin','{}','published',7) returning id`,
|
||||
[owner],
|
||||
)
|
||||
).rows[0].id;
|
||||
const revision = (
|
||||
await db.query(
|
||||
`insert into public.revisions(dataset_id,manifest,asset_root)
|
||||
values($1,'{}','private-root') returning id`,
|
||||
[dataset],
|
||||
)
|
||||
).rows[0].id;
|
||||
await db.query(
|
||||
`insert into public.game_sessions(revision_id,access_hash,difficulty,current_round,created_at)
|
||||
values($1,'test','easy',5,now()),($1,'test','hard',0,now()-interval '2 days')`,
|
||||
[revision],
|
||||
);
|
||||
const rpc = async (sql, args = []) =>
|
||||
(await db.query(sql, args)).rows[0].value;
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for (const sql of [
|
||||
"select token_hash from public.prometheus_settings",
|
||||
`select public.prometheus_status('${owner}')`,
|
||||
`select public.configure_prometheus('${owner}','${oldHash}')`,
|
||||
`select public.read_prometheus_metrics('${oldHash}')`,
|
||||
])
|
||||
await assert.rejects(db.query(sql), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
await db.exec("set role service_role");
|
||||
for (const actor of [moderator, player, null]) {
|
||||
await assert.rejects(
|
||||
db.query("select public.prometheus_status($1)", [actor]),
|
||||
/Admin permission required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.configure_prometheus($1,$2)", [actor, oldHash]),
|
||||
/Admin permission required/,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(await rpc("select public.prometheus_status($1) value", [owner])).enabled,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
null,
|
||||
);
|
||||
await rpc("select public.configure_prometheus($1,$2) value", [
|
||||
owner,
|
||||
oldHash,
|
||||
]);
|
||||
const snapshot = await rpc(
|
||||
"select public.read_prometheus_metrics($1) value",
|
||||
[oldHash],
|
||||
);
|
||||
assert.equal(snapshot.length, 43);
|
||||
assert.equal(snapshot.find((s) => s.metric === "minegeo_accounts").value, 3);
|
||||
assert.equal(
|
||||
snapshot.find(
|
||||
(s) => s.metric === "minegeo_maps" && s.labels.state === "published",
|
||||
).value,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find((s) => s.metric === "minegeo_published_panoramas").value,
|
||||
7,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find((s) => s.metric === "minegeo_games_started_24h").value,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find(
|
||||
(s) =>
|
||||
s.metric === "minegeo_games" &&
|
||||
s.labels.difficulty === "easy" &&
|
||||
s.labels.status === "complete",
|
||||
).value,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.find(
|
||||
(s) =>
|
||||
s.metric === "minegeo_games" &&
|
||||
s.labels.difficulty === "hard" &&
|
||||
s.labels.status === "incomplete",
|
||||
).value,
|
||||
1,
|
||||
);
|
||||
assert.ok(!JSON.stringify(snapshot).includes("Private map title"));
|
||||
assert.ok(
|
||||
snapshot.every(
|
||||
(s) => Object.keys(s).sort().join(",") === "labels,metric,value",
|
||||
),
|
||||
);
|
||||
assert.ok(!JSON.stringify(snapshot).includes(owner));
|
||||
assert.ok(
|
||||
(await rpc("select public.prometheus_status($1) value", [owner]))
|
||||
.lastScrapedAt,
|
||||
);
|
||||
// Cache is shared by scrapes rather than tied to a serverless process.
|
||||
await db.query(
|
||||
"update public.profiles set display_name='Private name' where id=$1",
|
||||
[player],
|
||||
);
|
||||
assert.deepEqual(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
snapshot,
|
||||
);
|
||||
await db.query("update public.datasets set state='unpublished' where id=$1", [
|
||||
dataset,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
snapshot,
|
||||
);
|
||||
await db.exec(
|
||||
"update public.prometheus_settings set snapshot_at=now()-interval '1 minute'",
|
||||
);
|
||||
const refreshed = await rpc(
|
||||
"select public.read_prometheus_metrics($1) value",
|
||||
[oldHash],
|
||||
);
|
||||
assert.equal(
|
||||
refreshed.find(
|
||||
(s) => s.metric === "minegeo_maps" && s.labels.state === "published",
|
||||
).value,
|
||||
0,
|
||||
);
|
||||
await rpc("select public.configure_prometheus($1,$2) value", [
|
||||
owner,
|
||||
newHash,
|
||||
]);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [oldHash]),
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [newHash]),
|
||||
);
|
||||
await db.query("update public.profiles set role='contributor' where id=$1", [
|
||||
owner,
|
||||
]);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [newHash]),
|
||||
null,
|
||||
);
|
||||
await db.query("update public.profiles set role='admin' where id=$1", [
|
||||
owner,
|
||||
]);
|
||||
await rpc("select public.configure_prometheus($1,null) value", [owner]);
|
||||
assert.equal(
|
||||
await rpc("select public.read_prometheus_metrics($1) value", [newHash]),
|
||||
null,
|
||||
);
|
||||
console.log(
|
||||
"Prometheus SQL passed: Admin-only setup, private table/RPCs, disabled default, bounded aggregates, cache, rotation, disable, and issuer demotion.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
@@ -702,6 +702,35 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
developer_keys_list: { Args: { p_owner: string }; Returns: Json };
|
||||
developer_key_create: {
|
||||
Args: {
|
||||
p_owner: string;
|
||||
p_name: string;
|
||||
p_scopes: string[];
|
||||
p_token_hash: string;
|
||||
p_prefix: string;
|
||||
p_expires_days: number;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
developer_key_revoke: {
|
||||
Args: { p_owner: string; p_key: string };
|
||||
Returns: boolean;
|
||||
};
|
||||
developer_key_consume: { Args: { p_token_hash: string }; Returns: Json };
|
||||
prometheus_status: {
|
||||
Args: { p_reviewer: string };
|
||||
Returns: Json;
|
||||
};
|
||||
configure_prometheus: {
|
||||
Args: { p_reviewer: string; p_token_hash: string | null };
|
||||
Returns: Json;
|
||||
};
|
||||
read_prometheus_metrics: {
|
||||
Args: { p_token_hash: string };
|
||||
Returns: Json;
|
||||
};
|
||||
receive_bug_close: {
|
||||
Args: { p_report: string; p_external: string };
|
||||
Returns: boolean;
|
||||
|
||||
71
shared/developer-api.ts
Normal file
71
shared/developer-api.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const developerScopes = [
|
||||
"maps:read",
|
||||
"account:read",
|
||||
"account:maps:read",
|
||||
"account:games:read",
|
||||
] as const;
|
||||
export type DeveloperScope = (typeof developerScopes)[number];
|
||||
export type DeveloperKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: DeveloperScope[];
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
export const developerKeyInput = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(60),
|
||||
scopes: z
|
||||
.array(z.enum(developerScopes))
|
||||
.min(1)
|
||||
.max(4)
|
||||
.refine(
|
||||
(values) => new Set(values).size === values.length,
|
||||
"Duplicate permissions",
|
||||
),
|
||||
expiresDays: z.number().int().min(1).max(365).default(90),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export function developerPagination(url: URL, search = false) {
|
||||
const allowed = new Set(
|
||||
search ? ["limit", "offset", "q"] : ["limit", "offset"],
|
||||
);
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1)
|
||||
throw new Error("Unsupported or duplicate query parameter");
|
||||
}
|
||||
const integer = (key: string, fallback: number, min: number, max: number) => {
|
||||
const raw = url.searchParams.get(key);
|
||||
if (raw === null) return fallback;
|
||||
if (!/^\d+$/.test(raw)) throw new Error(`Invalid ${key}`);
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < min || value > max)
|
||||
throw new Error(`Invalid ${key}`);
|
||||
return value;
|
||||
};
|
||||
const q = (url.searchParams.get("q") ?? "").trim();
|
||||
if (q.length > 80) throw new Error("Search must be 80 characters or fewer");
|
||||
return {
|
||||
limit: integer("limit", 20, 1, 50),
|
||||
offset: integer("offset", 0, 0, 10000),
|
||||
q,
|
||||
};
|
||||
}
|
||||
export type DeveloperPagination = ReturnType<typeof developerPagination>;
|
||||
export function developerPage(total: number, page: DeveloperPagination) {
|
||||
return {
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
total,
|
||||
nextOffset:
|
||||
page.offset + page.limit < total && page.offset + page.limit <= 10000
|
||||
? page.offset + page.limit
|
||||
: null,
|
||||
};
|
||||
}
|
||||
86
shared/developer-docs.ts
Normal file
86
shared/developer-docs.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export const developerScopes = [
|
||||
{
|
||||
id: "maps:read",
|
||||
name: ["Public maps", "Общие карты"],
|
||||
description: [
|
||||
"Read the published map catalog and map details.",
|
||||
"Читать каталог опубликованных карт и сведения о картах.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account:read",
|
||||
name: ["My profile", "Мой профиль"],
|
||||
description: [
|
||||
"Read your account profile and username appearance.",
|
||||
"Читать профиль вашего аккаунта и оформление имени.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account:maps:read",
|
||||
name: ["My maps, including private maps", "Мои карты, включая личные"],
|
||||
description: [
|
||||
"Read your own map records, including unpublished maps.",
|
||||
"Читать ваши карты, в том числе неопубликованные.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "account:games:read",
|
||||
name: ["My game history", "Моя история игр"],
|
||||
description: ["Read your game history.", "Читать вашу историю игр."],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const developerEndpoints = [
|
||||
{
|
||||
path: "/maps",
|
||||
scope: "maps:read",
|
||||
description: [
|
||||
"Published maps. Supports q, limit and offset.",
|
||||
"Опубликованные карты. Поддерживает q, limit и offset.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/maps/{id}",
|
||||
scope: "maps:read",
|
||||
description: [
|
||||
"Details of one published map.",
|
||||
"Сведения об одной опубликованной карте.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/me",
|
||||
scope: "account:read",
|
||||
description: [
|
||||
"The key owner's profile and username appearance.",
|
||||
"Профиль и оформление имени владельца ключа.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/me/maps",
|
||||
scope: "account:maps:read",
|
||||
description: [
|
||||
"Your own maps, including private maps. Supports limit and offset.",
|
||||
"Ваши карты, включая личные. Поддерживает limit и offset.",
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/me/games",
|
||||
scope: "account:games:read",
|
||||
description: [
|
||||
"Your game history. Supports limit and offset.",
|
||||
"Ваша история игр. Поддерживает limit и offset.",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const developerPythonExample = `import os
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
"https://minegeo.vercel.app/api/dev/v1/maps",
|
||||
headers={"Authorization": f"Bearer {os.environ['MINEGEO_API_KEY']}"},
|
||||
params={"limit": 20, "offset": 0},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(response.json())`;
|
||||
208
shared/developer-openapi.ts
Normal file
208
shared/developer-openapi.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
const string = { type: "string" };
|
||||
const date = { type: "string", format: "date-time" };
|
||||
const id = { type: "string", format: "uuid" };
|
||||
const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` });
|
||||
const pagination = [
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
schema: { type: "integer", minimum: 1, maximum: 50, default: 20 },
|
||||
},
|
||||
{
|
||||
name: "offset",
|
||||
in: "query",
|
||||
schema: { type: "integer", minimum: 0, maximum: 10000, default: 0 },
|
||||
},
|
||||
];
|
||||
const success = (schema: unknown) => ({
|
||||
description: "Success",
|
||||
content: { "application/json": { schema } },
|
||||
});
|
||||
const errors = Object.fromEntries(
|
||||
[
|
||||
[400, "Invalid query or map ID"],
|
||||
[401, "Missing, invalid, expired or revoked key"],
|
||||
[403, "Required permission missing"],
|
||||
[404, "Resource or endpoint not found"],
|
||||
[429, "Quota exceeded; wait Retry-After seconds"],
|
||||
[503, "Temporarily unavailable"],
|
||||
].map(([status, description]) => [
|
||||
status,
|
||||
{ description, content: { "application/json": { schema: ref("Error") } } },
|
||||
]),
|
||||
);
|
||||
const list = (name: string, item: string) => ({
|
||||
type: "object",
|
||||
required: [name, "pagination"],
|
||||
properties: {
|
||||
[name]: { type: "array", items: ref(item) },
|
||||
pagination: ref("Pagination"),
|
||||
},
|
||||
});
|
||||
const operation = (
|
||||
summary: string,
|
||||
scope: string,
|
||||
schema: unknown,
|
||||
parameters: unknown[] = [],
|
||||
) => ({
|
||||
summary,
|
||||
description: `Requires the ${scope} key permission.`,
|
||||
"x-required-scope": scope,
|
||||
security: [{ DeveloperKey: [] }],
|
||||
parameters,
|
||||
responses: { 200: success(schema), ...errors },
|
||||
});
|
||||
export const developerOpenApi = {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
title: "MineGeo Developer API",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Read-only HTTPS API. Bearer keys are managed at /dev/api. 60 requests per key per fixed minute; 180 requests per account per minute shared across keys. All authenticated requests, including validation/permission errors, consume quota. A 429 response includes Retry-After; successful authorized responses include RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until reset). X-RateLimit-Reset is a Unix timestamp. Requests with invalid keys cannot access account data. No upload, publication, gameplay writes or moderator access is granted.",
|
||||
},
|
||||
servers: [{ url: "/api/dev/v1" }],
|
||||
paths: {
|
||||
"/maps": {
|
||||
get: operation("List published maps", "maps:read", list("maps", "Map"), [
|
||||
...pagination,
|
||||
{
|
||||
name: "q",
|
||||
in: "query",
|
||||
schema: { type: "string", maxLength: 80 },
|
||||
description: "Case-insensitive map-name search",
|
||||
},
|
||||
]),
|
||||
},
|
||||
"/maps/{id}": {
|
||||
get: operation(
|
||||
"Get a published map",
|
||||
"maps:read",
|
||||
{
|
||||
type: "object",
|
||||
required: ["map"],
|
||||
properties: { map: ref("Map") },
|
||||
},
|
||||
[{ name: "id", in: "path", required: true, schema: id }],
|
||||
),
|
||||
},
|
||||
"/me": {
|
||||
get: operation("Get the key owner's profile", "account:read", {
|
||||
type: "object",
|
||||
required: ["account"],
|
||||
properties: { account: ref("Account") },
|
||||
}),
|
||||
},
|
||||
"/me/maps": {
|
||||
get: operation(
|
||||
"List the owner's maps, including private maps",
|
||||
"account:maps:read",
|
||||
list("maps", "Map"),
|
||||
[
|
||||
...pagination,
|
||||
{ name: "q", in: "query", schema: { type: "string", maxLength: 80 } },
|
||||
],
|
||||
),
|
||||
},
|
||||
"/me/games": {
|
||||
get: operation(
|
||||
"List the owner's solo-game history",
|
||||
"account:games:read",
|
||||
list("games", "Game"),
|
||||
pagination,
|
||||
),
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
DeveloperKey: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "mg_dev_<64 hex characters>",
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
required: ["error"],
|
||||
properties: {
|
||||
error: {
|
||||
type: "object",
|
||||
required: ["code", "message"],
|
||||
properties: { code: string, message: string },
|
||||
},
|
||||
},
|
||||
},
|
||||
Pagination: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: { type: "integer" },
|
||||
offset: { type: "integer" },
|
||||
total: { type: "integer" },
|
||||
nextOffset: { type: ["integer", "null"] },
|
||||
},
|
||||
},
|
||||
Map: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id,
|
||||
name: string,
|
||||
description: string,
|
||||
source: string,
|
||||
state: string,
|
||||
bounds: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minX: { type: "number" },
|
||||
minZ: { type: "number" },
|
||||
maxX: { type: "number" },
|
||||
maxZ: { type: "number" },
|
||||
},
|
||||
},
|
||||
panoramaCount: { type: "integer" },
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
url: {
|
||||
type: "string",
|
||||
description:
|
||||
"Relative MineGeo map page path; private maps still require their owner's browser login.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Account: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id,
|
||||
displayName: string,
|
||||
createdAt: date,
|
||||
appearance: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nameGlow: { type: "boolean" },
|
||||
glowColor: string,
|
||||
bugFinder: { type: "boolean" },
|
||||
customPrefix: { type: ["string", "null"] },
|
||||
customPrefixIcon: string,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Game: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id,
|
||||
difficulty: { type: "string", enum: ["easy", "normal", "hard"] },
|
||||
createdAt: date,
|
||||
currentRound: { type: "integer", minimum: 0, maximum: 5 },
|
||||
complete: { type: "boolean" },
|
||||
score: {
|
||||
type: ["integer", "null"],
|
||||
minimum: 0,
|
||||
maximum: 25000,
|
||||
description:
|
||||
"Final score only; null until a complete five-round result is available.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
3
shared/forum.json
Normal file
3
shared/forum.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"url": "https://minegeo.discourse.group/"
|
||||
}
|
||||
236
shared/grafana-dashboard.ts
Normal file
236
shared/grafana-dashboard.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// Grafana's classic import model prompts for the user's Prometheus data source.
|
||||
// This template contains query definitions only, never credentials or player data.
|
||||
const datasource = { type: "prometheus", uid: "${DS_PROMETHEUS}" };
|
||||
const filter = 'job="minegeo",instance=~"$instance"';
|
||||
const panels = [
|
||||
[
|
||||
"Scrape health",
|
||||
"stat",
|
||||
`min(up{${filter}})`,
|
||||
"short",
|
||||
"Shows whether Prometheus can scrape MineGeo. 1 = healthy, 0 = failed.",
|
||||
],
|
||||
[
|
||||
"Registered accounts",
|
||||
"stat",
|
||||
`sum(minegeo_accounts{${filter}})`,
|
||||
"short",
|
||||
"Currently retained profiles.",
|
||||
],
|
||||
[
|
||||
"Published maps",
|
||||
"stat",
|
||||
`sum(minegeo_maps{${filter},state="published"})`,
|
||||
"short",
|
||||
"Maps available in the public catalog.",
|
||||
],
|
||||
[
|
||||
"Published panoramas",
|
||||
"stat",
|
||||
`sum(minegeo_published_panoramas{${filter}})`,
|
||||
"short",
|
||||
"Playable panorama locations in published maps.",
|
||||
],
|
||||
[
|
||||
"Solo games started · last 24h",
|
||||
"stat",
|
||||
`sum(minegeo_games_started_24h{${filter}})`,
|
||||
"short",
|
||||
"Rolling 24-hour window, not a counter or page-view total.",
|
||||
],
|
||||
[
|
||||
"Parties created · last 24h",
|
||||
"stat",
|
||||
`sum(minegeo_rooms_created_24h{${filter}})`,
|
||||
"short",
|
||||
"Rolling 24-hour count of created multiplayer rooms.",
|
||||
],
|
||||
[
|
||||
"Map catalog over time",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_maps{${filter}})`,
|
||||
"short",
|
||||
"Current retained map inventory. Counts can decrease after deletion.",
|
||||
],
|
||||
[
|
||||
"Solo games by difficulty and completion",
|
||||
"timeseries",
|
||||
`sum by (difficulty,status) (minegeo_games{${filter}})`,
|
||||
"short",
|
||||
"Incomplete includes abandoned games. Complete means all five rounds ended.",
|
||||
],
|
||||
[
|
||||
"Multiplayer room lifecycle",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_rooms{${filter}})`,
|
||||
"short",
|
||||
"Stored room lifecycle states. These are not a count of online users.",
|
||||
],
|
||||
[
|
||||
"Upload processing",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_upload_jobs{${filter}})`,
|
||||
"short",
|
||||
"Queued, running, completed, failed and cancelled jobs.",
|
||||
],
|
||||
[
|
||||
"Player bug reports",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_bug_reports{${filter}})`,
|
||||
"short",
|
||||
"Retained reports. Closed reports are deleted after three days, so counts may fall.",
|
||||
],
|
||||
[
|
||||
"Content moderation reports",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_content_reports{${filter}})`,
|
||||
"short",
|
||||
"Open and resolved map-content reports.",
|
||||
],
|
||||
[
|
||||
"Discord delivery",
|
||||
"timeseries",
|
||||
`sum by (state) (minegeo_discord_announcements{${filter}})`,
|
||||
"short",
|
||||
"Retained announcement delivery states.",
|
||||
],
|
||||
[
|
||||
"Exporter read time",
|
||||
"timeseries",
|
||||
`max(minegeo_exporter_read_duration_seconds{${filter}})`,
|
||||
"s",
|
||||
"Time spent reading and validating a database snapshot; not site request latency.",
|
||||
],
|
||||
[
|
||||
"Snapshot age",
|
||||
"timeseries",
|
||||
`time() - min(minegeo_snapshot_timestamp_seconds{${filter}})`,
|
||||
"s",
|
||||
"Snapshot age grows when scraping stops. The database caches aggregates for 30 seconds.",
|
||||
],
|
||||
] as const;
|
||||
|
||||
export function grafanaDashboard() {
|
||||
return {
|
||||
__inputs: [
|
||||
{
|
||||
name: "DS_PROMETHEUS",
|
||||
label: "Prometheus",
|
||||
description: "Choose the Prometheus data source that scrapes MineGeo.",
|
||||
type: "datasource",
|
||||
pluginId: "prometheus",
|
||||
pluginName: "Prometheus",
|
||||
},
|
||||
],
|
||||
id: null,
|
||||
uid: "minegeo-community",
|
||||
title: "MineGeo · Community analytics",
|
||||
version: 1,
|
||||
schemaVersion: 39,
|
||||
tags: ["minegeo", "prometheus"],
|
||||
timezone: "browser",
|
||||
editable: true,
|
||||
graphTooltip: 1,
|
||||
refresh: "1m",
|
||||
time: { from: "now-24h", to: "now" },
|
||||
description:
|
||||
"Aggregate MineGeo product metrics. All MineGeo series are gauges; no personal data or per-process counters. History starts when Prometheus begins scraping.",
|
||||
templating: {
|
||||
list: [
|
||||
{
|
||||
name: "instance",
|
||||
label: "MineGeo instance",
|
||||
type: "query",
|
||||
datasource,
|
||||
query: {
|
||||
query: 'label_values(minegeo_accounts{job="minegeo"}, instance)',
|
||||
refId: "instance",
|
||||
},
|
||||
refresh: 1,
|
||||
multi: false,
|
||||
includeAll: false,
|
||||
sort: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
panels: panels.map(([title, type, expr, unit, description], index) => {
|
||||
const stat = index < 6;
|
||||
return {
|
||||
id: index + 1,
|
||||
title,
|
||||
type,
|
||||
description,
|
||||
datasource,
|
||||
gridPos: stat
|
||||
? { x: (index % 3) * 8, y: Math.floor(index / 3) * 4, w: 8, h: 4 }
|
||||
: {
|
||||
x: ((index - 6) % 2) * 12,
|
||||
y: 8 + Math.floor((index - 6) / 2) * 8,
|
||||
w: 12,
|
||||
h: 8,
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
refId: "A",
|
||||
expr,
|
||||
datasource,
|
||||
legendFormat: "{{state}} {{difficulty}} {{status}}",
|
||||
range: !stat,
|
||||
instant: stat,
|
||||
},
|
||||
],
|
||||
fieldConfig: {
|
||||
defaults: {
|
||||
unit,
|
||||
min: 0,
|
||||
decimals: unit === "short" ? 0 : 2,
|
||||
color: { mode: stat ? "thresholds" : "palette-classic" },
|
||||
thresholds: {
|
||||
mode: "absolute",
|
||||
steps:
|
||||
index === 0
|
||||
? [
|
||||
{ color: "red", value: null },
|
||||
{ color: "green", value: 1 },
|
||||
]
|
||||
: [{ color: "green", value: null }],
|
||||
},
|
||||
...(stat
|
||||
? {}
|
||||
: {
|
||||
custom: {
|
||||
drawStyle: "line",
|
||||
lineInterpolation: "smooth",
|
||||
lineWidth: 2,
|
||||
fillOpacity: 12,
|
||||
showPoints: "never",
|
||||
spanNulls: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
overrides: [],
|
||||
},
|
||||
options: stat
|
||||
? {
|
||||
colorMode: "value",
|
||||
graphMode: "area",
|
||||
justifyMode: "auto",
|
||||
textMode: "auto",
|
||||
reduceOptions: {
|
||||
calcs: ["lastNotNull"],
|
||||
fields: "",
|
||||
values: false,
|
||||
},
|
||||
}
|
||||
: {
|
||||
legend: {
|
||||
displayMode: "list",
|
||||
placement: "bottom",
|
||||
showLegend: true,
|
||||
},
|
||||
tooltip: { mode: "multi", sort: "desc" },
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Forum": "Форум",
|
||||
"Site language": "Язык сайта",
|
||||
"Automatic": "Автоматически",
|
||||
"MineGeo home": "Главная MineGeo",
|
||||
@@ -849,5 +850,39 @@
|
||||
"Download the portable app, run the EXE, and start exploring. No installation needed.": "Скачайте портативное приложение, запустите EXE и начните исследование. Установка не нужна.",
|
||||
"Windows 10/11 · 64-bit · Version {version} · Internet required": "Windows 10/11 · 64 бита · Версия {version} · Нужен интернет",
|
||||
"The app connects to minegeo.vercel.app and keeps you signed in on this device.": "Приложение подключается к minegeo.vercel.app и сохраняет вход на этом устройстве.",
|
||||
"This release is unsigned. Windows may show a publisher warning when you open it.": "Эта версия без цифровой подписи. При запуске Windows может показать предупреждение об издателе."
|
||||
"This release is unsigned. Windows may show a publisher warning when you open it.": "Эта версия без цифровой подписи. При запуске Windows может показать предупреждение об издателе.",
|
||||
"Prometheus & Grafana": "Prometheus и Grafana",
|
||||
"Track games, maps, multiplayer, uploads, and reports in your own Grafana dashboard. Admin permission is required to manage access.": "Следите за играми, картами, мультиплеером, загрузками и обращениями на своей панели Grafana. Для управления доступом нужны права администратора.",
|
||||
"Only aggregate counts are exported. Usernames, messages, emails, and map identifiers are excluded.": "Экспортируются только общие показатели. Имена пользователей, сообщения, электронные адреса и идентификаторы карт не передаются.",
|
||||
"Loading metrics setup…": "Загружаем настройки метрик…",
|
||||
"Metrics enabled": "Метрики включены",
|
||||
"Metrics disabled": "Метрики отключены",
|
||||
"Metrics endpoint": "Адрес метрик",
|
||||
"Copy endpoint": "Копировать адрес",
|
||||
"Last successful scrape: {time}": "Последний успешный сбор: {time}",
|
||||
"Not scraped yet": "Сбор ещё не выполнялся",
|
||||
"Use the hosted HTTPS site to generate a production scrape config.": "Откройте опубликованный сайт по HTTPS, чтобы создать конфигурацию сбора метрик.",
|
||||
"Generate a token below, then copy the config into prometheus.yml. If scrape_configs already exists, add only the MineGeo job beneath it.": "Создайте токен ниже и скопируйте конфигурацию в prometheus.yml. Если раздел scrape_configs уже есть, добавьте в него только задание MineGeo.",
|
||||
"Reload or restart Prometheus. Open its Targets page and check that the minegeo job is UP.": "Перезагрузите конфигурацию или перезапустите Prometheus. На странице Targets проверьте, что задание minegeo имеет статус UP.",
|
||||
"In Grafana, add your Prometheus server as a data source. Import the downloaded dashboard JSON and select that data source.": "В Grafana добавьте свой сервер Prometheus как источник данных. Импортируйте скачанный JSON панели и выберите этот источник.",
|
||||
"History starts with the first scrape. Counts are cached for 30 seconds; room states are not online presence. This integration does not export page views or Vercel CPU statistics.": "История начинается с первого сбора. Показатели кешируются на 30 секунд; состояния комнат не показывают число игроков онлайн. Просмотры страниц и статистика процессора Vercel не экспортируются.",
|
||||
"Rotate metrics token": "Заменить токен метрик",
|
||||
"Generate metrics token": "Создать токен метрик",
|
||||
"Disable metrics access": "Отключить доступ к метрикам",
|
||||
"Download Grafana dashboard": "Скачать панель Grafana",
|
||||
"Confirm metrics access change": "Подтвердить изменение доступа к метрикам",
|
||||
"Rotating immediately invalidates the old token. Update Prometheus with the new config afterward.": "Замена сразу отключит старый токен. После этого обновите конфигурацию Prometheus.",
|
||||
"Disabling immediately stops all authenticated scrapes. Prometheus will report this target as DOWN.": "Отключение сразу остановит сбор метрик. Prometheus покажет для этой цели статус DOWN.",
|
||||
"Confirm change": "Подтвердить изменение",
|
||||
"Keep this config private. The token is shown once and grants read-only access to aggregate metrics. Rotating or disabling it revokes access.": "Храните конфигурацию в тайне. Токен показывается один раз и разрешает только чтение общих метрик. Замена или отключение токена отзывает доступ.",
|
||||
"Prometheus scrape config": "Конфигурация сбора Prometheus",
|
||||
"Copy Prometheus config": "Копировать конфигурацию Prometheus",
|
||||
"Metrics token": "Токен метрик",
|
||||
"Hide generated credentials": "Скрыть созданные данные доступа",
|
||||
"The existing token cannot be retrieved. Use your saved config, or rotate the token to generate a new one.": "Существующий токен нельзя получить повторно. Используйте сохранённую конфигурацию или замените токен.",
|
||||
"New metrics token generated. Copy the config now; it cannot be recovered later.": "Создан новый токен метрик. Скопируйте конфигурацию сейчас: восстановить токен позже нельзя.",
|
||||
"Metrics access disabled. Existing scrape credentials no longer work.": "Доступ к метрикам отключён. Прежние данные доступа больше не работают.",
|
||||
"Copied to clipboard.": "Скопировано в буфер обмена.",
|
||||
"Could not copy. Select the text and copy it manually.": "Не удалось скопировать. Выделите текст и скопируйте его вручную.",
|
||||
"Status": "Статус сервисов"
|
||||
}
|
||||
|
||||
152
shared/prometheus.ts
Normal file
152
shared/prometheus.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const prometheusAction = z
|
||||
.object({ action: z.enum(["rotate", "disable"]) })
|
||||
.strict();
|
||||
export type PrometheusStatus = {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
updatedAt: string | null;
|
||||
lastScrapedAt: string | null;
|
||||
};
|
||||
|
||||
export function prometheusConfig(endpoint: string, token: string) {
|
||||
const url = new URL(endpoint);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
)
|
||||
throw new Error("A public HTTPS metrics endpoint is required");
|
||||
if (!/^mg_metrics_[a-f0-9]{64}$/.test(token))
|
||||
throw new Error("Invalid metrics token");
|
||||
// JSON strings are also YAML quoted scalars; never interpolate raw host input.
|
||||
return `scrape_configs:
|
||||
- job_name: minegeo
|
||||
scrape_interval: 60s
|
||||
scrape_timeout: 20s
|
||||
scheme: https
|
||||
metrics_path: ${JSON.stringify(url.pathname)}
|
||||
follow_redirects: false
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: ${JSON.stringify(token)}
|
||||
static_configs:
|
||||
- targets: [${JSON.stringify(url.host)}]
|
||||
`;
|
||||
}
|
||||
|
||||
export const metricDefinitions = {
|
||||
minegeo_accounts: {
|
||||
help: "Registered MineGeo profiles currently retained.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_maps: {
|
||||
help: "Retained maps by publication state.",
|
||||
labels: {
|
||||
state: [
|
||||
"uploading",
|
||||
"processing",
|
||||
"draft",
|
||||
"review",
|
||||
"published",
|
||||
"rejected",
|
||||
"unpublished",
|
||||
"failed",
|
||||
],
|
||||
},
|
||||
},
|
||||
minegeo_published_panoramas: {
|
||||
help: "Panoramas in currently published maps.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_games: {
|
||||
help: "Retained solo games by difficulty and completion state.",
|
||||
labels: {
|
||||
difficulty: ["easy", "normal", "hard"],
|
||||
status: ["incomplete", "complete"],
|
||||
},
|
||||
},
|
||||
minegeo_games_started_24h: {
|
||||
help: "Solo games created during the rolling last 24 hours.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_rooms: {
|
||||
help: "Retained multiplayer rooms by lifecycle state, not live online presence.",
|
||||
labels: {
|
||||
state: ["lobby", "loading", "playing", "results", "finished", "closed"],
|
||||
},
|
||||
},
|
||||
minegeo_rooms_created_24h: {
|
||||
help: "Multiplayer rooms created during the rolling last 24 hours.",
|
||||
labels: {},
|
||||
},
|
||||
minegeo_upload_jobs: {
|
||||
help: "Retained map upload jobs by processing state.",
|
||||
labels: { state: ["queued", "running", "complete", "failed", "cancelled"] },
|
||||
},
|
||||
minegeo_bug_reports: {
|
||||
help: "Retained player bug reports by support state.",
|
||||
labels: { state: ["draft", "pending", "open", "done"] },
|
||||
},
|
||||
minegeo_content_reports: {
|
||||
help: "Retained content reports by moderation state.",
|
||||
labels: { state: ["open", "resolved", "confirmed", "dismissed"] },
|
||||
},
|
||||
minegeo_discord_announcements: {
|
||||
help: "Retained Discord announcements by delivery state.",
|
||||
labels: { state: ["pending", "sending", "sent", "skipped", "failed"] },
|
||||
},
|
||||
minegeo_snapshot_timestamp_seconds: {
|
||||
help: "Unix timestamp when the database snapshot was collected.",
|
||||
labels: {},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const sampleSchema = z
|
||||
.object({
|
||||
metric: z.string(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
value: z.number().finite().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Reject unknown names/labels rather than exporting user-provided dimensions. */
|
||||
export function formatMetrics(input: unknown) {
|
||||
const samples = z.array(sampleSchema).max(100).parse(input);
|
||||
const lines: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const declared = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
if (!Object.hasOwn(metricDefinitions, sample.metric))
|
||||
throw new Error("Unknown metric");
|
||||
const definition =
|
||||
metricDefinitions[sample.metric as keyof typeof metricDefinitions];
|
||||
const allowed: Record<string, readonly string[]> = definition.labels;
|
||||
const keys = Object.keys(sample.labels).sort();
|
||||
if (
|
||||
keys.length !== Object.keys(allowed).length ||
|
||||
keys.some((k) => !allowed[k]?.includes(sample.labels[k]))
|
||||
)
|
||||
throw new Error("Invalid metric labels");
|
||||
const labels = keys
|
||||
.map((k) => `${k}=${JSON.stringify(sample.labels[k])}`)
|
||||
.join(",");
|
||||
const name = sample.metric + (labels ? `{${labels}}` : "");
|
||||
if (seen.has(name)) throw new Error("Duplicate metric sample");
|
||||
seen.add(name);
|
||||
if (!declared.has(sample.metric)) {
|
||||
lines.push(
|
||||
`# HELP ${sample.metric} ${definition.help}`,
|
||||
`# TYPE ${sample.metric} gauge`,
|
||||
);
|
||||
declared.add(sample.metric);
|
||||
}
|
||||
lines.push(`${name} ${sample.value}`);
|
||||
}
|
||||
if (declared.size !== Object.keys(metricDefinitions).length)
|
||||
throw new Error("Incomplete metrics snapshot");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
v2.120.0
|
||||
102
supabase/migrations/20261012085217_prometheus_metrics.sql
Normal file
102
supabase/migrations/20261012085217_prometheus_metrics.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
-- Credentials and cached aggregates are server-only. No raw token is retained.
|
||||
create table public.prometheus_settings (
|
||||
id boolean primary key default true check (id),
|
||||
token_hash text check (token_hash ~ '^[a-f0-9]{64}$'),
|
||||
updated_by uuid references public.profiles(id) on delete set null,
|
||||
updated_at timestamptz,
|
||||
last_scraped_at timestamptz,
|
||||
snapshot jsonb,
|
||||
snapshot_at timestamptz
|
||||
);
|
||||
insert into public.prometheus_settings(id) values (true);
|
||||
alter table public.prometheus_settings enable row level security;
|
||||
revoke all on public.prometheus_settings from public, anon, authenticated;
|
||||
grant select, update on public.prometheus_settings to service_role;
|
||||
create policy "Server manages metrics integration" on public.prometheus_settings
|
||||
for all to service_role using (true) with check (true);
|
||||
|
||||
create function public.prometheus_status(p_reviewer uuid) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' as $$
|
||||
begin
|
||||
if not exists (select 1 from public.profiles where id=p_reviewer and role='admin') then
|
||||
raise exception using errcode='42501', message='Admin permission required';
|
||||
end if;
|
||||
return (select jsonb_build_object(
|
||||
'enabled', s.token_hash is not null and exists(select 1 from public.profiles p where p.id=s.updated_by and p.role='admin'),
|
||||
'updatedAt', s.updated_at, 'lastScrapedAt', s.last_scraped_at
|
||||
) from public.prometheus_settings s where s.id);
|
||||
end; $$;
|
||||
|
||||
create function public.configure_prometheus(p_reviewer uuid, p_token_hash text) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' as $$
|
||||
begin
|
||||
if not exists (select 1 from public.profiles where id=p_reviewer and role='admin') then
|
||||
raise exception using errcode='42501', message='Admin permission required';
|
||||
end if;
|
||||
if p_token_hash is not null and p_token_hash !~ '^[a-f0-9]{64}$' then raise exception 'Invalid metrics token hash'; end if;
|
||||
update public.prometheus_settings set token_hash=p_token_hash, updated_by=p_reviewer,
|
||||
updated_at=clock_timestamp(), last_scraped_at=null, snapshot=null, snapshot_at=null where id;
|
||||
return public.prometheus_status(p_reviewer);
|
||||
end; $$;
|
||||
|
||||
create index if not exists game_sessions_metrics_created on public.game_sessions(created_at);
|
||||
create index if not exists multiplayer_rooms_metrics_created on public.multiplayer_rooms(created_at);
|
||||
|
||||
create function public.read_prometheus_metrics(p_token_hash text) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' set statement_timeout = '15s' as $$
|
||||
declare settings public.prometheus_settings; collected_at timestamptz := clock_timestamp(); result jsonb;
|
||||
begin
|
||||
-- The token grants only access to aggregate metrics. Demoting/deleting its
|
||||
-- issuing Admin also invalidates it. Lock prevents concurrent scrape stampedes.
|
||||
select s.* into settings from public.prometheus_settings s
|
||||
where s.id and s.token_hash=p_token_hash
|
||||
and exists(select 1 from public.profiles p where p.id=s.updated_by and p.role='admin')
|
||||
for update;
|
||||
if not found then return null; end if;
|
||||
if settings.snapshot_at > collected_at - interval '30 seconds' then
|
||||
update public.prometheus_settings set last_scraped_at=collected_at where id;
|
||||
return settings.snapshot;
|
||||
end if;
|
||||
with samples(metric, labels, value) as (
|
||||
select 'minegeo_accounts', '{}'::jsonb, count(*)::double precision from public.profiles
|
||||
union all select 'minegeo_published_panoramas', '{}'::jsonb, coalesce(sum(panorama_count),0)::double precision from public.datasets where state='published'
|
||||
union all select 'minegeo_games_started_24h', '{}'::jsonb, count(*)::double precision from public.game_sessions where created_at >= collected_at - interval '24 hours'
|
||||
union all select 'minegeo_rooms_created_24h', '{}'::jsonb, count(*)::double precision from public.multiplayer_rooms where created_at >= collected_at - interval '24 hours'
|
||||
union all select 'minegeo_snapshot_timestamp_seconds', '{}'::jsonb, extract(epoch from collected_at)::double precision
|
||||
union all
|
||||
select 'minegeo_maps', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('uploading'),('processing'),('draft'),('review'),('published'),('rejected'),('unpublished'),('failed')) v(state)
|
||||
left join (select state,count(*) n from public.datasets group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_games', jsonb_build_object('difficulty',d.difficulty,'status',v.status), coalesce(c.n,0)::double precision
|
||||
from (values('easy'),('normal'),('hard')) d(difficulty)
|
||||
cross join (values('incomplete'),('complete')) v(status)
|
||||
left join (select difficulty,case when current_round=5 then 'complete' else 'incomplete' end status,count(*) n
|
||||
from public.game_sessions group by 1,2) c using(difficulty,status)
|
||||
union all
|
||||
select 'minegeo_rooms', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('lobby'),('loading'),('playing'),('results'),('finished'),('closed')) v(state)
|
||||
left join (select status state,count(*) n from public.multiplayer_rooms group by status) c using(state)
|
||||
union all
|
||||
select 'minegeo_upload_jobs', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('queued'),('running'),('complete'),('failed'),('cancelled')) v(state)
|
||||
left join (select state,count(*) n from public.upload_jobs group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_bug_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('draft'),('pending'),('open'),('done')) v(state)
|
||||
left join (select state,count(*) n from public.bug_reports group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_content_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('open'),('resolved'),('confirmed'),('dismissed')) v(state)
|
||||
left join (select state,count(*) n from public.reports group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_discord_announcements', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('pending'),('sending'),('sent'),('skipped'),('failed')) v(state)
|
||||
left join (select state,count(*) n from public.discord_announcements group by state) c using(state)
|
||||
) select jsonb_agg(jsonb_build_object('metric',metric,'labels',labels,'value',value) order by metric,labels::text) into result from samples;
|
||||
update public.prometheus_settings set last_scraped_at=collected_at, snapshot_at=collected_at, snapshot=result where id;
|
||||
return result;
|
||||
end; $$;
|
||||
|
||||
revoke all on function public.prometheus_status(uuid), public.configure_prometheus(uuid,text), public.read_prometheus_metrics(text) from public,anon,authenticated;
|
||||
grant execute on function public.prometheus_status(uuid), public.configure_prometheus(uuid,text), public.read_prometheus_metrics(text) to service_role;
|
||||
128
supabase/migrations/20261012124943_developer_api_keys.sql
Normal file
128
supabase/migrations/20261012124943_developer_api_keys.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- Developer credentials are opaque application tokens, never Supabase sessions.
|
||||
-- Only the trusted server can manage/consume them; the raw token is not retained.
|
||||
create table public.developer_api_keys (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
owner_id uuid not null references public.profiles(id) on delete cascade,
|
||||
name text not null check (length(btrim(name)) between 1 and 60),
|
||||
prefix text not null check (prefix ~ '^[A-Za-z0-9_-]{8,24}$'),
|
||||
token_hash text not null unique check (token_hash ~ '^[a-f0-9]{64}$'),
|
||||
scopes text[] not null check (
|
||||
cardinality(scopes) between 1 and 4 and array_ndims(scopes)=1 and
|
||||
array_position(scopes,null) is null and scopes <@ array[
|
||||
'maps:read','account:read','account:maps:read','account:games:read']::text[]),
|
||||
created_at timestamptz not null default clock_timestamp(),
|
||||
expires_at timestamptz not null,
|
||||
revoked_at timestamptz,
|
||||
last_used_at timestamptz,
|
||||
rate_window timestamptz,
|
||||
rate_count integer not null default 0 check (rate_count between 0 and 60)
|
||||
);
|
||||
create index developer_api_keys_owner_created on public.developer_api_keys(owner_id,created_at desc);
|
||||
create table public.developer_api_owner_limits (
|
||||
owner_id uuid primary key references public.profiles(id) on delete cascade,
|
||||
rate_window timestamptz not null,
|
||||
rate_count integer not null default 0 check (rate_count between 0 and 180)
|
||||
);
|
||||
alter table public.developer_api_keys enable row level security;
|
||||
alter table public.developer_api_owner_limits enable row level security;
|
||||
revoke all on public.developer_api_keys, public.developer_api_owner_limits from public, anon, authenticated;
|
||||
grant select, insert, update, delete on public.developer_api_keys, public.developer_api_owner_limits to service_role;
|
||||
create policy "Server manages developer keys" on public.developer_api_keys for all to service_role using (true) with check (true);
|
||||
create policy "Server manages developer quotas" on public.developer_api_owner_limits for all to service_role using (true) with check (true);
|
||||
|
||||
create function public.developer_keys_list(p_owner uuid) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' as $$
|
||||
begin
|
||||
-- profiles.id references auth.users ON DELETE CASCADE, so this also checks
|
||||
-- account existence without granting service_role access to auth internals.
|
||||
if not exists(select 1 from public.profiles p where p.id=p_owner) then
|
||||
raise exception using errcode='42501', message='Account required';
|
||||
end if;
|
||||
-- Bounded metadata history, with every active key retained before old records.
|
||||
-- Explicit field selection deliberately excludes token_hash and quota state.
|
||||
return (select coalesce(jsonb_agg(jsonb_build_object(
|
||||
'id',k.id,'name',k.name,'prefix',k.prefix,'scopes',k.scopes,
|
||||
'createdAt',k.created_at,'expiresAt',k.expires_at,'revokedAt',k.revoked_at,'lastUsedAt',k.last_used_at
|
||||
) order by k.active desc,k.created_at desc,k.id desc),'[]'::jsonb) from (
|
||||
select d.*, (d.revoked_at is null and d.expires_at>clock_timestamp()) as active
|
||||
from public.developer_api_keys d where d.owner_id=p_owner
|
||||
order by active desc,d.created_at desc,d.id desc limit 100
|
||||
) k);
|
||||
end; $$;
|
||||
|
||||
create function public.developer_key_create(p_owner uuid,p_name text,p_scopes text[],p_token_hash text,p_prefix text,p_expires_days integer) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' as $$
|
||||
declare k public.developer_api_keys; normalized_scopes text[];
|
||||
begin
|
||||
if p_name is null or length(btrim(p_name)) not between 1 and 60
|
||||
or p_token_hash is null or p_token_hash !~ '^[a-f0-9]{64}$'
|
||||
or p_prefix is null or p_prefix !~ '^[A-Za-z0-9_-]{8,24}$'
|
||||
or p_expires_days is null or p_expires_days not between 1 and 365 then
|
||||
raise exception using errcode='22023', message='Invalid API key settings';
|
||||
end if;
|
||||
if p_scopes is null or cardinality(p_scopes) not between 1 and 4 or array_ndims(p_scopes)<>1
|
||||
or array_position(p_scopes,null) is not null or not p_scopes <@ array[
|
||||
'maps:read','account:read','account:maps:read','account:games:read']::text[] then
|
||||
raise exception using errcode='22023', message='Invalid API key scopes';
|
||||
end if;
|
||||
select array_agg(distinct s order by s) into normalized_scopes from unnest(p_scopes) s;
|
||||
-- A single owner lock serializes both key creation and quota consumption.
|
||||
perform p.id from public.profiles p where p.id=p_owner for update;
|
||||
if not found then raise exception using errcode='42501', message='Account required'; end if;
|
||||
if (select count(*) from public.developer_api_keys where owner_id=p_owner and revoked_at is null and expires_at>clock_timestamp())>=5 then
|
||||
raise exception using errcode='22023', message='Maximum 5 active API keys';
|
||||
end if;
|
||||
insert into public.developer_api_keys(owner_id,name,prefix,token_hash,scopes,expires_at)
|
||||
values(p_owner,btrim(p_name),p_prefix,p_token_hash,normalized_scopes,clock_timestamp()+make_interval(days=>p_expires_days)) returning * into k;
|
||||
return jsonb_build_object('id',k.id,'name',k.name,'prefix',k.prefix,'scopes',k.scopes,
|
||||
'createdAt',k.created_at,'expiresAt',k.expires_at,'revokedAt',k.revoked_at,'lastUsedAt',k.last_used_at);
|
||||
end; $$;
|
||||
|
||||
create function public.developer_key_revoke(p_owner uuid,p_key uuid) returns boolean
|
||||
language plpgsql security invoker set search_path = '' as $$
|
||||
begin
|
||||
perform p.id from public.profiles p where p.id=p_owner for update;
|
||||
if not found then raise exception using errcode='42501', message='Account required'; end if;
|
||||
update public.developer_api_keys set revoked_at=coalesce(revoked_at,clock_timestamp()) where owner_id=p_owner and id=p_key;
|
||||
return found;
|
||||
end; $$;
|
||||
|
||||
create function public.developer_key_consume(p_token_hash text) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' set statement_timeout = '10s' as $$
|
||||
declare k public.developer_api_keys; limits public.developer_api_owner_limits;
|
||||
owner uuid; checked_at timestamptz; window_at timestamptz; reset_at bigint; key_count integer; owner_count integer;
|
||||
begin
|
||||
if p_token_hash is null or p_token_hash !~ '^[a-f0-9]{64}$' then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
|
||||
select owner_id into owner from public.developer_api_keys where token_hash=p_token_hash;
|
||||
if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
|
||||
-- Every consume/create/revoke takes the same owner lock first, then the key.
|
||||
-- This ensures concurrent keys cannot overspend a shared account allowance.
|
||||
perform p.id from public.profiles p where p.id=owner for update;
|
||||
if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
|
||||
checked_at:=clock_timestamp(); window_at:=date_trunc('minute',checked_at);
|
||||
reset_at:=extract(epoch from window_at+interval '1 minute')::bigint;
|
||||
select * into k from public.developer_api_keys where token_hash=p_token_hash and owner_id=owner
|
||||
and revoked_at is null and expires_at>checked_at for update;
|
||||
if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if;
|
||||
insert into public.developer_api_owner_limits(owner_id,rate_window) values(owner,window_at) on conflict(owner_id) do nothing;
|
||||
select * into limits from public.developer_api_owner_limits where owner_id=owner for update;
|
||||
key_count:=case when k.rate_window=window_at then k.rate_count else 0 end;
|
||||
owner_count:=case when limits.rate_window=window_at then limits.rate_count else 0 end;
|
||||
if key_count>=60 or owner_count>=180 then
|
||||
return jsonb_build_object('ok',false,'error','rate_limited','limit',60,'remaining',0,
|
||||
'ownerLimit',180,'ownerRemaining',greatest(180-owner_count,0),'resetAt',reset_at);
|
||||
end if;
|
||||
update public.developer_api_keys set rate_window=window_at,rate_count=key_count+1,last_used_at=checked_at where id=k.id;
|
||||
update public.developer_api_owner_limits set rate_window=window_at,rate_count=owner_count+1 where owner_id=owner;
|
||||
return jsonb_build_object('ok',true,'keyId',k.id,'ownerId',owner,'scopes',k.scopes,
|
||||
'limit',60,'remaining',59-key_count,'ownerLimit',180,'ownerRemaining',179-owner_count,'resetAt',reset_at);
|
||||
end; $$;
|
||||
|
||||
revoke all on function public.developer_keys_list(uuid) from public, anon, authenticated;
|
||||
revoke all on function public.developer_key_create(uuid,text,text[],text,text,integer) from public, anon, authenticated;
|
||||
revoke all on function public.developer_key_revoke(uuid,uuid) from public, anon, authenticated;
|
||||
revoke all on function public.developer_key_consume(text) from public, anon, authenticated;
|
||||
grant execute on function public.developer_keys_list(uuid) to service_role;
|
||||
grant execute on function public.developer_key_create(uuid,text,text[],text,text,integer) to service_role;
|
||||
grant execute on function public.developer_key_revoke(uuid,uuid) to service_role;
|
||||
grant execute on function public.developer_key_consume(text) to service_role;
|
||||
238
tests/developer-api.test.ts
Normal file
238
tests/developer-api.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
developerResponse,
|
||||
type DeveloperData,
|
||||
} from "../lib/developer-response";
|
||||
import {
|
||||
developerKeyInput,
|
||||
developerPagination,
|
||||
developerPage,
|
||||
type DeveloperScope,
|
||||
} from "../shared/developer-api";
|
||||
import { developerOpenApi } from "../shared/developer-openapi";
|
||||
|
||||
const owner = "00000000-0000-4000-8000-000000000001";
|
||||
const keyId = "00000000-0000-4000-8000-000000000002";
|
||||
const token = "mg_dev_" + "a".repeat(64);
|
||||
function request(
|
||||
path = "maps",
|
||||
authorization = `Bearer ${token}`,
|
||||
method = "GET",
|
||||
) {
|
||||
return new Request(`https://minegeo.vercel.app/api/dev/v1/${path}`, {
|
||||
method,
|
||||
headers: { authorization, cookie: "admin=ignored" },
|
||||
});
|
||||
}
|
||||
function deps(scopes: DeveloperScope[] = ["maps:read"]): DeveloperData {
|
||||
return {
|
||||
consume: async (hash) => {
|
||||
assert.equal(hash, createHash("sha256").update(token).digest("hex"));
|
||||
return {
|
||||
ok: true,
|
||||
keyId,
|
||||
ownerId: owner,
|
||||
scopes,
|
||||
limit: 60,
|
||||
remaining: 59,
|
||||
ownerLimit: 180,
|
||||
ownerRemaining: 179,
|
||||
resetAt: Math.floor(Date.now() / 1000) + 30,
|
||||
};
|
||||
},
|
||||
maps: async () => ({
|
||||
maps: [],
|
||||
pagination: { limit: 20, offset: 0, total: 0, nextOffset: null },
|
||||
}),
|
||||
map: async () => null,
|
||||
account: async () => ({ id: owner }),
|
||||
games: async () => ({ games: [] }),
|
||||
};
|
||||
}
|
||||
test("developer API accepts only dedicated Bearer keys, never cookies, contributor, metrics or query credentials", async () => {
|
||||
for (const auth of [
|
||||
"",
|
||||
"Bearer contributor-token",
|
||||
"Bearer mg_metrics_" + "a".repeat(64),
|
||||
token,
|
||||
`Bearer ${token}suffix`,
|
||||
]) {
|
||||
const d = deps();
|
||||
d.consume = async () => {
|
||||
assert.fail("must reject credential shape before querying");
|
||||
};
|
||||
const r = await developerResponse(
|
||||
request("maps?key=" + token, auth),
|
||||
["maps"],
|
||||
d,
|
||||
);
|
||||
assert.equal(r.status, 401);
|
||||
assert.match(r.headers.get("cache-control")!, /no-store/);
|
||||
}
|
||||
});
|
||||
test("API denies revoked/expired/unknown keys and fails closed without leaking database errors", async () => {
|
||||
for (const consume of [
|
||||
async () => ({ ok: false, error: "invalid_key" }),
|
||||
async () => {
|
||||
throw Error("private SQL password");
|
||||
},
|
||||
async () => ({ ok: true }),
|
||||
]) {
|
||||
const d = deps();
|
||||
d.consume = consume;
|
||||
d.maps = async () => {
|
||||
assert.fail("must not read data");
|
||||
};
|
||||
const r = await developerResponse(request(), ["maps"], d);
|
||||
assert.ok([401, 503].includes(r.status));
|
||||
assert.ok(!(await r.text()).includes("private SQL"));
|
||||
}
|
||||
});
|
||||
test("each private API permission is independent; owner identity comes only from the validated key", async () => {
|
||||
for (const [path, scope] of [
|
||||
["me", "account:read"],
|
||||
["me/maps", "account:maps:read"],
|
||||
["me/games", "account:games:read"],
|
||||
] as const) {
|
||||
const denied = deps();
|
||||
denied.account =
|
||||
denied.games =
|
||||
denied.maps =
|
||||
async () => {
|
||||
assert.fail("missing permission must not read");
|
||||
};
|
||||
assert.equal(
|
||||
(await developerResponse(request(path), path.split("/"), denied)).status,
|
||||
403,
|
||||
);
|
||||
const allowed = deps([scope]);
|
||||
allowed.account = async (id) => {
|
||||
assert.equal(id, owner);
|
||||
return { id };
|
||||
};
|
||||
allowed.maps = async (_page, id) => {
|
||||
assert.equal(id, owner);
|
||||
return { maps: [] };
|
||||
};
|
||||
allowed.games = async (id) => {
|
||||
assert.equal(id, owner);
|
||||
return { games: [] };
|
||||
};
|
||||
assert.equal(
|
||||
(await developerResponse(request(path), path.split("/"), allowed)).status,
|
||||
200,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await developerResponse(
|
||||
request("me?ownerId=other"),
|
||||
["me"],
|
||||
deps(["account:read"]),
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
test("quota rejection returns Retry-After and headers before any data read", async () => {
|
||||
const d = deps();
|
||||
d.consume = async () => ({
|
||||
ok: false,
|
||||
error: "rate_limited",
|
||||
limit: 60,
|
||||
remaining: 0,
|
||||
ownerLimit: 180,
|
||||
ownerRemaining: 0,
|
||||
resetAt: Math.floor(Date.now() / 1000) + 30,
|
||||
});
|
||||
d.maps = async () => {
|
||||
assert.fail("quota exhausted");
|
||||
};
|
||||
const r = await developerResponse(request(), ["maps"], d);
|
||||
assert.equal(r.status, 429);
|
||||
assert.equal(r.headers.get("ratelimit-remaining"), "0");
|
||||
assert.ok(Number(r.headers.get("retry-after")) > 0);
|
||||
assert.equal(r.headers.get("x-ratelimit-owner-limit"), "180");
|
||||
});
|
||||
test("published map lookup hides missing/private maps and forbids mutation", async () => {
|
||||
const d = deps();
|
||||
d.map = async (id) => {
|
||||
assert.equal(id, owner);
|
||||
return null;
|
||||
};
|
||||
assert.equal(
|
||||
(await developerResponse(request("maps/" + owner), ["maps", owner], d))
|
||||
.status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await developerResponse(
|
||||
request("maps/not-a-uuid"),
|
||||
["maps", "not-a-uuid"],
|
||||
d,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const response = await developerResponse(
|
||||
request("maps", `Bearer ${token}`, "POST"),
|
||||
["maps"],
|
||||
d,
|
||||
);
|
||||
assert.equal(response.status, 405);
|
||||
assert.equal(response.headers.get("allow"), "GET");
|
||||
});
|
||||
test("pagination is bounded, search literal and strict; key scopes/expiry cannot escalate", () => {
|
||||
const url = (q: string) => new URL("https://example.com/?" + q);
|
||||
for (const q of [
|
||||
"limit=51",
|
||||
"limit=0",
|
||||
"limit=1.5",
|
||||
"offset=-1",
|
||||
"offset=10001",
|
||||
"limit=1&limit=2",
|
||||
"owner_id=other",
|
||||
"q=" + "x".repeat(81),
|
||||
])
|
||||
assert.throws(() => developerPagination(url(q), true), q);
|
||||
assert.deepEqual(
|
||||
developerPagination(url("limit=50&offset=10&q=%25_"), true),
|
||||
{ limit: 50, offset: 10, q: "%_" },
|
||||
);
|
||||
assert.equal(
|
||||
developerPage(80, { limit: 50, offset: 50, q: "" }).nextOffset,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
developerPage(11000, { limit: 20, offset: 10000, q: "" }).nextOffset,
|
||||
null,
|
||||
);
|
||||
const input = { name: "Python", scopes: ["maps:read"], expiresDays: 90 };
|
||||
assert.ok(developerKeyInput.safeParse(input).success);
|
||||
for (const bad of [
|
||||
{ ...input, scopes: ["admin"] },
|
||||
{ ...input, scopes: [] },
|
||||
{ ...input, scopes: ["maps:read", "maps:read"] },
|
||||
{ ...input, expiresDays: 366 },
|
||||
{ ...input, ownerId: owner },
|
||||
])
|
||||
assert.equal(developerKeyInput.safeParse(bad).success, false);
|
||||
});
|
||||
test("OpenAPI documents only bounded read-only endpoints with explicit key permissions", () => {
|
||||
assert.equal(developerOpenApi.openapi, "3.1.0");
|
||||
assert.deepEqual(Object.keys(developerOpenApi.paths).sort(), [
|
||||
"/maps",
|
||||
"/maps/{id}",
|
||||
"/me",
|
||||
"/me/games",
|
||||
"/me/maps",
|
||||
]);
|
||||
for (const path of Object.values(developerOpenApi.paths)) {
|
||||
assert.deepEqual(Object.keys(path), ["get"]);
|
||||
assert.equal(path.get.security[0].DeveloperKey.length, 0);
|
||||
assert.ok(path.get["x-required-scope"]);
|
||||
}
|
||||
});
|
||||
147
tests/prometheus.test.ts
Normal file
147
tests/prometheus.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
metricDefinitions,
|
||||
formatMetrics,
|
||||
prometheusAction,
|
||||
prometheusConfig,
|
||||
} from "../shared/prometheus";
|
||||
import { metricsResponse } from "../lib/prometheus-response";
|
||||
import { grafanaDashboard } from "../shared/grafana-dashboard";
|
||||
|
||||
const token = "mg_metrics_" + "a".repeat(64);
|
||||
const samples = Object.entries(metricDefinitions).flatMap(
|
||||
([metric, definition]) => {
|
||||
let dimensions: Record<string, string>[] = [{}];
|
||||
for (const [key, values] of Object.entries(definition.labels) as [
|
||||
string,
|
||||
readonly string[],
|
||||
][])
|
||||
dimensions = dimensions.flatMap((labels) =>
|
||||
values.map((value) => ({ ...labels, [key]: value })),
|
||||
);
|
||||
return dimensions.map((labels) => ({ metric, labels, value: 0 }));
|
||||
},
|
||||
);
|
||||
|
||||
test("metrics exposition is bounded, complete and contains only fixed aggregate dimensions", () => {
|
||||
const text = formatMetrics(samples);
|
||||
assert.equal(samples.length, 43);
|
||||
assert.equal(
|
||||
text.split("\n").filter((line) => line && !line.startsWith("#")).length,
|
||||
43,
|
||||
);
|
||||
assert.ok(text.endsWith("\n"));
|
||||
assert.match(text, /# TYPE minegeo_games gauge/);
|
||||
assert.match(text, /minegeo_games\{difficulty="easy",status="complete"\} 0/);
|
||||
for (const bad of [
|
||||
[...samples, samples[0]],
|
||||
samples.filter((s) => s.metric !== "minegeo_accounts"),
|
||||
[{ ...samples[0], metric: "evil_metric" }, ...samples.slice(1)],
|
||||
[
|
||||
{ ...samples[0], labels: { username: "private-player" } },
|
||||
...samples.slice(1),
|
||||
],
|
||||
[{ ...samples[0], value: NaN }, ...samples.slice(1)],
|
||||
[{ ...samples[0], value: -1 }, ...samples.slice(1)],
|
||||
])
|
||||
assert.throws(() => formatMetrics(bad));
|
||||
});
|
||||
|
||||
test("scrape authentication rejects cookie-only, query-only and device-style credentials before reading data", async () => {
|
||||
for (const authorization of [
|
||||
null,
|
||||
"Bearer contributor-token",
|
||||
"Basic abc",
|
||||
token,
|
||||
`Bearer ${token}\n`,
|
||||
`Bearer ${token}suffix`,
|
||||
]) {
|
||||
const headers = new Headers({ cookie: "admin=session" });
|
||||
// Headers reject newlines before our endpoint is involved.
|
||||
if (authorization?.includes("\n")) continue;
|
||||
if (authorization) headers.set("authorization", authorization);
|
||||
const response = await metricsResponse({ headers }, async () => {
|
||||
assert.fail("must not query without a valid credential shape");
|
||||
});
|
||||
assert.equal(response.status, 401);
|
||||
assert.match(response.headers.get("cache-control")!, /no-store/);
|
||||
}
|
||||
const response = await metricsResponse(
|
||||
{ headers: new Headers({ authorization: `Bearer ${token}` }) },
|
||||
async (hash) => {
|
||||
assert.equal(hash, createHash("sha256").update(token).digest("hex"));
|
||||
return null;
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
|
||||
test("scrape returns Prometheus text on success and fails closed during database or validation outages", async () => {
|
||||
const request = {
|
||||
headers: new Headers({ authorization: `Bearer ${token}` }),
|
||||
};
|
||||
const response = await metricsResponse(request, async () => samples);
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(
|
||||
response.headers.get("content-type")!,
|
||||
/text\/plain; version=0.0.4/,
|
||||
);
|
||||
const body = await response.text();
|
||||
assert.match(body, /minegeo_exporter_read_duration_seconds [0-9.]+/);
|
||||
assert.ok(!body.includes(token));
|
||||
for (const read of [
|
||||
async () => {
|
||||
throw new Error("private database detail");
|
||||
},
|
||||
async () => [],
|
||||
]) {
|
||||
const failed = await metricsResponse(request, read);
|
||||
assert.equal(failed.status, 503);
|
||||
assert.equal(await failed.text(), "Metrics temporarily unavailable\n");
|
||||
}
|
||||
});
|
||||
|
||||
test("copyable YAML scopes bearer credentials to the HTTPS metrics target and disallows malformed setup", () => {
|
||||
const config = prometheusConfig(
|
||||
"https://minegeo.vercel.app/api/metrics",
|
||||
token,
|
||||
);
|
||||
assert.match(config, /metrics_path: "\/api\/metrics"/);
|
||||
assert.match(config, /targets: \["minegeo.vercel.app"\]/);
|
||||
assert.match(config, /follow_redirects: false/);
|
||||
assert.match(config, /type: Bearer/);
|
||||
assert.match(config, /scrape_interval: 60s/);
|
||||
for (const endpoint of [
|
||||
"http://localhost/api/metrics",
|
||||
"https://user:secret@example.org/api/metrics",
|
||||
"https://example.org/api/metrics?secret=bad",
|
||||
])
|
||||
assert.throws(() => prometheusConfig(endpoint, token));
|
||||
assert.throws(() =>
|
||||
prometheusConfig("https://example.org/api/metrics", "bad\nconfig"),
|
||||
);
|
||||
assert.equal(
|
||||
prometheusAction.safeParse({ action: "rotate", role: "admin" }).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Grafana dashboard imports with a selected Prometheus source and avoids rates on retained-count gauges", () => {
|
||||
const dashboard = grafanaDashboard();
|
||||
assert.equal(dashboard.__inputs[0].pluginId, "prometheus");
|
||||
assert.equal(dashboard.panels.length, 15);
|
||||
const json = JSON.stringify(dashboard);
|
||||
assert.ok(!json.includes(token));
|
||||
const supported = new Set([
|
||||
...Object.keys(metricDefinitions),
|
||||
"minegeo_exporter_read_duration_seconds",
|
||||
]);
|
||||
for (const panel of dashboard.panels) {
|
||||
assert.equal(panel.datasource.uid, "${DS_PROMETHEUS}");
|
||||
assert.ok(!/\b(rate|increase)\(/.test(panel.targets[0].expr));
|
||||
for (const name of panel.targets[0].expr.match(/minegeo_[a-z0-9_]+/g) ?? [])
|
||||
assert.ok(supported.has(name), name);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user