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 {
|
import {
|
||||||
BootstrapResponseSchema,
|
BootstrapResponseSchema,
|
||||||
@@ -11,13 +13,28 @@ import {
|
|||||||
SseEventSchema,
|
SseEventSchema,
|
||||||
StableEngineeringErrorCodeSchema,
|
StableEngineeringErrorCodeSchema,
|
||||||
StateSseEventSchema,
|
StateSseEventSchema,
|
||||||
|
createErrorEnvelope,
|
||||||
isCorrelationId,
|
isCorrelationId,
|
||||||
type BootstrapResponse,
|
type BootstrapResponse,
|
||||||
} from "@dada/shared-contracts";
|
} from "@dada/shared-contracts";
|
||||||
import swagger from "@fastify/swagger";
|
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 { EventHub } from "./event-hub.js";
|
||||||
|
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||||
|
|
||||||
const defaultBootstrap: BootstrapResponse = {
|
const defaultBootstrap: BootstrapResponse = {
|
||||||
app_version: "0.0.0",
|
app_version: "0.0.0",
|
||||||
@@ -33,18 +50,80 @@ const defaultBootstrap: BootstrapResponse = {
|
|||||||
|
|
||||||
export interface CreateAppOptions {
|
export interface CreateAppOptions {
|
||||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||||
|
browserGate?: boolean;
|
||||||
|
browserSupportRelease?: BrowserSupportRelease;
|
||||||
|
browserSupportSecret?: Buffer;
|
||||||
eventHub?: EventHub;
|
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>) {
|
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
||||||
const header = headers["x-correlation-id"];
|
const header = headers["x-correlation-id"];
|
||||||
const candidate = Array.isArray(header) ? header[0] : header;
|
const candidate = Array.isArray(header) ? header[0] : header;
|
||||||
return isCorrelationId(candidate) ? candidate : randomUUID();
|
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 = {}) {
|
export async function createApp(options: CreateAppOptions = {}) {
|
||||||
const eventHub = options.eventHub ?? new EventHub();
|
const eventHub = options.eventHub ?? new EventHub();
|
||||||
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
|
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
|
||||||
|
const browserGate = options.browserGate ?? true;
|
||||||
|
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
||||||
|
const browserSupportRelease = options.browserSupportRelease;
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
genReqId: (request) => requestCorrelationId(request.headers),
|
genReqId: (request) => requestCorrelationId(request.headers),
|
||||||
logger: false,
|
logger: false,
|
||||||
@@ -70,6 +149,9 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
StableEngineeringErrorCodeSchema,
|
StableEngineeringErrorCodeSchema,
|
||||||
ErrorDetailsSchema,
|
ErrorDetailsSchema,
|
||||||
ErrorEnvelopeSchema,
|
ErrorEnvelopeSchema,
|
||||||
|
BrowserUnsupportedReasonSchema,
|
||||||
|
BrowserSupportRequestSchema,
|
||||||
|
BrowserSupportSuccessSchema,
|
||||||
BootstrapResponseSchema,
|
BootstrapResponseSchema,
|
||||||
StateSseEventSchema,
|
StateSseEventSchema,
|
||||||
ModelConfigSseEventSchema,
|
ModelConfigSseEventSchema,
|
||||||
@@ -79,10 +161,106 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
app.addSchema(schema);
|
app.addSchema(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.addHook("onRequest", (request, reply, done) => {
|
app.addHook("onRequest", async (request, reply) => {
|
||||||
reply.header("X-Correlation-Id", request.id);
|
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(
|
app.get(
|
||||||
"/api/v1/bootstrap",
|
"/api/v1/bootstrap",
|
||||||
@@ -91,6 +269,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
operationId: "getBootstrap",
|
operationId: "getBootstrap",
|
||||||
response: {
|
response: {
|
||||||
200: BootstrapResponseSchema,
|
200: BootstrapResponseSchema,
|
||||||
|
426: ErrorEnvelopeSchema,
|
||||||
},
|
},
|
||||||
tags: ["Bootstrap"],
|
tags: ["Bootstrap"],
|
||||||
},
|
},
|
||||||
@@ -112,6 +291,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
},
|
},
|
||||||
description: "Non-sensitive state change hints. REST remains authoritative.",
|
description: "Non-sensitive state change hints. REST remains authoritative.",
|
||||||
},
|
},
|
||||||
|
426: ErrorEnvelopeSchema,
|
||||||
},
|
},
|
||||||
tags: ["State events"],
|
tags: ["State events"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<typeof BrowserUnsupportedReasonSchema>;
|
||||||
|
export type BrowserSupportRequest = Static<typeof BrowserSupportRequestSchema>;
|
||||||
|
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<SupportedBrand>(["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 };
|
||||||
|
}
|
||||||
@@ -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({
|
await app.listen({
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,47 @@
|
|||||||
|
|
||||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
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<{
|
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||||
"app_version": string;
|
"app_version": string;
|
||||||
"dependencies": Array<{
|
"dependencies": Array<{
|
||||||
|
|||||||
@@ -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 CorrelationId = string;
|
||||||
|
|
||||||
export type ErrorDetails = {
|
export type ErrorDetails = {
|
||||||
@@ -28,7 +55,12 @@ export type ErrorDetails = {
|
|||||||
"message_key": string;
|
"message_key": string;
|
||||||
}>;
|
}>;
|
||||||
"latest_version"?: number | string;
|
"latest_version"?: number | string;
|
||||||
|
"reason"?: "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
|
||||||
"remaining_bytes"?: number;
|
"remaining_bytes"?: number;
|
||||||
|
"supported_browsers"?: Array<{
|
||||||
|
"brand": "Google Chrome" | "Microsoft Edge";
|
||||||
|
"major": number;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ErrorEnvelope = {
|
export type ErrorEnvelope = {
|
||||||
@@ -43,7 +75,12 @@ export type ErrorEnvelope = {
|
|||||||
"message_key": string;
|
"message_key": string;
|
||||||
}>;
|
}>;
|
||||||
"latest_version"?: number | string;
|
"latest_version"?: number | string;
|
||||||
|
"reason"?: "platform_unsupported" | "brand_unsupported" | "version_unsupported" | "identity_unavailable";
|
||||||
"remaining_bytes"?: number;
|
"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";
|
"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;
|
"message_key": string;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" data-support-status="checking">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>浏览器支持检查 · Dada</title>
|
||||||
|
<link rel="stylesheet" href="/support-gate.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top-rule" aria-hidden="true"></div>
|
||||||
|
<main>
|
||||||
|
<section class="gate" aria-labelledby="gate-title">
|
||||||
|
<div class="wordmark">DADA</div>
|
||||||
|
<div id="status-code" class="code">BROWSER_UNSUPPORTED</div>
|
||||||
|
<h1 id="gate-title" tabindex="0">当前浏览器无法使用 Dada</h1>
|
||||||
|
<p id="gate-description" class="description">
|
||||||
|
本次 P0-A 发布仅支持当前这台 Windows 电脑上、已经完成验收的 Chrome 或 Edge
|
||||||
|
版本。当前环境无法可靠通过支持检查,因此不会加载任何产品功能。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="detection" role="status" aria-live="assertive" aria-atomic="true">
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>检测原因</dt>
|
||||||
|
<dd id="reason-value" class="danger">identity_unavailable</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>当前浏览器</dt>
|
||||||
|
<dd id="browser-value">正在检测</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>受支持环境</dt>
|
||||||
|
<dd id="supported-value">等待发布记录</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>查看如何在 Chrome / Edge 中打开本机地址</summary>
|
||||||
|
<p>
|
||||||
|
请在当前 Windows 电脑上使用本次发布记录支持的 Chrome 或 Edge,打开
|
||||||
|
<code>http://127.0.0.1:43121/</code>。
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="retry-button" type="button">重新检测</button>
|
||||||
|
<button id="copy-button" class="secondary" type="button">复制本机地址</button>
|
||||||
|
</div>
|
||||||
|
<p id="no-bypass" class="no-bypass">没有“仍然继续”入口,也不会提供绕过令牌或参数。</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer>P0-A · LOCALHOST ONLY · WINDOWS</footer>
|
||||||
|
<script type="module" src="/support-gate.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -14,18 +14,19 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs",
|
"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:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"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",
|
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||||
"check:openapi": "node scripts/check-openapi.mjs",
|
"check:openapi": "node scripts/check-openapi.mjs",
|
||||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||||
"validate:external": "node scripts/validate-external.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: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-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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ export const StableEngineeringErrorCodeSchema = Type.Union(
|
|||||||
);
|
);
|
||||||
export const ErrorDetailsSchema = Type.Object(
|
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(
|
capacity_status: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("normal"),
|
Type.Literal("normal"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
forbidOnly: true,
|
forbidOnly: true,
|
||||||
fullyParallel: false,
|
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",
|
reporter: "line",
|
||||||
retries: 0,
|
retries: 0,
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ function operationResult(operation) {
|
|||||||
return response.schema ? schemaType(response.schema) : "unknown";
|
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) {
|
function operationMediaType(operation) {
|
||||||
const content = operation.responses?.["200"]?.content;
|
const content = operation.responses?.["200"]?.content;
|
||||||
if (!content) return undefined;
|
if (!content) return undefined;
|
||||||
@@ -68,16 +73,23 @@ export function generateClient(input, output) {
|
|||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
|
|
||||||
const operationList = operations(document);
|
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 = [
|
const sdk = [
|
||||||
"// Generated from openapi/openapi.json. Do not edit by hand.",
|
"// Generated from openapi/openapi.json. Do not edit by hand.",
|
||||||
importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "",
|
importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "",
|
||||||
"export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }",
|
"export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }",
|
||||||
...operationList.map(({ method, operation, path }) => {
|
...operationList.map(({ method, operation, path }) => {
|
||||||
const resultType = operationResult(operation);
|
const resultType = operationResult(operation);
|
||||||
|
const bodyType = operationBodyType(operation);
|
||||||
if (operationMediaType(operation) === "text/event-stream") {
|
if (operationMediaType(operation) === "text/event-stream") {
|
||||||
return `export function ${identifier(operation.operationId)}(options: Pick<ClientOptions, "baseUrl"> = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`;
|
return `export function ${identifier(operation.operationId)}(options: Pick<ClientOptions, "baseUrl"> = {}): 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}`;
|
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}`;
|
||||||
}),
|
}),
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|||||||
import { extname, join, relative } from "node:path";
|
import { extname, join, relative } from "node:path";
|
||||||
|
|
||||||
const scanRoots = ["apps", "packages", "scripts", "supervisor", "tests"];
|
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 = [];
|
const findings = [];
|
||||||
|
|
||||||
function visit(path) {
|
function visit(path) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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}/");
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { createApp } from "../../apps/api/src/app.js";
|
import { createApp } from "../../apps/api/src/app.js";
|
||||||
|
|
||||||
describe("Fastify skeleton", () => {
|
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();
|
const app = await createApp();
|
||||||
await app.ready();
|
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();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ afterAll(async () => {
|
|||||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_API;
|
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_API;
|
||||||
if (!evidenceDirectory) return;
|
if (!evidenceDirectory) return;
|
||||||
|
|
||||||
const app = await createApp();
|
const app = await createApp({ browserGate: false });
|
||||||
await app.ready();
|
await app.ready();
|
||||||
const document = app.swagger();
|
const document = app.swagger();
|
||||||
const response = await app.inject({
|
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",
|
method: "GET",
|
||||||
url: "/api/v1/bootstrap",
|
url: "/api/v1/bootstrap",
|
||||||
});
|
});
|
||||||
@@ -52,7 +55,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
describe("TDD-WP0-API-001 schema envelope", () => {
|
describe("TDD-WP0-API-001 schema envelope", () => {
|
||||||
it("generates the committed OpenAPI 3.1 snapshot from runtime schemas", async () => {
|
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();
|
await app.ready();
|
||||||
|
|
||||||
const document = app.swagger();
|
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 () => {
|
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();
|
await app.ready();
|
||||||
|
|
||||||
const accepted = await app.inject({
|
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",
|
method: "GET",
|
||||||
url: "/api/v1/bootstrap",
|
url: "/api/v1/bootstrap",
|
||||||
});
|
});
|
||||||
const replaced = await app.inject({
|
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",
|
method: "GET",
|
||||||
url: "/api/v1/bootstrap",
|
url: "/api/v1/bootstrap",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, string | string[] | undefined> }) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,11 @@ let vite: ViteDevServer;
|
|||||||
let webUrl: string;
|
let webUrl: string;
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
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/entity", async (request) => ({ entity_ref: (request.query as { ref?: string }).ref ?? null }));
|
||||||
app.get("/__test/models", async () => ({ source: "rest" }));
|
app.get("/__test/models", async () => ({ source: "rest" }));
|
||||||
const apiUrl = await app.listen({ host: "127.0.0.1", port: 0 });
|
const apiUrl = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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}"`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user