91 lines
2.8 KiB
TypeScript
91 lines
2.8 KiB
TypeScript
import type { FastifyPluginAsync } from "fastify";
|
|
import { z } from "zod";
|
|
|
|
type RateLimitRouteOptions = {
|
|
config: {
|
|
rateLimit: {
|
|
max: number;
|
|
timeWindow: number;
|
|
keyGenerator?: (req: any) => string;
|
|
};
|
|
};
|
|
};
|
|
|
|
type SiteAccessRoutesOptions = {
|
|
underConstructionEnabled: boolean;
|
|
breakGlassVerifyEnabled: boolean;
|
|
breakGlassVerifyCode: string | null;
|
|
siteAccessExpectedToken: string | null;
|
|
cookieDomain?: string;
|
|
secureCookie: boolean;
|
|
siteAccessCookieName: string;
|
|
siteAccessMaxAgeSeconds: number;
|
|
authRateLimit: RateLimitRouteOptions;
|
|
mutationRateLimit: RateLimitRouteOptions;
|
|
hasSiteAccessBypass: (req: { cookies?: Record<string, unknown> }) => boolean;
|
|
safeEqual: (a: string, b: string) => boolean;
|
|
};
|
|
|
|
const siteAccessRoutes: FastifyPluginAsync<SiteAccessRoutesOptions> = async (app, opts) => {
|
|
app.get("/site-access/status", async (req) => {
|
|
if (!opts.underConstructionEnabled) {
|
|
return { ok: true, enabled: false, unlocked: true };
|
|
}
|
|
return {
|
|
ok: true,
|
|
enabled: true,
|
|
unlocked: opts.hasSiteAccessBypass(req),
|
|
};
|
|
});
|
|
|
|
app.post("/site-access/unlock", opts.authRateLimit, async (req, reply) => {
|
|
const Body = z.object({
|
|
code: z.string().min(1).max(512),
|
|
});
|
|
const parsed = Body.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" });
|
|
}
|
|
if (!opts.underConstructionEnabled) {
|
|
return { ok: true, enabled: false, unlocked: true };
|
|
}
|
|
if (!opts.breakGlassVerifyEnabled || !opts.siteAccessExpectedToken) {
|
|
return reply.code(503).send({
|
|
ok: false,
|
|
code: "UNDER_CONSTRUCTION_MISCONFIGURED",
|
|
message: "Under-construction access is not configured.",
|
|
});
|
|
}
|
|
if (!opts.breakGlassVerifyCode || !opts.safeEqual(parsed.data.code, opts.breakGlassVerifyCode)) {
|
|
return reply.code(401).send({
|
|
ok: false,
|
|
code: "INVALID_ACCESS_CODE",
|
|
message: "Invalid access code.",
|
|
});
|
|
}
|
|
|
|
reply.setCookie(opts.siteAccessCookieName, opts.siteAccessExpectedToken, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: opts.secureCookie,
|
|
path: "/",
|
|
maxAge: opts.siteAccessMaxAgeSeconds,
|
|
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
|
|
});
|
|
return { ok: true, enabled: true, unlocked: true };
|
|
});
|
|
|
|
app.post("/site-access/lock", opts.mutationRateLimit, async (_req, reply) => {
|
|
reply.clearCookie(opts.siteAccessCookieName, {
|
|
path: "/",
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: opts.secureCookie,
|
|
...(opts.cookieDomain ? { domain: opts.cookieDomain } : {}),
|
|
});
|
|
return { ok: true, enabled: opts.underConstructionEnabled, unlocked: false };
|
|
});
|
|
};
|
|
|
|
export default siteAccessRoutes;
|