Add developer API, Prometheus metrics, and community links

This commit is contained in:
2026-10-12 19:59:37 +05:00
parent 3cc3136762
commit 018be5049e
50 changed files with 4087 additions and 31 deletions

View File

@@ -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
View 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,
});
}

View 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
View 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
View 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
View 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 });
}

View 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,
});
}
}