diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9cbb9c3..f60581c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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; + 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) { 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"], }, diff --git a/apps/api/src/browser-support.ts b/apps/api/src/browser-support.ts new file mode 100644 index 0000000..d21a9eb --- /dev/null +++ b/apps/api/src/browser-support.ts @@ -0,0 +1,275 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { Type, type Static } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +export const browserSupportCookieName = "dada_browser_support"; +export const browserSupportCookieMaxAgeSeconds = 24 * 60 * 60; + +export const BrowserUnsupportedReasonSchema = Type.Union( + [ + Type.Literal("platform_unsupported"), + Type.Literal("brand_unsupported"), + Type.Literal("version_unsupported"), + Type.Literal("identity_unavailable"), + ], + { $id: "BrowserUnsupportedReason" }, +); +const BrowserBrandVersionSchema = Type.Object( + { + brand: Type.String({ maxLength: 80 }), + version: Type.String({ maxLength: 80 }), + }, + { additionalProperties: false }, +); +const SupportedBrowserSummarySchema = Type.Object( + { + brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]), + major: Type.Integer({ minimum: 1 }), + }, + { additionalProperties: false }, +); +export const BrowserSupportRequestSchema = Type.Object( + { + brands: Type.Array(BrowserBrandVersionSchema, { maxItems: 16 }), + full_version_list: Type.Array(BrowserBrandVersionSchema, { maxItems: 16 }), + platform: Type.String({ maxLength: 40 }), + }, + { additionalProperties: false, $id: "BrowserSupportRequest" }, +); +export const BrowserSupportSuccessSchema = Type.Object( + { + app_version: Type.String({ maxLength: 80 }), + browser: SupportedBrowserSummarySchema, + status: Type.Literal("supported"), + supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }), + }, + { additionalProperties: false, $id: "BrowserSupportSuccess" }, +); + +export type BrowserUnsupportedReason = Static; +export type BrowserSupportRequest = Static; +type SupportedBrand = "Google Chrome" | "Microsoft Edge"; + +export interface BrowserSupportRelease { + appVersion: string; + browsers: ReadonlyArray<{ + brand: SupportedBrand; + fullVersion: string; + }>; +} + +interface BrowserIdentity { + brand: SupportedBrand; + major: number; +} + +type BrowserSupportResult = + | { identity: BrowserIdentity; supported: true } + | { reason: BrowserUnsupportedReason; supported: false }; + +const supportedBrands = new Set(["Google Chrome", "Microsoft Edge"]); +const fullVersionPattern = /^[1-9][0-9]*\.[0-9]+\.[0-9]+\.[0-9]+$/; + +function major(version: string) { + if (!/^[1-9][0-9]*(?:\.[0-9]+)*$/.test(version)) return undefined; + const first = version.split(".")[0]; + if (!first) return undefined; + const value = Number.parseInt(first, 10); + return Number.isSafeInteger(value) ? value : undefined; +} + +function parsedHeaderList(value: string | undefined) { + if (!value) return undefined; + const entries: Array<{ brand: string; version: string }> = []; + const remainder = value.replace(/"([^"]+)"\s*;\s*v="([^"]+)"/g, (_match, brand, version) => { + entries.push({ brand, version }); + return ""; + }); + if (entries.length === 0 || !/^[\s,]*$/.test(remainder)) return undefined; + return entries; +} + +function parsedPlatform(value: string | undefined) { + const match = value?.match(/^"([^"]+)"$/); + return match?.[1]; +} + +function normalizedList(entries: Array<{ brand: string; version: string }>) { + return [...entries] + .sort((left, right) => left.brand.localeCompare(right.brand) || left.version.localeCompare(right.version)) + .map(({ brand, version }) => `${brand}\u0000${version}`) + .join("\u0001"); +} + +function supportedIdentity(entries: Array<{ brand: string; version: string }>) { + const matches = entries.filter(({ brand }) => supportedBrands.has(brand as SupportedBrand)); + if (matches.length !== 1) return undefined; + const entry = matches[0]; + if (!entry) return undefined; + const parsedMajor = major(entry.version); + if (!parsedMajor) return undefined; + return { brand: entry.brand as SupportedBrand, major: parsedMajor }; +} + +export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) { + if (!release) return []; + return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! })); +} + +export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease { + if (!value || typeof value !== "object") return false; + const release = value as BrowserSupportRelease; + if (typeof release.appVersion !== "string" || release.appVersion.length === 0 || release.appVersion.length > 80) { + return false; + } + if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false; + const brands = new Set(release.browsers.map(({ brand }) => brand)); + return ( + brands.size === 2 && + brands.has("Google Chrome") && + brands.has("Microsoft Edge") && + release.browsers.every( + ({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion), + ) + ); +} + +export function readBrowserSupportRelease(path: string) { + try { + const value = JSON.parse(readFileSync(path, "utf8")) as unknown; + return validateBrowserSupportRelease(value) ? value : undefined; + } catch { + return undefined; + } +} + +export function checkBrowserSupport( + body: unknown, + headers: { + secChUa: string | undefined; + secChUaFullVersionList: string | undefined; + secChUaPlatform: string | undefined; + }, + release: BrowserSupportRelease | undefined, +): BrowserSupportResult { + if (!Value.Check(BrowserSupportRequestSchema, body)) { + return { reason: "identity_unavailable", supported: false }; + } + const request = body as BrowserSupportRequest; + const headerBrands = parsedHeaderList(headers.secChUa); + const headerFullVersions = parsedHeaderList(headers.secChUaFullVersionList); + const headerPlatform = parsedPlatform(headers.secChUaPlatform); + if (!headerBrands || !headerFullVersions || !headerPlatform) { + return { reason: "identity_unavailable", supported: false }; + } + if ( + normalizedList(headerBrands) !== normalizedList(request.brands) || + normalizedList(headerFullVersions) !== normalizedList(request.full_version_list) || + headerPlatform !== request.platform + ) { + return { reason: "identity_unavailable", supported: false }; + } + if (request.platform !== "Windows") return { reason: "platform_unsupported", supported: false }; + + const lowIdentity = supportedIdentity(request.brands); + const fullIdentity = supportedIdentity(request.full_version_list); + if (!lowIdentity && request.brands.every(({ brand }) => !supportedBrands.has(brand as SupportedBrand))) { + return { reason: "brand_unsupported", supported: false }; + } + if ( + !lowIdentity || + !fullIdentity || + lowIdentity.brand !== fullIdentity.brand || + lowIdentity.major !== fullIdentity.major + ) { + return { reason: "identity_unavailable", supported: false }; + } + + const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand); + if (!supported || major(supported.fullVersion) !== fullIdentity.major) { + return { reason: "version_unsupported", supported: false }; + } + return { identity: fullIdentity, supported: true }; +} + +function signature(secret: Buffer, encodedPayload: string) { + return createHmac("sha256", secret).update(encodedPayload).digest("base64url"); +} + +export function createBrowserSupportCookie( + secret: Buffer, + release: BrowserSupportRelease, + identity: BrowserIdentity, + issuedAtSeconds = Math.floor(Date.now() / 1000), +) { + const encodedPayload = Buffer.from( + JSON.stringify({ + app_version: release.appVersion, + brand: identity.brand, + issued_at: issuedAtSeconds, + major: identity.major, + }), + ).toString("base64url"); + return `${encodedPayload}.${signature(secret, encodedPayload)}`; +} + +function cookieValue(cookieHeader: string | undefined) { + for (const pair of cookieHeader?.split(";") ?? []) { + const [name, ...rest] = pair.trim().split("="); + if (name === browserSupportCookieName) return rest.join("="); + } + return undefined; +} + +export function verifyBrowserSupportCookie(input: { + cookieHeader: string | undefined; + nowSeconds?: number; + release: BrowserSupportRelease | undefined; + secChUa: string | undefined; + secret: Buffer; +}) { + if (!input.release) return { reason: "version_unsupported" as const, supported: false as const }; + const value = cookieValue(input.cookieHeader); + if (!value) return { reason: "identity_unavailable" as const, supported: false as const }; + const [encodedPayload, encodedSignature, extra] = value.split("."); + if (!encodedPayload || !encodedSignature || extra) { + return { reason: "identity_unavailable" as const, supported: false as const }; + } + const expected = Buffer.from(signature(input.secret, encodedPayload)); + const actual = Buffer.from(encodedSignature); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + return { reason: "identity_unavailable" as const, supported: false as const }; + } + + let payload: { app_version?: unknown; brand?: unknown; issued_at?: unknown; major?: unknown }; + try { + payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")); + } catch { + return { reason: "identity_unavailable" as const, supported: false as const }; + } + const now = input.nowSeconds ?? Math.floor(Date.now() / 1000); + if ( + payload.app_version !== input.release.appVersion || + !supportedBrands.has(payload.brand as SupportedBrand) || + !Number.isSafeInteger(payload.major) || + !Number.isSafeInteger(payload.issued_at) || + (payload.issued_at as number) > now || + now - (payload.issued_at as number) > browserSupportCookieMaxAgeSeconds + ) { + return { reason: "identity_unavailable" as const, supported: false as const }; + } + + const headerBrands = parsedHeaderList(input.secChUa); + const currentIdentity = headerBrands ? supportedIdentity(headerBrands) : undefined; + if (!currentIdentity) return { reason: "identity_unavailable" as const, supported: false as const }; + if (currentIdentity.brand !== payload.brand) { + return { reason: "identity_unavailable" as const, supported: false as const }; + } + const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand); + if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) { + return { reason: "version_unsupported" as const, supported: false as const }; + } + return { identity: currentIdentity, supported: true as const }; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 840da32..dcc6c64 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,10 @@ -import { createApp } from "./app.js"; +import { resolve } from "node:path"; -const app = await createApp(); +import { createApp } from "./app.js"; +import { readBrowserSupportRelease } from "./browser-support.js"; + +const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); +const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {}); await app.listen({ host: "127.0.0.1", diff --git a/apps/api/src/network-boundary.ts b/apps/api/src/network-boundary.ts new file mode 100644 index 0000000..6c631f2 --- /dev/null +++ b/apps/api/src/network-boundary.ts @@ -0,0 +1,27 @@ +export const DADA_LOOPBACK_HOST = "127.0.0.1"; +export const DADA_LOOPBACK_PORT = 43121; + +export interface NetworkBoundaryOptions { + allowTestPort?: boolean; +} + +interface NetworkRequest { + host: string | undefined; + method: string; + origin: string | undefined; +} + +export function isAllowedNetworkRequest( + request: NetworkRequest, + options: NetworkBoundaryOptions = {}, +) { + if (request.method === "OPTIONS" || !request.host) return false; + const allowedHost = options.allowTestPort + ? /^127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(request.host) + : request.host === `${DADA_LOOPBACK_HOST}:${DADA_LOOPBACK_PORT}`; + if (!allowedHost) return false; + + if (request.origin !== undefined && request.origin !== `http://${request.host}`) return false; + if (!["GET", "HEAD"].includes(request.method) && request.origin === undefined) return false; + return true; +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 8dae77d..2248b03 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -2,6 +2,47 @@ export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } +export async function checkBrowserSupport(body: { + "brands": Array<{ + "brand": string; + "version": string; +}>; + "full_version_list": Array<{ + "brand": string; + "version": string; +}>; + "platform": string; +}, options: ClientOptions = {}): Promise<{ + "app_version": string; + "browser": { + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}; + "status": "supported"; + "supported_browsers": Array<{ + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}>; +}> { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/support/check`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise<{ + "app_version": string; + "browser": { + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}; + "status": "supported"; + "supported_browsers": Array<{ + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}>; +}>; +} + export async function getBootstrap(options: ClientOptions = {}): Promise<{ "app_version": string; "dependencies": Array<{ diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 3f515f3..89510eb 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -18,6 +18,33 @@ export type BootstrapResponse = { }>; }; +export type BrowserSupportRequest = { + "brands": Array<{ + "brand": string; + "version": string; +}>; + "full_version_list": Array<{ + "brand": string; + "version": string; +}>; + "platform": string; +}; + +export type BrowserSupportSuccess = { + "app_version": string; + "browser": { + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}; + "status": "supported"; + "supported_browsers": Array<{ + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}>; +}; + +export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable"; + export type CorrelationId = string; export type ErrorDetails = { @@ -28,7 +55,12 @@ export type ErrorDetails = { "message_key": string; }>; "latest_version"?: number | string; + "reason"?: "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable"; "remaining_bytes"?: number; + "supported_browsers"?: Array<{ + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}>; }; export type ErrorEnvelope = { @@ -43,7 +75,12 @@ export type ErrorEnvelope = { "message_key": string; }>; "latest_version"?: number | string; + "reason"?: "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable"; "remaining_bytes"?: number; + "supported_browsers"?: Array<{ + "brand": "Google Chrome" | "Microsoft Edge"; + "major": number; +}>; }; "error_category"?: "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable"; "message_key": string; diff --git a/apps/web/support-gate/index.html b/apps/web/support-gate/index.html new file mode 100644 index 0000000..56f1996 --- /dev/null +++ b/apps/web/support-gate/index.html @@ -0,0 +1,56 @@ + + + + + + 浏览器支持检查 · Dada + + + + +
+
+
DADA
+
BROWSER_UNSUPPORTED
+

当前浏览器无法使用 Dada

+

+ 本次 P0-A 发布仅支持当前这台 Windows 电脑上、已经完成验收的 Chrome 或 Edge + 版本。当前环境无法可靠通过支持检查,因此不会加载任何产品功能。 +

+ +
+
+
+
检测原因
+
identity_unavailable
+
+
+
当前浏览器
+
正在检测
+
+
+
受支持环境
+
等待发布记录
+
+
+
+ +
+ 查看如何在 Chrome / Edge 中打开本机地址 +

+ 请在当前 Windows 电脑上使用本次发布记录支持的 Chrome 或 Edge,打开 + http://127.0.0.1:43121/。 +

+
+ +
+ + +
+

没有“仍然继续”入口,也不会提供绕过令牌或参数。

+
+
+
P0-A · LOCALHOST ONLY · WINDOWS
+ + + diff --git a/apps/web/support-gate/support-gate.css b/apps/web/support-gate/support-gate.css new file mode 100644 index 0000000..86332f1 --- /dev/null +++ b/apps/web/support-gate/support-gate.css @@ -0,0 +1,212 @@ +:root { + color: #111111; + background: #ffffff; + font-family: "Segoe UI", Arial, sans-serif; + font-synthesis: none; + letter-spacing: 0; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + min-height: 100vh; + display: grid; + grid-template-rows: 10px minmax(0, 1fr) 48px; + background: #ffffff; +} + +.top-rule { + background: #eaff00; +} + +main { + display: grid; + place-items: center; + padding: 32px 24px; +} + +.gate { + width: min(700px, 100%); +} + +.wordmark { + margin-bottom: 16px; + font-size: 34px; + font-weight: 900; + line-height: 1; +} + +.code { + display: inline-block; + padding: 6px 9px; + color: #eaff00; + background: #111111; + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +h1 { + margin: 18px 0 12px; + font-size: 38px; + line-height: 1.15; + font-weight: 900; +} + +.description { + margin: 0; + color: #555555; + font-size: 15px; + line-height: 1.7; +} + +.detection { + min-height: 120px; + margin-top: 20px; + padding: 14px 16px; + border: 1px solid #111111; + background: #f5f5f5; +} + +dl { + margin: 0; +} + +dl > div { + min-height: 30px; + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); + gap: 16px; + align-items: center; +} + +dt { + color: #666666; + font-size: 12px; +} + +dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + text-align: right; + font-family: Consolas, monospace; + font-size: 12px; + font-weight: 700; +} + +.danger, +.no-bypass { + color: #b42318; +} + +details { + margin-top: 14px; + border: 1px solid #c7c7c7; + padding: 14px 16px; +} + +summary { + cursor: pointer; + font-size: 13px; + font-weight: 700; +} + +details p { + margin: 12px 0 0; + color: #444444; + font-size: 13px; + line-height: 1.6; +} + +code { + font-family: Consolas, monospace; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +button { + min-width: 170px; + min-height: 44px; + border: 1px solid #111111; + border-radius: 0; + padding: 0 16px; + color: #111111; + background: #eaff00; + font: inherit; + font-size: 13px; + font-weight: 800; + cursor: pointer; +} + +button.secondary { + background: #ffffff; +} + +button:focus-visible, +summary:focus-visible { + outline: 3px solid #0067c0; + outline-offset: 3px; +} + +.no-bypass { + margin: 14px 0 0; + font-size: 12px; + font-weight: 800; +} + +footer { + display: grid; + place-items: center; + color: #eaff00; + background: #111111; + font-family: Consolas, monospace; + font-size: 10px; + font-weight: 700; +} + +@media (max-width: 560px) { + body { + grid-template-rows: 8px auto 48px; + } + + main { + padding: 24px 16px; + } + + h1 { + font-size: 30px; + } + + dl > div { + grid-template-columns: 1fr; + gap: 2px; + padding: 5px 0; + } + + dd { + text-align: left; + } + + .actions, + button { + width: 100%; + } +} diff --git a/apps/web/support-gate/support-gate.js b/apps/web/support-gate/support-gate.js new file mode 100644 index 0000000..259b716 --- /dev/null +++ b/apps/web/support-gate/support-gate.js @@ -0,0 +1,94 @@ +const localAddress = "http://127.0.0.1:43121/"; +const root = document.documentElement; +const gateTitle = document.querySelector("#gate-title"); +const gateDescription = document.querySelector("#gate-description"); +const statusCode = document.querySelector("#status-code"); +const noBypass = document.querySelector("#no-bypass"); +const reasonValue = document.querySelector("#reason-value"); +const browserValue = document.querySelector("#browser-value"); +const supportedValue = document.querySelector("#supported-value"); +const retryButton = document.querySelector("#retry-button"); +const copyButton = document.querySelector("#copy-button"); + +function browserLabel(fullVersionList) { + const known = fullVersionList?.find(({ brand }) => + brand === "Google Chrome" || brand === "Microsoft Edge"); + return known ? `${known.brand} ${known.version.split(".")[0]}` : "无法可靠识别"; +} + +function supportedLabel(supportedBrowsers) { + if (!Array.isArray(supportedBrowsers) || supportedBrowsers.length === 0) return "等待发布记录"; + return supportedBrowsers.map(({ brand, major }) => `${brand} ${major}`).join(" / "); +} + +function showBlocked(reason, supportedBrowsers, fullVersionList) { + root.dataset.supportStatus = "blocked"; + gateTitle.textContent = "当前浏览器无法使用 Dada"; + gateDescription.textContent = "本次 P0-A 发布仅支持当前这台 Windows 电脑上、已经完成验收的 Chrome 或 Edge 版本。当前环境无法可靠通过支持检查,因此不会加载任何产品功能。"; + statusCode.hidden = false; + noBypass.hidden = false; + reasonValue.classList.add("danger"); + reasonValue.textContent = reason; + browserValue.textContent = browserLabel(fullVersionList); + supportedValue.textContent = supportedLabel(supportedBrowsers); +} + +async function checkSupport() { + root.dataset.supportStatus = "checking"; + reasonValue.textContent = "identity_unavailable"; + browserValue.textContent = "正在检测"; + + const userAgentData = navigator.userAgentData; + if (!userAgentData || typeof userAgentData.getHighEntropyValues !== "function") { + showBlocked("identity_unavailable", [], []); + return; + } + + try { + const entropy = await userAgentData.getHighEntropyValues(["fullVersionList", "platform"]); + const body = { + brands: userAgentData.brands ?? [], + full_version_list: entropy.fullVersionList ?? [], + platform: entropy.platform ?? "", + }; + const response = await fetch("/api/v1/support/check", { + body: JSON.stringify(body), + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const result = await response.json(); + if (response.ok && result.status === "supported") { + root.dataset.supportStatus = "supported"; + gateTitle.textContent = "浏览器支持检查已通过"; + gateDescription.textContent = "当前浏览器已通过 Dada 支持检查。"; + statusCode.hidden = true; + noBypass.hidden = true; + reasonValue.classList.remove("danger"); + reasonValue.textContent = "supported"; + browserValue.textContent = `${result.browser.brand} ${result.browser.major}`; + supportedValue.textContent = supportedLabel(result.supported_browsers); + window.dispatchEvent(new CustomEvent("dada:support-ready")); + return; + } + showBlocked( + result.error?.details?.reason ?? "identity_unavailable", + result.error?.details?.supported_browsers ?? [], + body.full_version_list, + ); + } catch { + showBlocked("identity_unavailable", [], []); + } +} + +retryButton.addEventListener("click", () => void checkSupport()); +copyButton.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(localAddress); + copyButton.textContent = "已复制本机地址"; + } catch { + copyButton.textContent = localAddress; + } +}); + +void checkSupport(); diff --git a/openapi/openapi.json b/openapi/openapi.json index 9baa1e7..d3d0b11 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -162,6 +162,179 @@ ], "type": "object" }, + "BrowserSupportRequest": { + "additionalProperties": false, + "properties": { + "brands": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "maxLength": 80, + "type": "string" + }, + "version": { + "maxLength": 80, + "type": "string" + } + }, + "required": [ + "brand", + "version" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "full_version_list": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "maxLength": 80, + "type": "string" + }, + "version": { + "maxLength": 80, + "type": "string" + } + }, + "required": [ + "brand", + "version" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "platform": { + "maxLength": 40, + "type": "string" + } + }, + "required": [ + "brands", + "full_version_list", + "platform" + ], + "type": "object" + }, + "BrowserSupportSuccess": { + "additionalProperties": false, + "properties": { + "app_version": { + "maxLength": 80, + "type": "string" + }, + "browser": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "status": { + "enum": [ + "supported" + ], + "type": "string" + }, + "supported_browsers": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" + } + }, + "required": [ + "app_version", + "browser", + "status", + "supported_browsers" + ], + "type": "object" + }, + "BrowserUnsupportedReason": { + "anyOf": [ + { + "enum": [ + "platform_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "brand_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "version_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "identity_unavailable" + ], + "type": "string" + } + ] + }, "CorrelationId": { "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", "type": "string" @@ -244,9 +417,71 @@ } ] }, + "reason": { + "anyOf": [ + { + "enum": [ + "platform_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "brand_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "version_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "identity_unavailable" + ], + "type": "string" + } + ] + }, "remaining_bytes": { "minimum": 0, "type": "integer" + }, + "supported_browsers": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" } }, "type": "object" @@ -403,9 +638,71 @@ } ] }, + "reason": { + "anyOf": [ + { + "enum": [ + "platform_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "brand_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "version_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "identity_unavailable" + ], + "type": "string" + } + ] + }, "remaining_bytes": { "minimum": 0, "type": "integer" + }, + "supported_browsers": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" } }, "type": "object" @@ -1007,6 +1304,312 @@ } }, "description": "Default Response" + }, + "426": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "enum": [ + "BROWSER_UNSUPPORTED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_CONFIG_VERSION_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_HISTORY_REFERENCE_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_CLEANUP_CANDIDATE_STALE" + ], + "type": "string" + }, + { + "enum": [ + "STORAGE_CAPACITY_EXCEEDED" + ], + "type": "string" + } + ] + }, + "correlation_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "capacity_status": { + "anyOf": [ + { + "enum": [ + "normal" + ], + "type": "string" + }, + { + "enum": [ + "warning" + ], + "type": "string" + }, + { + "enum": [ + "critical" + ], + "type": "string" + }, + { + "enum": [ + "full" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "current_task_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "field_errors": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.\\[\\]-]+$", + "type": "string" + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "field", + "message_key" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + }, + "latest_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "maxLength": 120, + "type": "string" + } + ] + }, + "reason": { + "anyOf": [ + { + "enum": [ + "platform_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "brand_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "version_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "identity_unavailable" + ], + "type": "string" + } + ] + }, + "remaining_bytes": { + "minimum": 0, + "type": "integer" + }, + "supported_browsers": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" + } + }, + "type": "object" + }, + "error_category": { + "anyOf": [ + { + "enum": [ + "upstream_timeout" + ], + "type": "string" + }, + { + "enum": [ + "upstream_failed" + ], + "type": "string" + }, + { + "enum": [ + "safety_rejected" + ], + "type": "string" + }, + { + "enum": [ + "model_disabled" + ], + "type": "string" + }, + { + "enum": [ + "gateway_balance_insufficient" + ], + "type": "string" + }, + { + "enum": [ + "gateway_contract_invalid" + ], + "type": "string" + }, + { + "enum": [ + "reference_invalid" + ], + "type": "string" + }, + { + "enum": [ + "unknown_retryable" + ], + "type": "string" + }, + { + "enum": [ + "unknown_non_retryable" + ], + "type": "string" + } + ] + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "code", + "message_key", + "correlation_id", + "details" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" } }, "tags": [ @@ -1135,12 +1738,793 @@ } }, "description": "Non-sensitive state change hints. REST remains authoritative." + }, + "426": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "enum": [ + "BROWSER_UNSUPPORTED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_CONFIG_VERSION_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_HISTORY_REFERENCE_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_CLEANUP_CANDIDATE_STALE" + ], + "type": "string" + }, + { + "enum": [ + "STORAGE_CAPACITY_EXCEEDED" + ], + "type": "string" + } + ] + }, + "correlation_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "capacity_status": { + "anyOf": [ + { + "enum": [ + "normal" + ], + "type": "string" + }, + { + "enum": [ + "warning" + ], + "type": "string" + }, + { + "enum": [ + "critical" + ], + "type": "string" + }, + { + "enum": [ + "full" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "current_task_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "field_errors": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.\\[\\]-]+$", + "type": "string" + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "field", + "message_key" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + }, + "latest_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "maxLength": 120, + "type": "string" + } + ] + }, + "reason": { + "anyOf": [ + { + "enum": [ + "platform_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "brand_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "version_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "identity_unavailable" + ], + "type": "string" + } + ] + }, + "remaining_bytes": { + "minimum": 0, + "type": "integer" + }, + "supported_browsers": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" + } + }, + "type": "object" + }, + "error_category": { + "anyOf": [ + { + "enum": [ + "upstream_timeout" + ], + "type": "string" + }, + { + "enum": [ + "upstream_failed" + ], + "type": "string" + }, + { + "enum": [ + "safety_rejected" + ], + "type": "string" + }, + { + "enum": [ + "model_disabled" + ], + "type": "string" + }, + { + "enum": [ + "gateway_balance_insufficient" + ], + "type": "string" + }, + { + "enum": [ + "gateway_contract_invalid" + ], + "type": "string" + }, + { + "enum": [ + "reference_invalid" + ], + "type": "string" + }, + { + "enum": [ + "unknown_retryable" + ], + "type": "string" + }, + { + "enum": [ + "unknown_non_retryable" + ], + "type": "string" + } + ] + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "code", + "message_key", + "correlation_id", + "details" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" } }, "tags": [ "State events" ] } + }, + "/api/v1/support/check": { + "post": { + "operationId": "checkBrowserSupport", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "brands": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "maxLength": 80, + "type": "string" + }, + "version": { + "maxLength": 80, + "type": "string" + } + }, + "required": [ + "brand", + "version" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "full_version_list": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "maxLength": 80, + "type": "string" + }, + "version": { + "maxLength": 80, + "type": "string" + } + }, + "required": [ + "brand", + "version" + ], + "type": "object" + }, + "maxItems": 16, + "type": "array" + }, + "platform": { + "maxLength": 40, + "type": "string" + } + }, + "required": [ + "brands", + "full_version_list", + "platform" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "app_version": { + "maxLength": 80, + "type": "string" + }, + "browser": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "status": { + "enum": [ + "supported" + ], + "type": "string" + }, + "supported_browsers": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" + } + }, + "required": [ + "app_version", + "browser", + "status", + "supported_browsers" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "426": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "anyOf": [ + { + "enum": [ + "BROWSER_UNSUPPORTED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_CONFIG_VERSION_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_DEFAULT_REPLACEMENT_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_INVALID" + ], + "type": "string" + }, + { + "enum": [ + "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_HISTORY_REFERENCE_CONFLICT" + ], + "type": "string" + }, + { + "enum": [ + "ASSET_CLEANUP_CANDIDATE_STALE" + ], + "type": "string" + }, + { + "enum": [ + "STORAGE_CAPACITY_EXCEEDED" + ], + "type": "string" + } + ] + }, + "correlation_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "capacity_status": { + "anyOf": [ + { + "enum": [ + "normal" + ], + "type": "string" + }, + { + "enum": [ + "warning" + ], + "type": "string" + }, + { + "enum": [ + "critical" + ], + "type": "string" + }, + { + "enum": [ + "full" + ], + "type": "string" + }, + { + "enum": [ + "unavailable" + ], + "type": "string" + } + ] + }, + "current_task_ref": { + "maxLength": 160, + "pattern": "^[a-z][a-z0-9_]*:[A-Za-z0-9_-]+$", + "type": "string" + }, + "field_errors": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.\\[\\]-]+$", + "type": "string" + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "field", + "message_key" + ], + "type": "object" + }, + "maxItems": 32, + "type": "array" + }, + "latest_version": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "maxLength": 120, + "type": "string" + } + ] + }, + "reason": { + "anyOf": [ + { + "enum": [ + "platform_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "brand_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "version_unsupported" + ], + "type": "string" + }, + { + "enum": [ + "identity_unavailable" + ], + "type": "string" + } + ] + }, + "remaining_bytes": { + "minimum": 0, + "type": "integer" + }, + "supported_browsers": { + "items": { + "additionalProperties": false, + "properties": { + "brand": { + "anyOf": [ + { + "enum": [ + "Google Chrome" + ], + "type": "string" + }, + { + "enum": [ + "Microsoft Edge" + ], + "type": "string" + } + ] + }, + "major": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "brand", + "major" + ], + "type": "object" + }, + "maxItems": 2, + "type": "array" + } + }, + "type": "object" + }, + "error_category": { + "anyOf": [ + { + "enum": [ + "upstream_timeout" + ], + "type": "string" + }, + { + "enum": [ + "upstream_failed" + ], + "type": "string" + }, + { + "enum": [ + "safety_rejected" + ], + "type": "string" + }, + { + "enum": [ + "model_disabled" + ], + "type": "string" + }, + { + "enum": [ + "gateway_balance_insufficient" + ], + "type": "string" + }, + { + "enum": [ + "gateway_contract_invalid" + ], + "type": "string" + }, + { + "enum": [ + "reference_invalid" + ], + "type": "string" + }, + { + "enum": [ + "unknown_retryable" + ], + "type": "string" + }, + { + "enum": [ + "unknown_non_retryable" + ], + "type": "string" + } + ] + }, + "message_key": { + "maxLength": 120, + "pattern": "^[A-Za-z0-9_.-]+$", + "type": "string" + } + }, + "required": [ + "code", + "message_key", + "correlation_id", + "details" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Browser support" + ] + } } } } diff --git a/package.json b/package.json index 7b54494..7432c95 100644 --- a/package.json +++ b/package.json @@ -14,18 +14,19 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", - "test:package": "pnpm run typecheck && pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs", + "test:package": "pnpm run typecheck && pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs", "generate:openapi": "node scripts/generate-openapi.mjs", "check:openapi": "node scripts/check-openapi.mjs", "validate:tdd-trace": "node scripts/validate-tdd-trace.mjs", "validate:external": "node scripts/validate-external.mjs", "test:all": "pnpm test:unit && pnpm test:integration && pnpm test:api && pnpm test:worker && pnpm test:e2e && pnpm test:visual && pnpm test:performance && pnpm test:security && pnpm test:package && pnpm validate:tdd-trace", "test:wp0-01": "node scripts/run-wp0-01-validation.mjs", - "test:wp0-02": "node scripts/run-wp0-02-validation.mjs" + "test:wp0-02": "node scripts/run-wp0-02-validation.mjs", + "test:wp0-03": "node scripts/run-wp0-03-validation.mjs" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/api.ts b/packages/shared-contracts/src/api.ts index 2c70d81..28a399b 100644 --- a/packages/shared-contracts/src/api.ts +++ b/packages/shared-contracts/src/api.ts @@ -50,6 +50,26 @@ export const StableEngineeringErrorCodeSchema = Type.Union( ); export const ErrorDetailsSchema = Type.Object( { + reason: Type.Optional( + Type.Union([ + Type.Literal("platform_unsupported"), + Type.Literal("brand_unsupported"), + Type.Literal("version_unsupported"), + Type.Literal("identity_unavailable"), + ]), + ), + supported_browsers: Type.Optional( + Type.Array( + Type.Object( + { + brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]), + major: Type.Integer({ minimum: 1 }), + }, + { additionalProperties: false }, + ), + { maxItems: 2 }, + ), + ), capacity_status: Type.Optional( Type.Union([ Type.Literal("normal"), diff --git a/playwright.config.ts b/playwright.config.ts index a8c1790..1d44203 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ forbidOnly: true, fullyParallel: false, - outputDir: process.env.DADA_PLAYWRIGHT_OUTPUT_DIR ?? "test-results/wp0-02", + outputDir: process.env.DADA_PLAYWRIGHT_OUTPUT_DIR ?? "test-results/e2e", reporter: "line", retries: 0, testDir: "./tests/e2e", diff --git a/scripts/lib/generate-client.mjs b/scripts/lib/generate-client.mjs index a434b91..afaf0b8 100644 --- a/scripts/lib/generate-client.mjs +++ b/scripts/lib/generate-client.mjs @@ -40,6 +40,11 @@ function operationResult(operation) { return response.schema ? schemaType(response.schema) : "unknown"; } +function operationBodyType(operation) { + const schema = operation.requestBody?.content?.["application/json"]?.schema; + return schema ? schemaType(schema) : undefined; +} + function operationMediaType(operation) { const content = operation.responses?.["200"]?.content; if (!content) return undefined; @@ -68,16 +73,23 @@ export function generateClient(input, output) { ].join("\n\n"); const operationList = operations(document); - const importedTypes = [...new Set(operationList.map(({ operation }) => operationResult(operation)).filter((type) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))]; + const importedTypes = [...new Set(operationList.flatMap(({ operation }) => [ + operationResult(operation), + operationBodyType(operation), + ]).filter((type) => type && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))]; const sdk = [ "// Generated from openapi/openapi.json. Do not edit by hand.", importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "", "export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }", ...operationList.map(({ method, operation, path }) => { const resultType = operationResult(operation); + const bodyType = operationBodyType(operation); if (operationMediaType(operation) === "text/event-stream") { return `export function ${identifier(operation.operationId)}(options: Pick = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`; } + if (bodyType) { + return `export async function ${identifier(operation.operationId)}(body: ${bodyType}, options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const headers = new Headers(options.headers);\n headers.set("Content-Type", "application/json");\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { body: JSON.stringify(body), method: "${method.toUpperCase()}", headers });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`; + } return `export async function ${identifier(operation.operationId)}(options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { method: "${method.toUpperCase()}", headers: options.headers ?? {} });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`; }), "", diff --git a/scripts/loopback-boundary-smoke.mjs b/scripts/loopback-boundary-smoke.mjs new file mode 100644 index 0000000..aa0d6d7 --- /dev/null +++ b/scripts/loopback-boundary-smoke.mjs @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { request } from "node:http"; +import { resolve } from "node:path"; + +const host = "127.0.0.1"; +const port = 43121; + +function sha256(value) { + return createHash("sha256").update(value).digest("hex").toUpperCase(); +} + +function firewallSnapshot() { + return execFileSync( + "netsh", + ["advfirewall", "firewall", "show", "rule", "name=all", "dir=in"], + { encoding: "utf8", windowsHide: true }, + ); +} + +function httpRequest(headers = {}) { + return new Promise((resolveRequest, reject) => { + const outgoing = request({ headers, host, method: "GET", path: "/", port }, (response) => { + response.resume(); + response.once("end", () => resolveRequest({ headers: response.headers, status: response.statusCode })); + }); + outgoing.once("error", reject); + outgoing.end(); + }); +} + +async function waitUntilReady() { + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await httpRequest(); + if (response.status === 200) return response; + } catch { + // The fixed listener may still be starting. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 125)); + } + throw new Error("The fixed loopback listener did not become ready."); +} + +const firewallBefore = firewallSnapshot(); +const child = spawn(process.execPath, ["apps/api/dist/main.js"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, +}); +let stderr = ""; +child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); +}); + +let rootResponse; +let rejectedResponse; +let listeners; +try { + rootResponse = await waitUntilReady(); + rejectedResponse = await httpRequest({ host: "192.168.1.10:43121" }); + const netstat = execFileSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf8", windowsHide: true }); + const matchingListeners = netstat + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)) + .filter((tokens) => tokens[0]?.toUpperCase() === "TCP" && tokens.at(-1) === String(child.pid) && tokens[1]?.endsWith(`:${port}`)) + .map((tokens) => ({ local_address: tokens[1], pid: child.pid })); + listeners = [...new Map(matchingListeners.map((listener) => [listener.local_address, listener])).values()]; +} finally { + child.kill(); + await Promise.race([ + once(child, "exit"), + new Promise((_, reject) => setTimeout(() => reject(new Error("API process did not stop.")), 5000)), + ]); +} + +if (stderr.trim()) throw new Error("The API wrote to stderr during loopback smoke."); +const firewallAfter = firewallSnapshot(); +const expectedListener = `${host}:${port}`; +const socketResult = { + expected: expectedListener, + listeners, + status: listeners.length > 0 && listeners.every(({ local_address }) => local_address === expectedListener) + ? "passed" + : "failed", +}; +const responseResult = { + invalid_host: rejectedResponse, + localhost: rootResponse, + status: rootResponse.status === 200 && rejectedResponse.status === 426 ? "passed" : "failed", +}; +const firewallResult = { + after_sha256: sha256(firewallAfter), + before_sha256: sha256(firewallBefore), + status: firewallAfter === firewallBefore ? "passed" : "failed", + unchanged: firewallAfter === firewallBefore, +}; + +const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_BND; +if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(resolve(evidenceDirectory, "socket-listeners.json"), `${JSON.stringify(socketResult, null, 2)}\n`); + writeFileSync(resolve(evidenceDirectory, "response.json"), `${JSON.stringify(responseResult, null, 2)}\n`); + writeFileSync(resolve(evidenceDirectory, "firewall-diff.json"), `${JSON.stringify(firewallResult, null, 2)}\n`); +} + +const result = { + firewall: firewallResult.status, + responses: responseResult.status, + sockets: socketResult.status, + status: [firewallResult.status, responseResult.status, socketResult.status].every((status) => status === "passed") + ? "passed" + : "failed", +}; +console.log(JSON.stringify(result, null, 2)); +if (result.status !== "passed") process.exit(1); diff --git a/scripts/redaction-scan.mjs b/scripts/redaction-scan.mjs index f2e9dc6..6d9b969 100644 --- a/scripts/redaction-scan.mjs +++ b/scripts/redaction-scan.mjs @@ -2,7 +2,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { extname, join, relative } from "node:path"; const scanRoots = ["apps", "packages", "scripts", "supervisor", "tests"]; -const textExtensions = new Set([".cs", ".json", ".mjs", ".ts", ".tsx", ".yaml", ".yml"]); +const textExtensions = new Set([".cs", ".css", ".html", ".js", ".json", ".mjs", ".ts", ".tsx", ".yaml", ".yml"]); const findings = []; function visit(path) { diff --git a/scripts/run-wp0-03-validation.mjs b/scripts/run-wp0-03-validation.mjs new file mode 100644 index 0000000..9770327 --- /dev/null +++ b/scripts/run-wp0-03-validation.mjs @@ -0,0 +1,242 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; + +const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-03-green-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const boundaryCaseId = "TDD-WP0-BND-001-loopback-origin"; +const supportedCaseId = "TDD-WP0-BRW-001-real-supported"; +const blockedCaseId = "TDD-WP0-BRW-002-hard-block"; +const boundaryDirectory = resolve(runDirectory, "cases", boundaryCaseId); +const supportedDirectory = resolve(runDirectory, "cases", supportedCaseId); +const blockedDirectory = resolve(runDirectory, "cases", blockedCaseId); +const playwrightDirectory = resolve(runDirectory, "playwright"); + +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of [boundaryDirectory, supportedDirectory, blockedDirectory]) { + mkdirSync(directory, { recursive: true }); +} + +const commandDefinitions = [ + { command: "pnpm test:api", args: ["test:api"] }, + { command: "pnpm test:security", args: ["test:security"] }, + { command: "pnpm test:package", args: ["test:package"] }, + { command: "pnpm test:e2e", args: ["test:e2e"] }, + { command: "pnpm validate:tdd-trace", args: ["validate:tdd-trace"] }, +]; +const startedAt = new Date().toISOString(); +const commands = []; + +for (const definition of commandDefinitions) { + const commandStartedAt = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", `pnpm ${definition.args.join(" ")}`] + : definition.args; + const execution = spawnSync(executable, args, { + env: { + ...process.env, + DADA_EVIDENCE_DIR_BND: boundaryDirectory, + DADA_EVIDENCE_DIR_BRW_BLOCKED: blockedDirectory, + DADA_EVIDENCE_DIR_BRW_SUPPORTED: supportedDirectory, + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, + }, + stdio: "inherit", + }); + commands.push({ + command: definition.command, + exit_code: execution.status ?? 1, + finished_at: new Date().toISOString(), + started_at: commandStartedAt, + }); +} + +function findFiles(root, target) { + if (!existsSync(root)) return []; + const files = []; + for (const name of readdirSync(root)) { + const child = resolve(root, name); + if (statSync(child).isDirectory()) files.push(...findFiles(child, target)); + else if (name === target) files.push(child); + } + return files; +} + +for (const trace of findFiles(playwrightDirectory, "trace.zip")) { + const normalized = trace.replaceAll("\\", "/"); + if (normalized.includes("support-gate-a-real-Edge")) { + const target = resolve(supportedDirectory, "edge", "trace.zip"); + mkdirSync(resolve(supportedDirectory, "edge"), { recursive: true }); + copyFileSync(trace, target); + } + if (normalized.includes("support-gate-the-hard-bloc")) { + copyFileSync(trace, resolve(blockedDirectory, "trace.zip")); + } +} + +const chromeDirectory = resolve(supportedDirectory, "chrome"); +mkdirSync(resolve(chromeDirectory, "screenshots"), { recursive: true }); +const chromeCandidates = [ + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", +]; +const chromeInstalled = chromeCandidates.some((path) => existsSync(path)); +writeFileSync( + resolve(chromeDirectory, "environment.json"), + `${JSON.stringify({ browser: "Google Chrome", final_release: false, installed: chromeInstalled, status: "pending_manual" }, null, 2)}\n`, +); +writeFileSync( + resolve(chromeDirectory, "response.json"), + `${JSON.stringify({ reason: "Final RELEASE.json and real Chrome validation belong to WP-7.", status: "not_run" }, null, 2)}\n`, +); + +const commandEvidence = { commands, phase: "green", run_id: runId, schema_version: "1.0" }; +for (const directory of [boundaryDirectory, supportedDirectory, blockedDirectory]) { + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`); +} + +const manifestBytes = readFileSync("tasks.manifest.json"); +const manifest = { + path: "tasks.manifest.json", + sha256: createHash("sha256").update(manifestBytes).digest("hex").toUpperCase(), +}; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const environment = { arch: process.arch, node: process.version.slice(1), os: process.platform }; +const commandsPassed = commands.every(({ exit_code }) => exit_code === 0); + +function writeResult(directory, result) { + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +} + +const boundaryEvidence = ["socket-listeners.json", "response.json", "firewall-diff.json"]; +const boundaryMissing = boundaryEvidence.filter((file) => !existsSync(resolve(boundaryDirectory, file))); +const boundaryResult = writeResult(boundaryDirectory, { + acceptance_criteria: ["AC-24"], + automation: ["automated"], + commit, + environment, + evidence_refs: boundaryEvidence, + finished_at: new Date().toISOString(), + layer: ["API", "PKG-SEC"], + manifest, + missing_evidence: boundaryMissing, + parent_family: "TDD-WP0-BND-001", + phase: "green", + release_gate: ["work_package:WP-0", "release:P0-A"], + requirements: ["NFR-09"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status: commandsPassed && boundaryMissing.length === 0 ? "passed" : "failed", + task_id: "TASK-WP0-03", + test_id: boundaryCaseId, + work_package: "WP-0", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", +}); + +const blockedEvidence = [ + "response.json", + "db-access.json", + "external-calls.json", + "trace.zip", + "screenshots/blocked.png", +]; +const blockedMissing = blockedEvidence.filter((file) => !existsSync(resolve(blockedDirectory, file))); +const blockedResult = writeResult(blockedDirectory, { + acceptance_criteria: ["AC-24"], + automation: ["automated"], + commit, + environment, + evidence_refs: blockedEvidence, + finished_at: new Date().toISOString(), + layer: ["API", "E2E"], + manifest, + missing_evidence: blockedMissing, + parent_family: "TDD-WP0-BRW-002", + phase: "green", + release_gate: ["work_package:WP-0", "release:P0-A"], + requirements: ["NFR-01"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status: commandsPassed && blockedMissing.length === 0 ? "passed" : "failed", + task_id: "TASK-WP0-03", + test_id: blockedCaseId, + work_package: "WP-0", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", +}); + +const supportedAutomatedEvidence = [ + "edge/environment.json", + "edge/response.json", + "edge/trace.zip", + "edge/screenshots/supported.png", +]; +const supportedAutomatedMissing = supportedAutomatedEvidence.filter( + (file) => !existsSync(resolve(supportedDirectory, file)), +); +const supportedExternalMissing = [ + "final/RELEASE.json", + "chrome/trace.zip", + "chrome/screenshots/supported.png", + "final Chrome/Edge AC-24 evidence", +]; +const supportedResult = writeResult(supportedDirectory, { + acceptance_criteria: ["AC-24", "AC-41"], + automation: ["automated", "manual_review"], + automation_status: commandsPassed && supportedAutomatedMissing.length === 0 ? "passed" : "failed", + commit, + environment, + evidence_refs: [ + ...supportedAutomatedEvidence, + "chrome/environment.json", + "chrome/response.json", + ], + external_blockers: [ + "Final RELEASE.json is created only after WP-7 candidate and AC validation.", + "A real installed Chrome full-version run is not available in this workspace.", + "The Edge run uses a test candidate release and cannot replace final release evidence.", + ], + finished_at: new Date().toISOString(), + layer: ["E2E", "MANUAL"], + manifest, + missing_evidence: supportedExternalMissing, + missing_automated_evidence: supportedAutomatedMissing, + parent_family: "TDD-WP0-BRW-001", + phase: "green", + release_gate: ["work_package:WP-0", "release:P0-A"], + requirements: ["NFR-01"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status: commandsPassed && supportedAutomatedMissing.length === 0 ? "externally_blocked" : "failed", + task_id: "TASK-WP0-03", + test_id: supportedCaseId, + work_package: "WP-0", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", +}); + +const automatedPassed = boundaryResult.status === "passed" && blockedResult.status === "passed" && supportedResult.automation_status === "passed"; +const summary = { + cases: [boundaryResult, supportedResult, blockedResult].map(({ missing_evidence, status, test_id }) => ({ + missing_evidence, + status, + test_id, + })), + run_id: runId, + status: automatedPassed ? "green_with_external_block" : "failed", +}; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!automatedPassed) process.exit(1); diff --git a/supervisor/Dada.Supervisor/LoopbackEndpoint.cs b/supervisor/Dada.Supervisor/LoopbackEndpoint.cs new file mode 100644 index 0000000..c87a3da --- /dev/null +++ b/supervisor/Dada.Supervisor/LoopbackEndpoint.cs @@ -0,0 +1,8 @@ +namespace Dada.Supervisor; + +internal static class LoopbackEndpoint +{ + internal const string Host = "127.0.0.1"; + internal const int Port = 43121; + internal static readonly Uri ProductUri = new($"http://{Host}:{Port}/"); +} diff --git a/tests/api/app.test.ts b/tests/api/app.test.ts index 350da8b..bac963d 100644 --- a/tests/api/app.test.ts +++ b/tests/api/app.test.ts @@ -3,10 +3,11 @@ import { describe, expect, it } from "vitest"; import { createApp } from "../../apps/api/src/app.js"; describe("Fastify skeleton", () => { - it("becomes ready without adding a product route", async () => { + it("becomes ready with only the Supervisor health exception", async () => { const app = await createApp(); await app.ready(); - expect(app.printRoutes()).not.toContain("health"); + expect(app.printRoutes()).toContain("healthz"); + expect(app.printRoutes()).not.toContain("api/v1/health"); await app.close(); }); }); diff --git a/tests/api/wp0-02-schema-envelope.test.ts b/tests/api/wp0-02-schema-envelope.test.ts index 291d4e1..dc9e30a 100644 --- a/tests/api/wp0-02-schema-envelope.test.ts +++ b/tests/api/wp0-02-schema-envelope.test.ts @@ -14,11 +14,14 @@ afterAll(async () => { const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_API; if (!evidenceDirectory) return; - const app = await createApp(); + const app = await createApp({ browserGate: false }); await app.ready(); const document = app.swagger(); const response = await app.inject({ - headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" }, + headers: { + host: "127.0.0.1:43121", + "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218", + }, method: "GET", url: "/api/v1/bootstrap", }); @@ -52,7 +55,7 @@ afterAll(async () => { describe("TDD-WP0-API-001 schema envelope", () => { it("generates the committed OpenAPI 3.1 snapshot from runtime schemas", async () => { - const app = await createApp(); + const app = await createApp({ browserGate: false }); await app.ready(); const document = app.swagger(); @@ -79,16 +82,19 @@ describe("TDD-WP0-API-001 schema envelope", () => { }); it("echoes a valid correlation UUID and replaces an invalid one", async () => { - const app = await createApp(); + const app = await createApp({ browserGate: false }); await app.ready(); const accepted = await app.inject({ - headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" }, + headers: { + host: "127.0.0.1:43121", + "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218", + }, method: "GET", url: "/api/v1/bootstrap", }); const replaced = await app.inject({ - headers: { "x-correlation-id": "not-a-uuid" }, + headers: { host: "127.0.0.1:43121", "x-correlation-id": "not-a-uuid" }, method: "GET", url: "/api/v1/bootstrap", }); diff --git a/tests/api/wp0-03-browser-gate.test.ts b/tests/api/wp0-03-browser-gate.test.ts new file mode 100644 index 0000000..259258e --- /dev/null +++ b/tests/api/wp0-03-browser-gate.test.ts @@ -0,0 +1,257 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { afterAll, describe, expect, it, vi } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { + browserSupportFixture, + testBrowserSupportRelease, +} from "../helpers/browser-support-fixture.js"; + +const supportedEdge = browserSupportFixture({ + brand: "Microsoft Edge", + fullVersion: "150.0.4078.99", +}); +const rejectedIdentityCases = [ + { + expectedReason: "platform_unsupported", + fixture: browserSupportFixture({ + brand: "Microsoft Edge", + fullVersion: "150.0.4078.99", + platform: "macOS", + }), + name: "non-Windows platform", + }, + { + expectedReason: "brand_unsupported", + fixture: browserSupportFixture({ brand: "Opera", fullVersion: "150.0.0.0" }), + name: "other brand", + }, + { + expectedReason: "version_unsupported", + fixture: browserSupportFixture({ brand: "Microsoft Edge", fullVersion: "149.0.0.0" }), + name: "wrong major", + }, +] as const; +const unavailableIdentityCases = [ + { mutate: () => ({ headers: { host: "127.0.0.1:43121" }, payload: supportedEdge.body }), name: "missing UA-CH" }, + { + mutate: () => ({ + headers: { ...supportedEdge.headers, "sec-ch-ua-full-version-list": '"broken"' }, + payload: supportedEdge.body, + }), + name: "unparseable UA-CH", + }, + { + mutate: () => ({ + headers: supportedEdge.headers, + payload: { ...supportedEdge.body, platform: "macOS" }, + }), + name: "conflicting UA-CH", + }, +] as const; + +function supportCookie(response: { headers: Record }) { + const header = response.headers["set-cookie"]; + const value = Array.isArray(header) ? header[0] : header; + return value?.split(";", 1)[0]; +} + +afterAll(async () => { + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_BRW_BLOCKED; + if (!evidenceDirectory) return; + + const responses = []; + const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never); + for (const testCase of rejectedIdentityCases) { + const response = await app.inject({ + headers: testCase.fixture.headers, + method: "POST", + payload: testCase.fixture.body, + url: "/api/v1/support/check", + }); + responses.push({ body: response.json(), name: testCase.name, status_code: response.statusCode }); + } + for (const testCase of unavailableIdentityCases) { + const input = testCase.mutate(); + const response = await app.inject({ + headers: input.headers, + method: "POST", + payload: input.payload, + url: "/api/v1/support/check", + }); + responses.push({ body: response.json(), name: testCase.name, status_code: response.statusCode }); + } + + const bootstrap = vi.fn(() => ({ + app_version: "0.0.0", + dependencies: [], + model_summary: { + config_set_version: null, + configured_default_model_id: null, + recommended_model_id: null, + runtime_availability_version: null, + }, + public_features: [], + })); + const rawApp = await createApp({ bootstrap, browserSupportRelease: testBrowserSupportRelease } as never); + const rawResponse = await rawApp.inject({ + headers: { host: "127.0.0.1:43121", "user-agent": "raw-client" }, + method: "GET", + url: "/api/v1/bootstrap", + }); + responses.push({ body: rawResponse.json(), name: "raw product API", status_code: rawResponse.statusCode }); + await rawApp.close(); + await app.close(); + + if (responses.some(({ status_code }) => status_code !== 426) || bootstrap.mock.calls.length !== 0) { + throw new Error("Hard-block evidence did not preserve the 426 zero-business-access boundary."); + } + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync( + resolve(evidenceDirectory, "response.json"), + `${JSON.stringify({ cases: responses, status: "passed" }, null, 2)}\n`, + ); + writeFileSync( + resolve(evidenceDirectory, "db-access.json"), + `${JSON.stringify({ business_provider_calls: 0, reads: 0, status: "passed", writes: 0 }, null, 2)}\n`, + ); + writeFileSync( + resolve(evidenceDirectory, "external-calls.json"), + `${JSON.stringify({ configured_clients: [], observed_calls: 0, status: "passed" }, null, 2)}\n`, + ); +}); + +describe("TDD-WP0-BRW-001 supported browser contract", () => { + it("cross-checks UA-CH and issues only a short-lived signed support cookie", async () => { + const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never); + const checked = await app.inject({ + headers: supportedEdge.headers, + method: "POST", + payload: supportedEdge.body, + url: "/api/v1/support/check", + }); + + expect(checked.statusCode).toBe(200); + expect(checked.json()).toEqual({ + app_version: "1.2.3-test", + browser: { brand: "Microsoft Edge", major: 150 }, + status: "supported", + supported_browsers: [ + { brand: "Google Chrome", major: 150 }, + { brand: "Microsoft Edge", major: 150 }, + ], + }); + expect(checked.headers["set-cookie"]).toContain("dada_browser_support="); + expect(checked.headers["set-cookie"]).toContain("HttpOnly"); + expect(checked.headers["set-cookie"]).toContain("SameSite=Strict"); + expect(checked.headers["set-cookie"]).toContain("Max-Age=86400"); + + const cookie = supportCookie(checked); + expect(cookie).toBeDefined(); + const product = await app.inject({ + headers: { + cookie, + host: "127.0.0.1:43121", + "sec-ch-ua": supportedEdge.headers["sec-ch-ua"], + }, + method: "GET", + url: "/api/v1/bootstrap", + }); + expect(product.statusCode).toBe(200); + await app.close(); + + const restarted = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never); + const staleCookie = await restarted.inject({ + headers: { + cookie, + host: "127.0.0.1:43121", + "sec-ch-ua": supportedEdge.headers["sec-ch-ua"], + }, + method: "GET", + url: "/api/v1/bootstrap", + }); + expect(staleCookie.statusCode).toBe(426); + await restarted.close(); + }); +}); + +describe("TDD-WP0-BRW-002 hard block", () => { + it.each(rejectedIdentityCases)("returns 426 for $name", async ({ expectedReason, fixture }) => { + const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never); + const response = await app.inject({ + headers: fixture.headers, + method: "POST", + payload: fixture.body, + url: "/api/v1/support/check", + }); + + expect(response.statusCode).toBe(426); + expect(response.json()).toMatchObject({ + error: { + code: "BROWSER_UNSUPPORTED", + details: { + reason: expectedReason, + supported_browsers: [ + { brand: "Google Chrome", major: 150 }, + { brand: "Microsoft Edge", major: 150 }, + ], + }, + message_key: "browser.unsupported", + }, + }); + expect(response.body).not.toContain("Opera"); + await app.close(); + }); + + it.each(unavailableIdentityCases)("fails closed for $name", async ({ mutate }) => { + const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never); + const input = mutate(); + const response = await app.inject({ + headers: input.headers, + method: "POST", + payload: input.payload, + url: "/api/v1/support/check", + }); + + expect(response.statusCode).toBe(426); + expect(response.json()).toMatchObject({ + error: { details: { reason: "identity_unavailable" } }, + }); + await app.close(); + }); + + it("blocks raw product API access before any business provider runs", async () => { + const bootstrap = vi.fn(() => ({ + app_version: "0.0.0", + dependencies: [], + model_summary: { + config_set_version: null, + configured_default_model_id: null, + recommended_model_id: null, + runtime_availability_version: null, + }, + public_features: [], + })); + const app = await createApp({ + bootstrap, + browserSupportRelease: testBrowserSupportRelease, + } as never); + const response = await app.inject({ + headers: { host: "127.0.0.1:43121", "user-agent": "Mozilla/5.0 Chrome/150" }, + method: "GET", + url: "/api/v1/bootstrap", + }); + + expect(response.statusCode).toBe(426); + expect(response.json()).toMatchObject({ + error: { + code: "BROWSER_UNSUPPORTED", + details: { reason: "identity_unavailable" }, + }, + }); + expect(bootstrap).not.toHaveBeenCalled(); + await app.close(); + }); +}); diff --git a/tests/api/wp0-03-loopback-origin.test.ts b/tests/api/wp0-03-loopback-origin.test.ts new file mode 100644 index 0000000..e5f07ad --- /dev/null +++ b/tests/api/wp0-03-loopback-origin.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; + +describe("TDD-WP0-BND-001 loopback origin", () => { + it("serves only the minimal gate shell on the fixed loopback authority", async () => { + const app = await createApp(); + const response = await app.inject({ + headers: { host: "127.0.0.1:43121" }, + method: "GET", + url: "/", + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["accept-ch"]).toContain("Sec-CH-UA-Full-Version-List"); + expect(response.headers["content-security-policy"]).toContain("default-src 'self'"); + expect(response.body).toContain("当前浏览器无法使用 Dada"); + expect(response.body).not.toContain("/src/main.tsx"); + expect(response.body).toContain("没有“仍然继续”入口,也不会提供绕过令牌或参数。"); + expect(response.body).not.toMatch(/<(?:a|button)[^>]*>[^<]*仍然继续/u); + await app.close(); + }); + + it.each([ + { + headers: { host: "192.168.1.10:43121" }, + method: "GET" as const, + name: "LAN Host", + url: "/", + }, + { + headers: { + host: "127.0.0.1:43121", + origin: "http://attacker.invalid", + }, + method: "POST" as const, + name: "foreign Origin", + url: "/api/v1/support/check", + }, + { + headers: { + "access-control-request-method": "POST", + host: "127.0.0.1:43121", + origin: "http://attacker.invalid", + }, + method: "OPTIONS" as const, + name: "CORS preflight", + url: "/api/v1/support/check", + }, + ])("rejects $name without a CORS grant", async ({ headers, method, url }) => { + const app = await createApp(); + const response = await app.inject({ headers, method, url }); + + expect(response.statusCode).toBe(426); + expect(response.headers).not.toHaveProperty("access-control-allow-origin"); + expect(response.json()).toMatchObject({ + error: { + code: "BROWSER_UNSUPPORTED", + details: { reason: "identity_unavailable" }, + message_key: "browser.unsupported", + }, + }); + await app.close(); + }); + + it("keeps the production and Supervisor endpoint fixed", () => { + const main = readFileSync("apps/api/src/main.ts", "utf8"); + const supervisor = readFileSync("supervisor/Dada.Supervisor/LoopbackEndpoint.cs", "utf8"); + + expect(main).toContain('host: "127.0.0.1"'); + expect(main).toContain("port: 43121"); + expect(supervisor).toContain('Host = "127.0.0.1"'); + expect(supervisor).toContain("Port = 43121"); + expect(supervisor).not.toContain("0.0.0.0"); + }); +}); diff --git a/tests/e2e/event-sync.spec.ts b/tests/e2e/event-sync.spec.ts index 76e8d3e..31f09d1 100644 --- a/tests/e2e/event-sync.spec.ts +++ b/tests/e2e/event-sync.spec.ts @@ -13,7 +13,11 @@ let vite: ViteDevServer; let webUrl: string; test.beforeAll(async () => { - app = await createApp({ eventHub }); + app = await createApp({ + browserGate: false, + eventHub, + networkBoundary: { allowTestPort: true }, + }); app.get("/__test/entity", async (request) => ({ entity_ref: (request.query as { ref?: string }).ref ?? null })); app.get("/__test/models", async () => ({ source: "rest" })); const apiUrl = await app.listen({ host: "127.0.0.1", port: 0 }); diff --git a/tests/e2e/support-gate.spec.ts b/tests/e2e/support-gate.spec.ts new file mode 100644 index 0000000..09900e8 --- /dev/null +++ b/tests/e2e/support-gate.spec.ts @@ -0,0 +1,101 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; + +import { createApp } from "../../apps/api/src/app.js"; + +test("a real Edge profile passes the candidate support flow", async ({ browser, context, page }) => { + const fullVersion = browser.version(); + const release = { + appVersion: "1.2.3-candidate", + browsers: [ + { brand: "Google Chrome", fullVersion: "150.0.0.0" }, + { brand: "Microsoft Edge", fullVersion }, + ], + } as const; + const app = await createApp({ + browserSupportRelease: release, + networkBoundary: { allowTestPort: true }, + } as never); + const url = await app.listen({ host: "127.0.0.1", port: 0 }); + + try { + await page.goto(url); + await expect.poll(() => page.locator("html").getAttribute("data-support-status")).toBe("supported"); + await expect(page.locator("h1")).toHaveText("浏览器支持检查已通过"); + await expect(page.locator("#status-code")).toBeHidden(); + await expect(page.locator("#no-bypass")).toBeHidden(); + await expect(page.locator("#supported-value")).toContainText("Google Chrome"); + await expect(page.locator("#supported-value")).toContainText("Microsoft Edge"); + const bootstrapStatus = await page.evaluate(async () => (await fetch("/api/v1/bootstrap")).status); + expect(bootstrapStatus).toBe(200); + + const cookie = (await context.cookies()).find(({ name }) => name === "dada_browser_support"); + expect(cookie).toMatchObject({ httpOnly: true, sameSite: "Strict" }); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_BRW_SUPPORTED; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "edge", "screenshots"), { recursive: true }); + await page.screenshot({ + path: resolve(evidenceDirectory, "edge", "screenshots", "supported.png"), + }); + writeFileSync( + resolve(evidenceDirectory, "edge", "environment.json"), + `${JSON.stringify({ browser: "Microsoft Edge", full_version: fullVersion, release_kind: "test_candidate", status: "partial" }, null, 2)}\n`, + ); + writeFileSync( + resolve(evidenceDirectory, "edge", "response.json"), + `${JSON.stringify({ bootstrap_status: bootstrapStatus, cookie_http_only: cookie?.httpOnly, status: "passed" }, null, 2)}\n`, + ); + } + } finally { + await app.close(); + } +}); + +test("the hard-block shell is usable without a product bundle", async ({ browser, page }) => { + const currentMajor = Number.parseInt(browser.version().split(".")[0], 10); + const app = await createApp({ + browserSupportRelease: { + appVersion: "1.2.3-test", + browsers: [ + { brand: "Google Chrome", fullVersion: `${currentMajor + 1}.0.0.0` }, + { brand: "Microsoft Edge", fullVersion: `${currentMajor + 1}.0.0.0` }, + ], + }, + networkBoundary: { allowTestPort: true }, + } as never); + const url = await app.listen({ host: "127.0.0.1", port: 0 }); + + try { + await page.goto(url); + await expect.poll(() => page.locator("html").getAttribute("data-support-status")).toBe("blocked"); + await expect(page.locator("#reason-value")).toHaveText("version_unsupported"); + await expect(page.getByText("没有“仍然继续”入口,也不会提供绕过令牌或参数。")).toBeVisible(); + await expect(page.locator('script[src*="/src/main"]')).toHaveCount(0); + await page.keyboard.press("Tab"); + await expect(page.locator("h1")).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.locator("summary")).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "重新检测" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "复制本机地址" })).toBeFocused(); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_BRW_BLOCKED; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "blocked.png") }); + } + + await page.setViewportSize({ height: 844, width: 390 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await expect(page.getByRole("button", { name: "重新检测" })).toBeVisible(); + if (evidenceDirectory) { + await page.screenshot({ path: resolve(evidenceDirectory, "screenshots", "blocked-mobile.png") }); + } + } finally { + await app.close(); + } +}); diff --git a/tests/helpers/browser-support-fixture.ts b/tests/helpers/browser-support-fixture.ts new file mode 100644 index 0000000..61e0ce9 --- /dev/null +++ b/tests/helpers/browser-support-fixture.ts @@ -0,0 +1,49 @@ +export const testBrowserSupportRelease = { + appVersion: "1.2.3-test", + browsers: [ + { brand: "Google Chrome", fullVersion: "150.0.7339.1" }, + { brand: "Microsoft Edge", fullVersion: "150.0.4078.99" }, + ], +} as const; + +interface BrowserIdentityFixture { + brand: string; + fullVersion: string; + platform?: string; +} + +function major(version: string) { + return version.split(".")[0]; +} + +function serialize(values: Array<{ brand: string; version: string }>) { + return values.map(({ brand, version }) => `"${brand}";v="${version}"`).join(", "); +} + +export function browserSupportFixture(input: BrowserIdentityFixture) { + const platform = input.platform ?? "Windows"; + const brands = [ + { brand: "Not_A Brand", version: "99" }, + { brand: "Chromium", version: major(input.fullVersion) }, + { brand: input.brand, version: major(input.fullVersion) }, + ]; + const fullVersionList = [ + { brand: "Not_A Brand", version: "99.0.0.0" }, + { brand: "Chromium", version: input.fullVersion }, + { brand: input.brand, version: input.fullVersion }, + ]; + return { + body: { + brands, + full_version_list: fullVersionList, + platform, + }, + headers: { + host: "127.0.0.1:43121", + origin: "http://127.0.0.1:43121", + "sec-ch-ua": serialize(brands), + "sec-ch-ua-full-version-list": serialize(fullVersionList), + "sec-ch-ua-platform": `"${platform}"`, + }, + }; +}