feat: complete TASK-WP0-03 browser gate
This commit is contained in:
+184
-4
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
BootstrapResponseSchema,
|
||||
@@ -11,13 +13,28 @@ import {
|
||||
SseEventSchema,
|
||||
StableEngineeringErrorCodeSchema,
|
||||
StateSseEventSchema,
|
||||
createErrorEnvelope,
|
||||
isCorrelationId,
|
||||
type BootstrapResponse,
|
||||
} from "@dada/shared-contracts";
|
||||
import swagger from "@fastify/swagger";
|
||||
import Fastify from "fastify";
|
||||
import Fastify, { type FastifyReply } from "fastify";
|
||||
|
||||
import {
|
||||
BrowserSupportRequestSchema,
|
||||
BrowserSupportSuccessSchema,
|
||||
BrowserUnsupportedReasonSchema,
|
||||
browserSupportCookieMaxAgeSeconds,
|
||||
browserSupportCookieName,
|
||||
checkBrowserSupport,
|
||||
createBrowserSupportCookie,
|
||||
supportedBrowserSummary,
|
||||
verifyBrowserSupportCookie,
|
||||
type BrowserSupportRelease,
|
||||
type BrowserUnsupportedReason,
|
||||
} from "./browser-support.js";
|
||||
import { EventHub } from "./event-hub.js";
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
|
||||
const defaultBootstrap: BootstrapResponse = {
|
||||
app_version: "0.0.0",
|
||||
@@ -33,18 +50,80 @@ const defaultBootstrap: BootstrapResponse = {
|
||||
|
||||
export interface CreateAppOptions {
|
||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||
browserGate?: boolean;
|
||||
browserSupportRelease?: BrowserSupportRelease;
|
||||
browserSupportSecret?: Buffer;
|
||||
eventHub?: EventHub;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
}
|
||||
|
||||
const supportGateDirectory = resolve("apps/web/support-gate");
|
||||
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
||||
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
||||
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
||||
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
|
||||
const contentSecurityPolicy = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' blob:",
|
||||
"font-src 'self' blob:",
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ");
|
||||
|
||||
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
||||
const header = headers["x-correlation-id"];
|
||||
const candidate = Array.isArray(header) ? header[0] : header;
|
||||
return isCorrelationId(candidate) ? candidate : randomUUID();
|
||||
}
|
||||
|
||||
function headerValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function isSupportGateRequest(method: string, path: string) {
|
||||
if (method === "POST" && path === "/api/v1/support/check") return true;
|
||||
if (method !== "GET" && method !== "HEAD") return false;
|
||||
return (
|
||||
path === "/" ||
|
||||
path === "/app" ||
|
||||
path.startsWith("/app/") ||
|
||||
path === "/admin" ||
|
||||
path.startsWith("/admin/") ||
|
||||
path === "/support-gate.css" ||
|
||||
path === "/support-gate.js" ||
|
||||
path === "/RELEASE.json" ||
|
||||
path === "/healthz"
|
||||
);
|
||||
}
|
||||
|
||||
function sendBrowserUnsupported(
|
||||
reply: FastifyReply,
|
||||
correlationId: string,
|
||||
reason: BrowserUnsupportedReason,
|
||||
release: BrowserSupportRelease | undefined,
|
||||
) {
|
||||
return reply.code(426).send(
|
||||
createErrorEnvelope({
|
||||
code: "BROWSER_UNSUPPORTED",
|
||||
correlationId,
|
||||
details: {
|
||||
reason,
|
||||
supported_browsers: supportedBrowserSummary(release),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createApp(options: CreateAppOptions = {}) {
|
||||
const eventHub = options.eventHub ?? new EventHub();
|
||||
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
|
||||
const browserGate = options.browserGate ?? true;
|
||||
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
||||
const browserSupportRelease = options.browserSupportRelease;
|
||||
const app = Fastify({
|
||||
genReqId: (request) => requestCorrelationId(request.headers),
|
||||
logger: false,
|
||||
@@ -70,6 +149,9 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
StableEngineeringErrorCodeSchema,
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
BrowserUnsupportedReasonSchema,
|
||||
BrowserSupportRequestSchema,
|
||||
BrowserSupportSuccessSchema,
|
||||
BootstrapResponseSchema,
|
||||
StateSseEventSchema,
|
||||
ModelConfigSseEventSchema,
|
||||
@@ -79,11 +161,107 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
app.addSchema(schema);
|
||||
}
|
||||
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
reply.header("X-Correlation-Id", request.id);
|
||||
done();
|
||||
reply.header("Accept-CH", clientHints);
|
||||
reply.header("Cache-Control", "no-store");
|
||||
reply.header("Content-Security-Policy", contentSecurityPolicy);
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
|
||||
const host = headerValue(request.headers.host);
|
||||
const origin = headerValue(request.headers.origin);
|
||||
if (!isAllowedNetworkRequest({ host, method: request.method, origin }, options.networkBoundary)) {
|
||||
return sendBrowserUnsupported(reply, request.id, "identity_unavailable", browserSupportRelease);
|
||||
}
|
||||
|
||||
const path = request.url.split("?", 1)[0] ?? "/";
|
||||
if (!browserGate || isSupportGateRequest(request.method, path)) return;
|
||||
const verified = verifyBrowserSupportCookie({
|
||||
cookieHeader: headerValue(request.headers.cookie),
|
||||
release: browserSupportRelease,
|
||||
secChUa: headerValue(request.headers["sec-ch-ua"]),
|
||||
secret: browserSupportSecret,
|
||||
});
|
||||
if (!verified.supported) {
|
||||
return sendBrowserUnsupported(reply, request.id, verified.reason, browserSupportRelease);
|
||||
}
|
||||
});
|
||||
|
||||
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
|
||||
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
|
||||
reply.type("text/html; charset=utf-8");
|
||||
return supportGateHtml;
|
||||
});
|
||||
}
|
||||
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
|
||||
reply.type("text/css; charset=utf-8");
|
||||
return supportGateCss;
|
||||
});
|
||||
app.get("/support-gate.js", { schema: { hide: true } }, async (_request, reply) => {
|
||||
reply.type("text/javascript; charset=utf-8");
|
||||
return supportGateJavaScript;
|
||||
});
|
||||
app.get("/RELEASE.json", { schema: { hide: true } }, async () => ({
|
||||
app_version: browserSupportRelease?.appVersion ?? null,
|
||||
browsers: supportedBrowserSummary(browserSupportRelease),
|
||||
}));
|
||||
app.get("/healthz", { schema: { hide: true } }, async () => ({
|
||||
bind_scope: "loopback",
|
||||
port: 43121,
|
||||
status: "ready",
|
||||
}));
|
||||
|
||||
app.post(
|
||||
"/api/v1/support/check",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: BrowserSupportRequestSchema,
|
||||
operationId: "checkBrowserSupport",
|
||||
response: {
|
||||
200: BrowserSupportSuccessSchema,
|
||||
426: ErrorEnvelopeSchema,
|
||||
},
|
||||
tags: ["Browser support"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const checked = checkBrowserSupport(
|
||||
request.validationError ? undefined : request.body,
|
||||
{
|
||||
secChUa: headerValue(request.headers["sec-ch-ua"]),
|
||||
secChUaFullVersionList: headerValue(request.headers["sec-ch-ua-full-version-list"]),
|
||||
secChUaPlatform: headerValue(request.headers["sec-ch-ua-platform"]),
|
||||
},
|
||||
browserSupportRelease,
|
||||
);
|
||||
if (!checked.supported || !browserSupportRelease) {
|
||||
return sendBrowserUnsupported(
|
||||
reply,
|
||||
request.id,
|
||||
checked.supported ? "version_unsupported" : checked.reason,
|
||||
browserSupportRelease,
|
||||
);
|
||||
}
|
||||
|
||||
const cookie = createBrowserSupportCookie(
|
||||
browserSupportSecret,
|
||||
browserSupportRelease,
|
||||
checked.identity,
|
||||
);
|
||||
reply.header(
|
||||
"Set-Cookie",
|
||||
`${browserSupportCookieName}=${cookie}; Max-Age=${browserSupportCookieMaxAgeSeconds}; Path=/; HttpOnly; SameSite=Strict`,
|
||||
);
|
||||
return {
|
||||
app_version: browserSupportRelease.appVersion,
|
||||
browser: checked.identity,
|
||||
status: "supported" as const,
|
||||
supported_browsers: supportedBrowserSummary(browserSupportRelease),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/bootstrap",
|
||||
{
|
||||
@@ -91,6 +269,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
operationId: "getBootstrap",
|
||||
response: {
|
||||
200: BootstrapResponseSchema,
|
||||
426: ErrorEnvelopeSchema,
|
||||
},
|
||||
tags: ["Bootstrap"],
|
||||
},
|
||||
@@ -112,6 +291,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
description: "Non-sensitive state change hints. REST remains authoritative.",
|
||||
},
|
||||
426: ErrorEnvelopeSchema,
|
||||
},
|
||||
tags: ["State events"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user