Files
MineGeo/lib/developer-response.ts

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