193 lines
5.6 KiB
TypeScript
193 lines
5.6 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|