Add developer API, Prometheus metrics, and community links
This commit is contained in:
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user