diff --git a/.changeset/api-client-rate-limit-retry.md b/.changeset/api-client-rate-limit-retry.md new file mode 100644 index 00000000..13ae9bab --- /dev/null +++ b/.changeset/api-client-rate-limit-retry.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +The API client retries rate-limited (429) responses up to three times, honoring the server's `Retry-After` (capped at 2s) before failing — transient edge saturation self-heals in queries and the session check instead of surfacing errors. diff --git a/.changeset/better-near-auth-error-formatting.md b/.changeset/better-near-auth-error-formatting.md new file mode 100644 index 00000000..9415aee3 --- /dev/null +++ b/.changeset/better-near-auth-error-formatting.md @@ -0,0 +1,5 @@ +--- +"better-near-auth": patch +--- + +Stringify unknown throwables safely in plugin error messages. diff --git a/.changeset/every-plugin-error-formatting.md b/.changeset/every-plugin-error-formatting.md new file mode 100644 index 00000000..d6d92eae --- /dev/null +++ b/.changeset/every-plugin-error-formatting.md @@ -0,0 +1,5 @@ +--- +"every-plugin": patch +--- + +Harden error rendering: unknown throwables (symbols, objects, unserializable values) stringify safely in runtime error messages and plugin-load logs instead of throwing or printing `[object Object]`. diff --git a/.changeset/host-rate-limit-health.md b/.changeset/host-rate-limit-health.md new file mode 100644 index 00000000..105942b2 --- /dev/null +++ b/.changeset/host-rate-limit-health.md @@ -0,0 +1,11 @@ +--- +"host": minor +--- + +Retune the edge rate limiter for practical use: + +- General budget raised from 300 to **2000 requests / 15 min** per client — normal browsing (page navigations, API calls, SSR renders) no longer brushes the cap, especially behind shared NAT/VPN egress. +- New **mutation tier** (60 / 15 min, `RATE_LIMIT_MUTATION_MAX`) for non-GET requests outside the oRPC (`/api/rpc/`), Better-Auth (`/api/auth/`), and MCP (`/api/mcp`) transports — brute-force protection on direct REST writes without touching sessions, procedures, or agents (those layers own their throttling). +- `x-forwarded-for` keying now trusts only the **last** hop (the address our proxy appended) — a client can no longer mint unlimited keys by rotating a spoofed first hop. +- A rate-limited **document navigation** now returns a styled HTML retry page instead of raw JSON, so a saturated browser session sees a "please wait" screen, not a broken site. API requests keep the JSON body and `Retry-After`. +- `GET/HEAD /health` is exempt from both tiers — infrastructure probes never consume a client's budget. diff --git a/.changeset/locale-negotiation-ssr.md b/.changeset/locale-negotiation-ssr.md new file mode 100644 index 00000000..eb7bbfd0 --- /dev/null +++ b/.changeset/locale-negotiation-ssr.md @@ -0,0 +1,5 @@ +--- +"everything-dev": minor +--- + +Deepen i18n: locale negotiation from `citynode_locale` cookie + `Accept-Language` (with q-values) during SSR, `Vary: Cookie, Accept-Language` on localized responses, request-scoped locale in router context, a shared locale selection across the main UI and auth bundle runtimes, and localized router-error/API-connection/tenant-validation copy. diff --git a/.changeset/publish-pin-verification-gate.md b/.changeset/publish-pin-verification-gate.md new file mode 100644 index 00000000..cf7d8521 --- /dev/null +++ b/.changeset/publish-pin-verification-gate.md @@ -0,0 +1,5 @@ +--- +"everything-dev": minor +--- + +Publish gate: before writing to the registry, every pinned slot's bytes are fetched from its production URL and checked against the pin's SRI. A publish whose pinned bytes are missing or mismatched is blocked instead of silently republishing broken pointers. diff --git a/.changeset/session-query-recovery.md b/.changeset/session-query-recovery.md new file mode 100644 index 00000000..81a98a07 --- /dev/null +++ b/.changeset/session-query-recovery.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +A failing `getSession` request no longer caches `null` as "signed out" — the session query errors instead, so a transient auth-service outage can't sign the UI out client-side. diff --git a/.changeset/staging-input-schema.md b/.changeset/staging-input-schema.md new file mode 100644 index 00000000..f06c95d2 --- /dev/null +++ b/.changeset/staging-input-schema.md @@ -0,0 +1,5 @@ +--- +"everything-dev": patch +--- + +Parse the authored `staging` block through `BosConfigInputSchema` — previously the interface accepted it but the schema dropped it during validation. diff --git a/host/README.md b/host/README.md index bdedfd18..bda7e48a 100644 --- a/host/README.md +++ b/host/README.md @@ -38,7 +38,7 @@ On top of that fixed server core, the host now supports request-scoped tenant UI - tenant config must extend the base BOS runtime - tenant requests may override UI-facing remotes and sidebar metadata without changing the server core -For full host/plugin/auth/api hot-swap, see `docs/plans/` for design docs. That is still a larger future design than the fixed-core tenant mode implemented now. +For full host/plugin/auth/api hot-swap, see `plans/` for design docs. That is still a larger future design than the fixed-core tenant mode implemented now. ## Development diff --git a/host/server.ts b/host/server.ts index 9e05e982..8ff47422 100644 --- a/host/server.ts +++ b/host/server.ts @@ -34,4 +34,4 @@ try { // server boots so SSR container loads and any config fetches route through it. installBundleFetchFromEnv({}); -runServerBlocking({ config }); +await runServerBlocking({ config }); diff --git a/host/src/middleware/security.ts b/host/src/middleware/security.ts index f2f16f2d..fd07be35 100644 --- a/host/src/middleware/security.ts +++ b/host/src/middleware/security.ts @@ -1,6 +1,6 @@ import { getConnInfo } from "@hono/node-server/conninfo"; import { Config, Context, Effect, Layer } from "effect"; -import type { MiddlewareHandler } from "hono"; +import type { Context as HonoContext, MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; import { NONCE, secureHeaders } from "hono/secure-headers"; import { rateLimiter } from "hono-rate-limiter"; @@ -9,7 +9,8 @@ import type { RuntimePlugin } from "../types"; import { logger } from "../utils/logger"; export const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS) || 900_000; -export const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX) || 300; +export const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX) || 2000; +export const RATE_LIMIT_MUTATION_MAX = Number(process.env.RATE_LIMIT_MUTATION_MAX) || 60; export const BODY_LIMIT_MAX = Number(process.env.BODY_LIMIT_MAX) || 10 * 1024 * 1024; export const API_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS) || 30_000; @@ -31,6 +32,89 @@ export function storageUploadTimeoutMs(): number { export const STATIC_ASSET_PATTERN = /\.(js|css|png|jpg|jpeg|gif|svg|ico|json|md|webmanifest|woff2?|ttf|eot|webp|avif|map|txt|xml)$/i; +/** + * Edge-limited transports that own their throttling: oRPC (all procedure + * calls are POSTs — a method-based mutation tier would collapse the whole + * API into the strict bucket), Better-Auth (its core limiter is enabled in + * production), and MCP (agents are legitimately chatty). + */ +const SENSITIVE_MUTATION_EXEMPT_PREFIXES = ["/api/rpc/", "/api/auth/", "/api/mcp"]; + +export function clientKeyOf(c: HonoContext): string { + // The trusted proxy appends the real client address as the LAST hop; + // earlier entries are client-controlled and must not be trusted as an + // identity (rotating a spoofed first hop would mint unlimited keys). + const forwarded = c.req.header("x-forwarded-for"); + if (forwarded) { + const hops = forwarded.split(","); + return hops[hops.length - 1]!.trim(); + } + try { + const info = getConnInfo(c); + return info.remote.address ?? "unknown"; + } catch { + return "unknown"; + } +} + +export function isProbeOrStatic(c: HonoContext): boolean { + if (c.req.path === "/health" && (c.req.method === "GET" || c.req.method === "HEAD")) { + return true; + } + const { pathname } = new URL(c.req.url); + const lastSegment = pathname.split("/").pop() ?? ""; + return STATIC_ASSET_PATTERN.test(lastSegment); +} + +export function isSensitiveMutation(c: HonoContext): boolean { + if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") { + return false; + } + if (!c.req.path.startsWith("/api/")) { + return false; + } + return !SENSITIVE_MUTATION_EXEMPT_PREFIXES.some( + (prefix) => + c.req.path === prefix || c.req.path.startsWith(`${prefix}/`) || c.req.path.startsWith(prefix), + ); +} + +export function rateLimitExceededResponse(c: HonoContext): Response { + const acceptsHtml = (c.req.header("accept") ?? "").includes("text/html"); + if (acceptsHtml) { + return c.html(rateLimitPage(c.req.path), 429); + } + return c.json({ error: "Too many requests, please try again later." }, 429); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function rateLimitPage(path: string): string { + const safePath = escapeHtml(path); + return ` + + + + +Too many requests + + +
+

Too many requests

+

You’re going faster than we can handle. Please wait a moment, then try again.

+Try again +
+ +`; +} + export function getCspStrict(isDev: boolean): boolean { return process.env.CSP_STRICT === "false" ? false : !isDev; } @@ -41,6 +125,7 @@ export class SecurityMiddleware extends Context.Service< cors: MiddlewareHandler; csrf: MiddlewareHandler; rateLimit: MiddlewareHandler; + rateLimitMutation: MiddlewareHandler; csp: MiddlewareHandler; } >()("host/SecurityMiddleware") { @@ -109,23 +194,18 @@ export class SecurityMiddleware extends Context.Service< const rateLimit: MiddlewareHandler = rateLimiter({ windowMs: RATE_LIMIT_WINDOW_MS, limit: RATE_LIMIT_MAX, - keyGenerator: (c) => { - const forwarded = c.req.header("x-forwarded-for"); - if (forwarded) { - return forwarded.split(",")[0]!.trim(); - } - try { - const info = getConnInfo(c); - return info.remote.address ?? "unknown"; - } catch { - return "unknown"; - } - }, - skip: (c) => { - const { pathname } = new URL(c.req.url); - const lastSegment = pathname.split("/").pop() ?? ""; - return STATIC_ASSET_PATTERN.test(lastSegment); - }, + keyGenerator: (c) => clientKeyOf(c), + skip: (c) => isProbeOrStatic(c) || isSensitiveMutation(c), + handler: (c) => rateLimitExceededResponse(c), + message: { error: "Too many requests, please try again later." }, + }); + + const rateLimitMutation: MiddlewareHandler = rateLimiter({ + windowMs: RATE_LIMIT_WINDOW_MS, + limit: RATE_LIMIT_MUTATION_MAX, + keyGenerator: (c) => clientKeyOf(c), + skip: (c) => isProbeOrStatic(c) || !isSensitiveMutation(c), + handler: (c) => rateLimitExceededResponse(c), message: { error: "Too many requests, please try again later." }, }); @@ -208,7 +288,7 @@ export class SecurityMiddleware extends Context.Service< })(c, next); }; - return { cors: corsHandler, csrf, rateLimit, csp }; + return { cors: corsHandler, csrf, rateLimit, rateLimitMutation, csp }; }), ); } diff --git a/host/src/program.ts b/host/src/program.ts index dd0fb754..a9639f31 100644 --- a/host/src/program.ts +++ b/host/src/program.ts @@ -94,6 +94,7 @@ export const createStartServer = (onReady?: () => void) => app.use("/*", security.cors); app.use("/*", security.csrf); app.use("/*", security.rateLimit); + app.use("/*", security.rateLimitMutation); app.use("*", security.csp); if (ssrEnabled) { @@ -204,7 +205,7 @@ export const createStartServer = (onReady?: () => void) => const startHttpServer = () => { const hostname = process.env.HOST || "0.0.0.0"; - const proxiedFetch = (req: Request): Response | Promise => { + const proxiedFetch: typeof app.fetch = (req, env, executionCtx) => { const url = new URL(req.url); const forwardedProto = req.headers.get("x-forwarded-proto"); const forwardedHost = req.headers.get("x-forwarded-host"); @@ -220,7 +221,7 @@ export const createStartServer = (onReady?: () => void) => req = new Request(url, req); } - return app.fetch(req); + return app.fetch(req, env, executionCtx); }; const server = serve({ fetch: proxiedFetch, port, hostname }, () => { diff --git a/host/src/services/errors.ts b/host/src/services/errors.ts index 514ef0b6..d96fd36f 100644 --- a/host/src/services/errors.ts +++ b/host/src/services/errors.ts @@ -20,7 +20,14 @@ export class FederationError extends Data.TaggedError("FederationError")<{ override get message() { if (this.detail) return this.detail; const raw = this.cause instanceof FederationError ? this.cause.cause : this.cause; - const detail = raw instanceof Error ? raw.message : String(raw ?? ""); + const detail = + raw instanceof Error + ? raw.message + : typeof raw === "string" + ? raw + : raw == null + ? "" + : (JSON.stringify(raw) ?? "[Unserializable value]"); return `Failed to load ${this.remoteName}${this.remoteUrl ? ` from ${this.remoteUrl}` : ""}: ${detail}`; } } @@ -32,7 +39,14 @@ export class PluginError extends Data.TaggedError("PluginError")<{ }> { override get message() { const raw = this.cause instanceof PluginError ? this.cause.cause : this.cause; - const detail = raw instanceof Error ? raw.message : String(raw ?? ""); + const detail = + raw instanceof Error + ? raw.message + : typeof raw === "string" + ? raw + : raw == null + ? "" + : (JSON.stringify(raw) ?? "[Unserializable value]"); return `Plugin ${this.pluginName ?? "unknown"}${this.pluginUrl ? ` at ${this.pluginUrl}` : ""} failed: ${detail}`; } } diff --git a/host/src/services/mcp.ts b/host/src/services/mcp.ts index ce5adb0c..89ea838b 100644 --- a/host/src/services/mcp.ts +++ b/host/src/services/mcp.ts @@ -144,7 +144,9 @@ export async function mountMcpRoute( resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value))); } else if (queryParamNames.includes(key)) { if (value !== undefined && value !== null) { - queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + const encodedValue = + typeof value === "string" ? value : (JSON.stringify(value) ?? ""); + queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(encodedValue)}`); } } else { bodyFields[key] = value; diff --git a/host/src/services/plugins.ts b/host/src/services/plugins.ts index 04510d2a..e8cc3248 100644 --- a/host/src/services/plugins.ts +++ b/host/src/services/plugins.ts @@ -184,14 +184,14 @@ function formatError(error: unknown): string { if (error.cause instanceof Error) { msg += ` (caused by: ${error.cause.message || error.cause.name})`; } else if (error.cause) { - msg += ` (caused by: ${String(error.cause)})`; + msg += ` (caused by: ${error.cause instanceof Error ? error.cause.message : (JSON.stringify(error.cause) ?? "[Unserializable cause]")})`; } return msg; } if (typeof error === "object" && error !== null) { const err = error as Record; - if (err.message) return String(err.message); - if (err._tag) return `[${err._tag}] ${JSON.stringify(error)}`; + if (typeof err.message === "string") return err.message; + if (typeof err._tag === "string") return `[${err._tag}] ${JSON.stringify(error)}`; return JSON.stringify(error); } return String(error); @@ -584,7 +584,7 @@ export const initializePlugins = Effect.gen(function* () { secrets: {}, }); - const mfInstance = (runtime as any).__mfInstance as any | undefined; + const mfInstance = (runtime as any).__mfInstance as any; if (mfInstance) { installIntegrityFetchHook(mfInstance, integrityRegistry); } diff --git a/host/src/utils/errors.ts b/host/src/utils/errors.ts index 8c024d43..505c7ae9 100644 --- a/host/src/utils/errors.ts +++ b/host/src/utils/errors.ts @@ -27,7 +27,7 @@ export function extractErrorDetails(error: unknown): { details.cause = `[Effect Cause] ${JSON.stringify(error.cause)}`; } } else { - details.cause = String(error.cause); + details.cause = JSON.stringify(error.cause) ?? "[Unserializable cause]"; } } @@ -51,5 +51,10 @@ export function extractErrorDetails(error: unknown): { return { message: JSON.stringify(error) }; } - return { message: String(error) }; + if (typeof error === "string") return { message: error }; + if (typeof error === "symbol") return { message: error.description ?? "" }; + if (typeof error === "number" || typeof error === "bigint" || typeof error === "boolean") { + return { message: `${error}` }; + } + return { message: "Unknown error" }; } diff --git a/host/tests/e2e/runtime-remote.spec.ts b/host/tests/e2e/runtime-remote.spec.ts index 3f57ef18..9ec52f2b 100644 --- a/host/tests/e2e/runtime-remote.spec.ts +++ b/host/tests/e2e/runtime-remote.spec.ts @@ -21,9 +21,11 @@ function expectNoHydrationFailure(pageErrors: string[]) { } for (const scenario of scenarios) { - const suite = scenario.available ? test.describe : test.describe.skip; + const describeScenario = scenario.available + ? test.describe + : (title: string, fn: () => void) => test.describe.skip(title, fn); - suite(`Remote runtime browser smoke: ${scenario.title}`, () => { + describeScenario(`Remote runtime browser smoke: ${scenario.title}`, () => { test.describe.configure({ mode: "serial" }); let runtime: RuntimeRemoteHost; diff --git a/host/tests/integration/rate-limit.test.ts b/host/tests/integration/rate-limit.test.ts new file mode 100644 index 00000000..e8061d84 --- /dev/null +++ b/host/tests/integration/rate-limit.test.ts @@ -0,0 +1,223 @@ +import { createServer, request } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createServer as createProbeServer } from "node:net"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// The security module reads its env at import time — pin a small burst +// budget here so the loops below stay fast regardless of production defaults. +vi.hoisted(() => { + process.env.RATE_LIMIT_MAX = "100"; +}); + +import { RATE_LIMIT_MAX, RATE_LIMIT_MUTATION_MAX } from "../../src/middleware/security"; +import { runServer, type ServerHandle } from "../../src/program"; +import type { RuntimeConfig } from "../../src/services/config"; +import { getAvailablePort } from "../helpers/ports"; + +describe("host rate limiting", () => { + let upstream: ReturnType; + let host: ServerHandle; + let origin: string; + let proxyArgIndex: number; + let port: number; + + beforeAll(async () => { + port = await getAvailablePort(); + origin = `http://127.0.0.1:${port}`; + }); + + beforeEach(async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("HOST", "127.0.0.1"); + proxyArgIndex = process.argv.length; + process.argv.push("--proxy"); + upstream = createServer((_req, res) => { + res.setHeader("content-type", "application/json"); + res.end('{"ok":true}'); + }); + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); + const upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + vi.stubEnv("PORT", String(port)); + const config: RuntimeConfig = { + env: "production", + account: "rate-limit.testnet", + domain: "rate-limit.test", + networkId: "testnet", + host: { name: "host", url: origin, entry: `${origin}/mf-manifest.json`, source: "local" }, + ui: { + name: "ui", + url: upstreamUrl, + entry: `${upstreamUrl}/mf-manifest.json`, + source: "remote", + }, + api: { + name: "api", + url: upstreamUrl, + entry: `${upstreamUrl}/mf-manifest.json`, + source: "remote", + proxy: upstreamUrl, + }, + }; + host = runServer({ config }); + await host.ready; + }); + + afterEach(async () => { + await host?.shutdown(); + await new Promise((resolve, reject) => { + upstream?.close((error) => (error ? reject(error) : resolve())); + }); + vi.unstubAllEnvs(); + process.argv.splice(proxyArgIndex, 1); + }); + + async function get(path: string, localAddress = "127.0.0.1", headers = {}) { + return new Promise<{ + status: number; + headers: import("node:http").IncomingHttpHeaders; + body: string; + }>((resolve, reject) => { + const req = request(`${origin}${path}`, { localAddress, headers, agent: false }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body })); + }); + req.on("error", reject); + req.end(); + }); + } + + it("keeps health probes available without consuming the client's request budget", async () => { + for (let i = 0; i <= RATE_LIMIT_MAX; i++) { + expect((await get("/health")).status).toBe(200); + } + expect((await get("/api/ping")).status).toBe(200); + for (let i = 1; i < RATE_LIMIT_MAX; i++) { + expect((await get("/api/ping")).status).toBe(200); + } + const limited = await get("/api/ping"); + expect(limited.status).toBe(429); + expect(JSON.parse(limited.body)).toEqual({ + error: "Too many requests, please try again later.", + }); + expect(Number(limited.headers["retry-after"])).toBeGreaterThan(0); + expect((await get("/health")).status).toBe(200); + }); + + it("isolates socket clients even when the public request URL is rewritten", async () => { + const headers = { "x-forwarded-host": "rate-limit.test", "x-forwarded-proto": "https" }; + for (let i = 0; i < RATE_LIMIT_MAX; i++) { + expect((await get("/api/ping", "127.0.0.1", headers)).status).toBe(200); + } + expect((await get("/api/ping", "127.0.0.1", headers)).status).toBe(429); + // Distinct 127/8 addresses bind natively on Linux; macOS needs an explicit + // `ifconfig lo0 alias` — skip the second-client isolation when the local + // alias does not exist rather than failing on an environment artifact. + if (await localAddressAvailable("127.0.0.2")) { + expect((await get("/api/ping", "127.0.0.2", headers)).status).toBe(200); + } + }); + + it("keys forwarded clients by the LAST proxy hop and still limits page requests", async () => { + // Only the last x-forwarded-for hop is trusted (our proxy appends the + // real client); two clients sharing a first hop but differing in the + // last hop must land in separate buckets. + const headers = { "x-forwarded-for": "192.0.2.1, 10.0.0.1" }; + for (let i = 0; i < RATE_LIMIT_MAX; i++) { + expect((await get("/api/ping", "127.0.0.1", headers)).status).toBe(200); + } + expect((await get("/", "127.0.0.1", headers)).status).toBe(429); + expect( + (await get("/api/ping", "127.0.0.1", { "x-forwarded-for": "192.0.2.1, 10.0.0.2" })).status, + ).toBe(200); + }); + + it("answers document navigations with an HTML retry page instead of JSON", async () => { + const headers = { "x-forwarded-for": "192.0.2.9" }; + for (let i = 0; i < RATE_LIMIT_MAX; i++) { + await get("/api/ping", "127.0.0.1", headers); + } + const page = await get("/", "127.0.0.1", { ...headers, accept: "text/html" }); + expect(page.status).toBe(429); + expect(page.headers["content-type"]).toContain("text/html"); + expect(page.body).toContain("Too many requests"); + expect(page.body).toContain(""); + const api = await get("/api/ping", "127.0.0.1", headers); + expect(api.status).toBe(429); + expect(api.headers["content-type"]).toContain("application/json"); + expect(JSON.parse(api.body)).toEqual({ + error: "Too many requests, please try again later.", + }); + }); + + it("applies the stricter mutation tier to non-RPC API writes without touching reads", async () => { + const write = { "x-forwarded-for": "192.0.2.20", "content-type": "application/json" }; + for (let i = 0; i < RATE_LIMIT_MUTATION_MAX; i++) { + expect( + ( + await rawRequest(`${origin}/api/ping`, { + method: "POST", + localAddress: "127.0.0.1", + headers: write, + agent: false, + }) + ).status, + ).toBe(200); + } + expect( + ( + await rawRequest(`${origin}/api/ping`, { + method: "POST", + localAddress: "127.0.0.1", + headers: write, + agent: false, + }) + ).status, + ).toBe(429); + // oRPC transport is exempt from the mutation tier... + expect( + ( + await rawRequest(`${origin}/api/rpc/example`, { + method: "POST", + localAddress: "127.0.0.1", + headers: write, + agent: false, + }) + ).status, + ).toBe(200); + // ...and reads keep their own bucket after the write bucket saturated. + expect((await get("/api/ping", "127.0.0.1", { "x-forwarded-for": "192.0.2.20" })).status).toBe( + 200, + ); + }); +}); + +async function rawRequest( + url: string, + options: import("node:http").RequestOptions, +): Promise<{ status: number; headers: import("node:http").IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const req = request(url, options, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, body })); + }); + req.on("error", reject); + req.end(); + }); +} + +async function localAddressAvailable(address: string): Promise { + return new Promise((resolve) => { + const probe = createProbeServer(); + probe.once("error", () => resolve(false)); + probe.once("listening", () => probe.close(() => resolve(true))); + probe.listen(0, address); + }); +} diff --git a/host/tests/integration/tenant-runtime.test.ts b/host/tests/integration/tenant-runtime.test.ts index acb93cd8..64e65b02 100644 --- a/host/tests/integration/tenant-runtime.test.ts +++ b/host/tests/integration/tenant-runtime.test.ts @@ -29,7 +29,7 @@ let remoteConfigs: Record = {}; function setRemoteConfigs(...entries: Array>) { remoteConfigs = {}; for (const entry of entries) { - remoteConfigs[`bos://${entry.account}/${entry.domain}`] = entry; + remoteConfigs[`bos://${String(entry.account)}/${String(entry.domain)}`] = entry; } } diff --git a/host/tests/unit/serving-swap-under-traffic.test.ts b/host/tests/unit/serving-swap-under-traffic.test.ts index 9d8e8442..7bd5d9ba 100644 --- a/host/tests/unit/serving-swap-under-traffic.test.ts +++ b/host/tests/unit/serving-swap-under-traffic.test.ts @@ -72,7 +72,7 @@ const ssrEntryBytes = (gen: number) => `console.log('ssr entry gen ${gen}');`; const sriOf = (bytes: string) => `sha384-${createHash("sha384").update(bytes).digest("base64")}`; const fakeCdnFetch = async (input: string | URL | Request): Promise => { - const url = String(input); + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const genMatch = url.match(/gen-(\d+)/); if (url.endsWith("/manifest.gen.json")) { return new Response(JSON.stringify(CORE_MANIFEST), { status: 200 }); diff --git a/host/tests/unit/snapshot-coordinator.test.ts b/host/tests/unit/snapshot-coordinator.test.ts index dbfcd93f..120059a1 100644 --- a/host/tests/unit/snapshot-coordinator.test.ts +++ b/host/tests/unit/snapshot-coordinator.test.ts @@ -81,7 +81,7 @@ vi.mock("../../src/services/ui-compose", async (importOriginal) => { beforeAll(() => { const fakeCdnFetch = async (input: string | URL | Request): Promise => { - const url = String(input); + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url.endsWith("/remoteEntry.cf71.js")) return new Response(entryBytes, { status: 200 }); return new Response("not found", { status: 404 }); }; diff --git a/packages/better-near-auth/src/client.ts b/packages/better-near-auth/src/client.ts index e6b72c2c..24efa236 100644 --- a/packages/better-near-auth/src/client.ts +++ b/packages/better-near-auth/src/client.ts @@ -65,7 +65,6 @@ export { type NearClientAtoms, type NearNetwork, type NearState, - readSessionNearAccountId, } from "./store.js"; export interface AuthCallbacks { diff --git a/packages/better-near-auth/src/gas-key.test.ts b/packages/better-near-auth/src/gas-key.test.ts index 2df4dfd1..b8ab8968 100644 --- a/packages/better-near-auth/src/gas-key.test.ts +++ b/packages/better-near-auth/src/gas-key.test.ts @@ -126,7 +126,7 @@ function setupClient(initial: unknown, fetchRoutes: Record = {} } async function setActiveNetworkTestnet(actions: ReturnType["actions"]) { - await actions.near.setNetwork("testnet"); + actions.near.setNetwork("testnet"); } function setSignedIn(plugin: ReturnType["plugin"], accountId = "test.testnet") { diff --git a/packages/better-near-auth/src/index.ts b/packages/better-near-auth/src/index.ts index 4ff5729b..65fae2fa 100644 --- a/packages/better-near-auth/src/index.ts +++ b/packages/better-near-auth/src/index.ts @@ -1783,7 +1783,7 @@ export const siwn = (options: SIWNPluginOptions) => { return ctx.json(RelayStatusResponse.parse({ status: "pending" })); } catch (error: unknown) { console.warn( - `[siwn] relay status lookup failed, reporting pending: ${error instanceof Error ? error.message : error}`, + `[siwn] relay status lookup failed, reporting pending: ${String(error instanceof Error ? error.message : error)}`, ); return ctx.json(RelayStatusResponse.parse({ status: "pending" })); } diff --git a/packages/every-plugin/src/dev/serve.ts b/packages/every-plugin/src/dev/serve.ts index 401be1d1..665700c3 100644 --- a/packages/every-plugin/src/dev/serve.ts +++ b/packages/every-plugin/src/dev/serve.ts @@ -206,7 +206,7 @@ export async function startPluginDevServer( } const handlers: { rpc: any; api: any } = { rpc: null, api: null }; - const effectContextHolder: { context: unknown | null } = { context: null }; + const effectContextHolder: { context: unknown } = { context: null }; let runtime: { shutdown: () => Promise } | null = null; const pathnameOf = (req: http.IncomingMessage): string => diff --git a/packages/every-plugin/src/runtime/errors.ts b/packages/every-plugin/src/runtime/errors.ts index 76363a4a..06919988 100644 --- a/packages/every-plugin/src/runtime/errors.ts +++ b/packages/every-plugin/src/runtime/errors.ts @@ -25,6 +25,22 @@ export class ValidationError extends Data.TaggedError("ValidationError")<{ readonly zodError: z.ZodError; }> {} +const stringifyUnknown = (value: unknown): string => { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (value instanceof Error) return value.message; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") { + return `${value}`; + } + if (typeof value === "symbol") return value.description ?? ""; + try { + return JSON.stringify(value) ?? "[Unserializable value]"; + } catch { + return "[Unserializable value]"; + } +}; + const extractErrorMessage = (error: unknown): string => { if (!error) return "Unknown error"; @@ -43,7 +59,7 @@ const extractErrorMessage = (error: unknown): string => { return String((error as any).message); } - return String(error); + return stringifyUnknown(error); }; /** @@ -95,7 +111,7 @@ const formatDataPreview = (data: unknown, maxLength = 100): string => { return `${str.slice(0, maxLength)}...`; } catch { - return String(data).slice(0, maxLength); + return stringifyUnknown(data).slice(0, maxLength); } }; @@ -257,7 +273,7 @@ export interface PluginLoadFailureInfo { pluginUrl?: string; /** failing runtime stage when the cause is stage-attributed */ operation?: string; - kind: PluginFailureKind | string; + kind: string; retryable: boolean; message: string; suggestion?: string; diff --git a/packages/every-plugin/src/runtime/services/module-federation.service.ts b/packages/every-plugin/src/runtime/services/module-federation.service.ts index 6f787b51..8fb1d7d4 100644 --- a/packages/every-plugin/src/runtime/services/module-federation.service.ts +++ b/packages/every-plugin/src/runtime/services/module-federation.service.ts @@ -89,7 +89,7 @@ const createModuleFederationInstance = Effect.cached( return instance; } catch (error) { - throw new Error(`Failed to initialize Module Federation: ${error}`); + throw new Error(`Failed to initialize Module Federation: ${String(error)}`); } }), ); diff --git a/packages/every-plugin/src/runtime/services/plugin-loader.service.ts b/packages/every-plugin/src/runtime/services/plugin-loader.service.ts index 2e7b2f32..48dfe998 100644 --- a/packages/every-plugin/src/runtime/services/plugin-loader.service.ts +++ b/packages/every-plugin/src/runtime/services/plugin-loader.service.ts @@ -142,7 +142,7 @@ export const PluginLoaderServiceDefault = Layer.effect( yield* moduleFederationService.registerRemote(pluginId, resolvedUrl).pipe( Effect.tapError((error) => - Effect.logError(`Plugin ${pluginId} failed during register-remote: ${error}`), + Effect.logError(`Plugin ${pluginId} failed during register-remote: ${String(error)}`), ), Effect.mapError((error) => toPluginRuntimeError(error, pluginId, undefined, "register-remote"), @@ -155,7 +155,7 @@ export const PluginLoaderServiceDefault = Layer.effect( .loadRemoteConstructor(pluginId, resolvedUrl) .pipe( Effect.tapError((error) => - Effect.logError(`Plugin ${pluginId} failed during load-remote: ${error}`), + Effect.logError(`Plugin ${pluginId} failed during load-remote: ${String(error)}`), ), Effect.mapError((error) => toPluginRuntimeError(error, pluginId, undefined, "load-remote"), @@ -298,7 +298,9 @@ export const PluginLoaderServiceDefault = Layer.effect( ), ), Effect.tapError((error) => - Effect.logError(`Plugin ${plugin.id} failed during initialize-plugin: ${error}`), + Effect.logError( + `Plugin ${plugin.id} failed during initialize-plugin: ${String(error)}`, + ), ), Effect.mapError((error) => toPluginRuntimeError(error, plugin.id, undefined, "initialize-plugin"), diff --git a/packages/every-plugin/src/ui/manifest/descriptor.ts b/packages/every-plugin/src/ui/manifest/descriptor.ts index 1481b3d0..a95756c7 100644 --- a/packages/every-plugin/src/ui/manifest/descriptor.ts +++ b/packages/every-plugin/src/ui/manifest/descriptor.ts @@ -45,7 +45,7 @@ export function App(input: AppInput): AppInput { return input; } -export function Plugin( +export function Plugin>( name: K, ): { local: (path: string) => PluginRefInput; diff --git a/packages/every-plugin/src/ui/manifest/mount-registry.ts b/packages/every-plugin/src/ui/manifest/mount-registry.ts index e911f23f..b25d17a7 100644 --- a/packages/every-plugin/src/ui/manifest/mount-registry.ts +++ b/packages/every-plugin/src/ui/manifest/mount-registry.ts @@ -29,7 +29,7 @@ export const MOUNT_REGISTRY = { team: { label: "team", gate: "team", parameterized: true, implemented: false }, } satisfies Record; -export type MountId = keyof typeof MOUNT_REGISTRY & string; +export type MountId = Extract; export const MOUNTS = Object.keys(MOUNT_REGISTRY) as MountId[]; diff --git a/packages/every-plugin/tests/unit/watch-kill.test.ts b/packages/every-plugin/tests/unit/watch-kill.test.ts index bd695950..89a26cb9 100644 --- a/packages/every-plugin/tests/unit/watch-kill.test.ts +++ b/packages/every-plugin/tests/unit/watch-kill.test.ts @@ -19,7 +19,7 @@ const makeFakeChild = (opts: FakeChildOptions = {}) => { child.killed = true; if (exitsOnSignal === signal) { child.exitCode = 0; - for (const listener of [...exitListeners]) listener(); + for (const listener of exitListeners) listener(); } }, addListener: (_event: "exit", listener: () => void) => { diff --git a/packages/everything-dev/src/api-contract.ts b/packages/everything-dev/src/api-contract.ts index 547d2330..172a5e86 100644 --- a/packages/everything-dev/src/api-contract.ts +++ b/packages/everything-dev/src/api-contract.ts @@ -100,7 +100,7 @@ export class ApiContractResolver extends Context.Service< >()("everything-dev/api-contract/ApiContractResolver") { static readonly layer: Layer.Layer = Layer.effect( ApiContractResolver, - Effect.gen(function* () { + Effect.sync(() => { const manifest = Effect.fn("ApiContractResolver.manifest")(function* ( apiBaseUrl: string, ): Effect.fn.Return { diff --git a/packages/everything-dev/src/cdn-deploy.ts b/packages/everything-dev/src/cdn-deploy.ts index 42871b76..3051a8d4 100644 --- a/packages/everything-dev/src/cdn-deploy.ts +++ b/packages/everything-dev/src/cdn-deploy.ts @@ -162,7 +162,7 @@ export async function probeStorageOrigin( try { response = await fetchImpl(probeUrl, { headers: { accept: "application/json" } }); } catch (error) { - return `unreachable (${error instanceof Error ? error.message : error})`; + return `unreachable (${String(error instanceof Error ? error.message : error)})`; } if (!response.ok) { return `GET /.well-known/mcp.json responded ${response.status}`; diff --git a/packages/everything-dev/src/dev-session.ts b/packages/everything-dev/src/dev-session.ts index f411650c..a217892c 100644 --- a/packages/everything-dev/src/dev-session.ts +++ b/packages/everything-dev/src/dev-session.ts @@ -295,7 +295,7 @@ export const runDevSession = ( ), Effect.tapError((err) => Effect.sync(() => { - callbacks.onLog(pkg, `Failed to start: ${err}`, true); + callbacks.onLog(pkg, `Failed to start: ${String(err)}`, true); callbacks.onStatus(pkg, "error"); }), ), @@ -462,11 +462,18 @@ const runApp = ( process.on("SIGINT", handleSignal); process.on("SIGTERM", handleSignal); - Effect.runPromiseExit(program).then((exit) => { - clearInterval(orphanWatch); - if (forceExitTimer) clearTimeout(forceExitTimer); - process.exit(Exit.isSuccess(exit) ? 0 : 1); - }); + void Effect.runPromiseExit(program) + .then((exit) => { + clearInterval(orphanWatch); + if (forceExitTimer) clearTimeout(forceExitTimer); + process.exit(Exit.isSuccess(exit) ? 0 : 1); + }) + .catch((error: unknown) => { + clearInterval(orphanWatch); + if (forceExitTimer) clearTimeout(forceExitTimer); + console.error("[Dev] Failed to observe application exit:", error); + process.exit(1); + }); }; export const devApp = runApp; diff --git a/packages/everything-dev/src/mf.ts b/packages/everything-dev/src/mf.ts index 59104f6e..0add2e33 100644 --- a/packages/everything-dev/src/mf.ts +++ b/packages/everything-dev/src/mf.ts @@ -198,7 +198,7 @@ export interface FederationCompatReport { } function parseSemver(v: unknown): { major: number; minor: number; patch: number } | null { - const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? "")); + const m = typeof v === "string" ? /^(\d+)\.(\d+)\.(\d+)/.exec(v) : null; return m ? { major: +m[1]!, minor: +m[2]!, patch: +m[3]! } : null; } diff --git a/packages/everything-dev/src/orchestrator.ts b/packages/everything-dev/src/orchestrator.ts index 5b02da63..08ac82e0 100644 --- a/packages/everything-dev/src/orchestrator.ts +++ b/packages/everything-dev/src/orchestrator.ts @@ -153,11 +153,22 @@ const patchConsole = (name: string, callbacks: ProcessCallbacks): (() => void) = const parts = [`${arg.name}: ${arg.message}`]; if (arg.cause instanceof Error) parts.push(`(cause: ${arg.cause.name}: ${arg.cause.message})`); - else if (arg.cause) parts.push(`(cause: ${String(arg.cause)})`); + else if (arg.cause) { + const cause = arg.cause; + parts.push( + `(cause: ${cause instanceof Error ? cause.message : typeof cause === "string" ? cause : (JSON.stringify(cause) ?? "[Unserializable cause]")})`, + ); + } if (isError && arg.stack) parts.push(arg.stack); return parts.join("\n"); } - return typeof arg === "object" ? JSON.stringify(arg, null, 2) : String(arg); + if (arg === null) return "null"; + if (typeof arg === "string") return arg; + if (typeof arg === "number" || typeof arg === "bigint" || typeof arg === "boolean") + return `${arg}`; + if (typeof arg === "symbol") return arg.description ?? ""; + if (typeof arg === "undefined") return "undefined"; + return JSON.stringify(arg, null, 2) ?? "[Unserializable value]"; }) .join(" "); }; @@ -272,7 +283,7 @@ const spawnRemoteHost = (descriptor: ServiceDescriptor, callbacks: ProcessCallba } } catch (e) { warnOutsideEffect( - `[Orchestrator] Failed to fetch or parse manifest from ${manifestUrl}, falling back to remoteEntryUrl: ${e}`, + `[Orchestrator] Failed to fetch or parse manifest from ${manifestUrl}, falling back to remoteEntryUrl: ${String(e)}`, ); } return remoteEntryUrl; diff --git a/packages/everything-dev/src/publish.ts b/packages/everything-dev/src/publish.ts index ba6ea26a..156705af 100644 --- a/packages/everything-dev/src/publish.ts +++ b/packages/everything-dev/src/publish.ts @@ -28,6 +28,7 @@ import { import { getNetworkIdForAccount } from "./network"; import { platformUrlDeployEntries, pluginUiUrlDeployEntries } from "./platform-deploy"; import { openResolution } from "./resolution/session"; +import { verifyRollbackSnapshot } from "./rollback"; import { collectDistFiles, uploadBundle, uploadWorkspaceDist } from "./storage-upload"; import type { BosConfig, BosConfigInput, PublishConfig, RuntimeConfig } from "./types"; import { padRight } from "./utils/string"; @@ -699,6 +700,25 @@ export async function publishToFastKv(input: PublishToFastKvInput): Promise !check.ok) + .map((check) => ` ${check.slot}: ${check.reason}`) + .join("\n"); + console.error( + ` ${colors.error(icons.err)} Publish blocked — a pinned slot's bytes do not serve at its production URL:\n${failures}`, + ); + return { + status: "error", + registryUrl, + built, + skipped, + deployResults, + error: `Publish blocked — pinned slot bytes missing or SRI mismatched:\n${failures}`, + }; + } + const registryKey = `apps/${account}/${gateway}/bos.config.json`; const publishedAt = new Date().toISOString(); // version identity printed + returned for every deploy (atomic-deploys 12) diff --git a/packages/everything-dev/src/types.ts b/packages/everything-dev/src/types.ts index 476af428..9dd26381 100644 --- a/packages/everything-dev/src/types.ts +++ b/packages/everything-dev/src/types.ts @@ -251,6 +251,7 @@ export const BosConfigInputSchema: z.ZodType = z.lazy(() => domain: z.string().optional(), status: z.enum(["active", "suspended", "pending_deletion"]).optional(), testnet: z.string().optional(), + staging: BosStagingSchema.optional(), template: z.string().optional(), gateway: z .object({ diff --git a/packages/everything-dev/src/ui/api.ts b/packages/everything-dev/src/ui/api.ts index aa6c21db..ae9ea90d 100644 --- a/packages/everything-dev/src/ui/api.ts +++ b/packages/everything-dev/src/ui/api.ts @@ -12,6 +12,7 @@ export type ApiClient = ContractRoute export interface ClientServiceConfig { hostUrl: string; rpcBase: `/${string}`; + connectionError?: () => { title: string; description: string }; } type ClientRouterContext = { @@ -40,9 +41,13 @@ function createRpcLink(config: ClientServiceConfig, url: `/${string}`, headers?: ) { void import("sonner") .then(({ toast }) => { - toast.error("Unable to connect to API", { - id: "api-connection-error", + const message = config.connectionError?.() ?? { + title: "Unable to connect to API", description: "The API is currently unavailable. Please try again later.", + }; + toast.error(message.title, { + id: "api-connection-error", + description: message.description, }); }) .catch(() => {}); @@ -51,7 +56,7 @@ function createRpcLink(config: ClientServiceConfig, url: `/${string}`, headers?: }), ], fetch(fetchUrl: RequestInfo | URL, options?: RequestInit) { - return fetch(fetchUrl, { + return fetchWithRateLimitRetry(fetchUrl, { ...options, credentials: "include", headers: headers @@ -62,6 +67,31 @@ function createRpcLink(config: ClientServiceConfig, url: `/${string}`, headers?: }); } +const RATE_LIMIT_MAX_RETRIES = 3; +const RATE_LIMIT_RETRY_CAP_MS = 2_000; + +/** + * A 429 from the host's edge limiter is transient saturation, not an + * application failure — retry within the server's `Retry-After` (capped) so + * queries and the session check self-heal instead of surfacing errors. + */ +async function fetchWithRateLimitRetry( + fetchUrl: RequestInfo | URL, + options?: RequestInit, +): Promise { + let response = await fetch(fetchUrl, options); + for (let attempt = 1; attempt <= RATE_LIMIT_MAX_RETRIES && response.status === 429; attempt++) { + const retryAfterSeconds = Number(response.headers.get("retry-after")); + const delay = + Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? Math.min(retryAfterSeconds * 1000, RATE_LIMIT_RETRY_CAP_MS) + : 100 * attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + response = await fetch(fetchUrl, options); + } + return response; +} + function buildClient( config: ClientServiceConfig, url: `/${string}`, diff --git a/packages/everything-dev/src/ui/auth.ts b/packages/everything-dev/src/ui/auth.ts index 6380de39..bbe61908 100644 --- a/packages/everything-dev/src/ui/auth.ts +++ b/packages/everything-dev/src/ui/auth.ts @@ -156,9 +156,10 @@ export function sessionQueryOptions(authClient: AuthClient) { return { queryKey: sessionQueryKey, queryFn: async () => { - const { data: session } = await authClient.getSession({ + const { data: session, error } = await authClient.getSession({ query: { disableCookieCache: true }, }); + if (error) throw new Error(error.message || "Unable to load your session"); return session ?? null; }, staleTime: 60 * 1000, diff --git a/packages/everything-dev/src/ui/hydrate.tsx b/packages/everything-dev/src/ui/hydrate.tsx index df704b87..1b55d002 100644 --- a/packages/everything-dev/src/ui/hydrate.tsx +++ b/packages/everything-dev/src/ui/hydrate.tsx @@ -11,7 +11,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { ClientRuntimeConfig } from "../types"; -import { createApiClient } from "./api"; +import { type ClientServiceConfig, createApiClient } from "./api"; import { createAuthClient } from "./auth"; import { CORE_UI_PLUGIN_KEY, @@ -78,6 +78,8 @@ export interface CoreHydrateOptions { createQueryClient?: () => QueryClient; /** Overrides the runtime config source (tests, embeds). */ config?: ClientRuntimeConfig; + /** Localized copy for the API connection-failure toast (see `createApiClient`). */ + apiConnectionError?: ClientServiceConfig["connectionError"]; } interface ComposedTree { @@ -321,9 +323,11 @@ export async function hydrate(options: CoreHydrateOptions) { queryClient: client, runtimeConfig, cspNonce, + locale: isServerRendered() ? document.documentElement.lang : undefined, apiClient: createApiClient({ hostUrl: runtimeConfig.hostUrl, rpcBase: runtimeConfig.rpcBase, + connectionError: options.apiConnectionError, }), authClient: createAuthClient({ runtimeConfig, cspNonce }), }, @@ -360,10 +364,35 @@ export async function hydrate(options: CoreHydrateOptions) { ); } + if (composed?.degraded) { + const { toast } = await import("sonner"); + toast.error("Some application features couldn't load", { + id: "application-compose-error", + description: "Plugin pages are unavailable. Reload to try again.", + duration: Number.POSITIVE_INFINITY, + action: { label: "Reload", onClick: () => window.location.reload() }, + }); + } + console.log("[Hydrate] Complete!"); })().catch((error) => { console.error("[Hydrate] Failed:", error); window.__EVERYTHING_DEV_HYDRATE_PROMISE__ = undefined; + const message = document.createElement("main"); + message.setAttribute("role", "alert"); + message.dataset.testid = "application-startup-error"; + message.className = + "min-h-screen flex flex-col items-center justify-center gap-4 bg-background text-foreground p-6"; + const heading = document.createElement("h1"); + heading.textContent = "The application couldn't load"; + const detail = document.createElement("p"); + detail.textContent = "Check your connection and reload to try again."; + const retry = document.createElement("button"); + retry.textContent = "Reload"; + retry.className = "rounded-md border border-border px-4 py-2"; + retry.addEventListener("click", () => window.location.reload()); + message.append(heading, detail, retry); + document.body.replaceChildren(message); throw error; }); diff --git a/packages/everything-dev/src/ui/i18n.tsx b/packages/everything-dev/src/ui/i18n.tsx index ea8d7ff3..6c990281 100644 --- a/packages/everything-dev/src/ui/i18n.tsx +++ b/packages/everything-dev/src/ui/i18n.tsx @@ -9,6 +9,25 @@ import { useMemo, useSyncExternalStore, } from "react"; +import { matchLocale, resolveLocale, serializeLocaleCookie } from "./locale"; + +export { Trans } from "@lingui/react"; +export { + matchLocale, + readAcceptLanguage, + readLocaleCookie, + resolveLocale, + serializeLocaleCookie, +} from "./locale"; +export { createMessageCatalogs } from "./message-catalogs"; + +type SharedLocaleState = { + cookieName: string; + locale: string; + selectLocale: (locale: string) => Promise; +}; + +const SharedLocaleContext = createContext(null); type LocaleStore = { get: () => string; @@ -35,61 +54,6 @@ function createLocaleStore(initialLocale: string): LocaleStore { }; } -export function readLocaleCookie(cookie: string, cookieName: string): string | undefined { - for (const part of cookie.split(";")) { - const [name, ...valueParts] = part.trim().split("="); - if (name !== cookieName) continue; - try { - return decodeURIComponent(valueParts.join("=")); - } catch { - return undefined; - } - } - return undefined; -} - -export function matchLocale( - candidate: string | null | undefined, - locales: readonly Locale[], -): Locale | undefined { - if (!candidate) return undefined; - const normalized = candidate.toLowerCase(); - const exact = locales.find((locale) => locale.toLowerCase() === normalized); - if (exact) return exact; - const base = normalized.split("-")[0]; - return locales.find((locale) => locale.toLowerCase() === base); -} - -export function resolveLocale({ - preferredLocale, - cookie, - browserLocales, - locales, - defaultLocale, - cookieName, -}: { - preferredLocale?: string | null; - cookie: string; - browserLocales: readonly string[]; - locales: readonly Locale[]; - defaultLocale: Locale; - cookieName: string; -}): Locale { - const preferred = matchLocale(preferredLocale, locales); - if (preferred) return preferred; - const saved = matchLocale(readLocaleCookie(cookie, cookieName), locales); - if (saved) return saved; - for (const browserLocale of browserLocales) { - const matched = matchLocale(browserLocale, locales); - if (matched) return matched; - } - return defaultLocale; -} - -export function serializeLocaleCookie(cookieName: string, locale: string, secure: boolean): string { - return `${cookieName}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`; -} - export function createLocaleRuntime({ locales, defaultLocale, @@ -122,11 +86,12 @@ export function createLocaleRuntime({ cookieName, }); - const getStore = (preferredLocale?: string | null) => { - if (typeof window === "undefined") return createLocaleStore(detectLocale(preferredLocale)); + const getStore = (initialLocale?: string | null) => { + const initial = matchLocale(initialLocale, locales) ?? detectLocale(); + if (typeof window === "undefined") return createLocaleStore(initial); const existing = browserStores.get(cookieName); if (existing) return existing; - const store = createLocaleStore(detectLocale(preferredLocale)); + const store = createLocaleStore(initial); browserStores.set(cookieName, store); return store; }; @@ -134,37 +99,60 @@ export function createLocaleRuntime({ function LocaleProvider({ children, messages, + initialLocale, preferredLocale, onLocaleChange, }: { children: ReactNode; messages: (locale: Locale) => Messages; + initialLocale?: string | null; preferredLocale?: string | null; onLocaleChange?: (locale: Locale) => void | Promise; }) { - const store = useMemo(() => getStore(preferredLocale), []); - const locale = useSyncExternalStore(store.subscribe, store.get, () => defaultLocale) as Locale; + const parent = useContext(SharedLocaleContext); + const sharedParent = parent?.cookieName === cookieName ? parent : null; + const resolvedInitial = + matchLocale(preferredLocale, locales) ?? + matchLocale(initialLocale, locales) ?? + matchLocale(sharedParent?.locale, locales); + const serverLocale = resolvedInitial ?? defaultLocale; + const store = useMemo(() => getStore(resolvedInitial), []); + const locale = useSyncExternalStore(store.subscribe, store.get, () => serverLocale) as Locale; const i18n = useMemo( () => setupI18n({ locale, messages: { [locale]: messages(locale) } }), [locale, messages], ); useEffect(() => { + if (sharedParent) return; const resolved = matchLocale(preferredLocale, locales); - if (resolved) store.set(resolved); - }, [preferredLocale, store]); + if (resolved) { + Reflect.set( + document, + "cookie", + serializeLocaleCookie(cookieName, resolved, window.location.protocol === "https:"), + ); + store.set(resolved); + } + }, [preferredLocale, sharedParent, store]); useEffect(() => { + if (sharedParent) return; const previousLocale = document.documentElement.lang; document.documentElement.lang = locale; return () => { document.documentElement.lang = previousLocale; }; - }, [locale]); + }, [locale, sharedParent]); const selectLocale = useCallback( async (nextLocale: Locale) => { if (!locales.includes(nextLocale)) return; + if (sharedParent && !onLocaleChange) { + await sharedParent.selectLocale(nextLocale); + return; + } + if (onLocaleChange) await onLocaleChange(nextLocale); if (typeof document !== "undefined") { Reflect.set( document, @@ -177,16 +165,28 @@ export function createLocaleRuntime({ ); } store.set(nextLocale); - await onLocaleChange?.(nextLocale); }, - [onLocaleChange, store], + [onLocaleChange, sharedParent, store], ); const value = useMemo(() => ({ locale, selectLocale }), [locale, selectLocale]); + const sharedValue = useMemo( + () => ({ + cookieName, + locale, + selectLocale: async (next: string) => { + const matched = matchLocale(next, locales); + if (matched) await selectLocale(matched); + }, + }), + [locale, selectLocale], + ); return ( - - {children} - + + + {children} + + ); } @@ -204,5 +204,8 @@ export function createLocaleRuntime({ ); } - return { LocaleProvider, useLocale, useTranslation, detectLocale }; + const getLocale = (): Locale => + matchLocale(browserStores.get(cookieName)?.get(), locales) ?? detectLocale(); + + return { LocaleProvider, useLocale, useTranslation, detectLocale, getLocale }; } diff --git a/packages/everything-dev/src/ui/locale.ts b/packages/everything-dev/src/ui/locale.ts new file mode 100644 index 00000000..31da3e19 --- /dev/null +++ b/packages/everything-dev/src/ui/locale.ts @@ -0,0 +1,70 @@ +export type LocaleOptions = { + locales: readonly Locale[]; + defaultLocale: Locale; + cookieName: string; +}; + +export function readLocaleCookie(cookie: string, cookieName: string): string | undefined { + for (const part of cookie.split(";")) { + const [name, ...valueParts] = part.trim().split("="); + if (name !== cookieName) continue; + try { + return decodeURIComponent(valueParts.join("=")); + } catch { + return undefined; + } + } + return undefined; +} + +export function matchLocale( + candidate: string | null | undefined, + locales: readonly Locale[], +): Locale | undefined { + if (!candidate) return undefined; + const normalized = candidate.trim().toLowerCase(); + const exact = locales.find((locale) => locale.toLowerCase() === normalized); + if (exact) return exact; + const base = normalized.split("-")[0]; + return locales.find((locale) => locale.toLowerCase() === base); +} + +export function readAcceptLanguage(header: string | null): string[] { + return (header ?? "") + .split(",") + .map((part, index) => { + const [language = "", ...parameters] = part.trim().split(";"); + const quality = parameters.find((parameter) => parameter.trim().startsWith("q=")); + return { language, quality: quality ? Number(quality.trim().slice(2)) : 1, index }; + }) + .filter(({ language, quality }) => language && quality > 0 && quality <= 1) + .sort((a, b) => b.quality - a.quality || a.index - b.index) + .map(({ language }) => language); +} + +export function resolveLocale({ + preferredLocale, + cookie, + browserLocales, + locales, + defaultLocale, + cookieName, +}: LocaleOptions & { + preferredLocale?: string | null; + cookie: string; + browserLocales: readonly string[]; +}): Locale { + const preferred = matchLocale(preferredLocale, locales); + if (preferred) return preferred; + const saved = matchLocale(readLocaleCookie(cookie, cookieName), locales); + if (saved) return saved; + for (const browserLocale of browserLocales) { + const matched = matchLocale(browserLocale, locales); + if (matched) return matched; + } + return defaultLocale; +} + +export function serializeLocaleCookie(cookieName: string, locale: string, secure: boolean): string { + return `${cookieName}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`; +} diff --git a/packages/everything-dev/src/ui/message-catalogs.ts b/packages/everything-dev/src/ui/message-catalogs.ts new file mode 100644 index 00000000..135853aa --- /dev/null +++ b/packages/everything-dev/src/ui/message-catalogs.ts @@ -0,0 +1,11 @@ +export function createMessageCatalogs< + const Locales extends readonly string[], + const Entries extends Record, +>(locales: Locales, entries: Entries) { + return Object.fromEntries( + locales.map((locale, index) => [ + locale, + Object.fromEntries(Object.entries(entries).map(([id, messages]) => [id, messages[index]])), + ]), + ) as { [Locale in Locales[number]]: { [Id in keyof Entries]: string } }; +} diff --git a/packages/everything-dev/src/ui/router-client.tsx b/packages/everything-dev/src/ui/router-client.tsx index b52f08af..01932e0a 100644 --- a/packages/everything-dev/src/ui/router-client.tsx +++ b/packages/everything-dev/src/ui/router-client.tsx @@ -58,6 +58,7 @@ export function createRouter< cspNonce: opts.context.cspNonce, }), session: opts.context.session, + locale: opts.context.locale, }, ...(cspNonce ? { ssr: { nonce: cspNonce } } : {}), defaultPreload: "intent", diff --git a/packages/everything-dev/src/ui/router-error.tsx b/packages/everything-dev/src/ui/router-error.tsx index 81fdf84c..6e1d4371 100644 --- a/packages/everything-dev/src/ui/router-error.tsx +++ b/packages/everything-dev/src/ui/router-error.tsx @@ -4,7 +4,15 @@ * the app's UI kit, so primitives are plain markup + semantic tokens. */ -export function RouterError({ error, reset }: { error: Error; reset?: () => void }) { +export function RouterError({ + error, + reset, + messages, +}: { + error: Error; + reset?: () => void; + messages?: { title: string; body: string; home: string; retry: string }; +}) { return (
void
-

This page didn’t load

+

+ {messages?.title ?? "This page didn’t load"} +

- Something went wrong on our side. Try again, or head back home. + {messages?.body ?? "Something went wrong on our side. Try again, or head back home."}

@@ -29,7 +39,7 @@ export function RouterError({ error, reset }: { error: Error; reset?: () => void data-testid="router-error-home" className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground" > - Back home + {messages?.home ?? "Back home"}
diff --git a/packages/everything-dev/src/ui/router-server.tsx b/packages/everything-dev/src/ui/router-server.tsx index f3891b37..96291f26 100644 --- a/packages/everything-dev/src/ui/router-server.tsx +++ b/packages/everything-dev/src/ui/router-server.tsx @@ -19,6 +19,7 @@ import { } from "@tanstack/react-router/ssr/server"; import { createApiClient } from "./api"; import { createAuthClient } from "./auth"; +import { type LocaleOptions, readAcceptLanguage, resolveLocale } from "./locale"; import { collectHeadData } from "./router"; import { defaultNotFoundComponent, @@ -46,6 +47,8 @@ export interface ServerRouterModuleOptions QueryClient; + /** Locale negotiation for SSR — resolves the request-scoped locale into router context. */ + locale?: LocaleOptions; } type ServerRouterOptions = CreateRouterOptions & { @@ -86,6 +89,7 @@ export function createServerRouterModule }), session: context?.session, cspNonce, + locale: context?.locale, pluginNav: context?.pluginNav, }, ...(cspNonce ? { ssr: { nonce: cspNonce } } : {}), @@ -146,6 +150,7 @@ export function createServerRouterModule authClient: context?.authClient ?? createAuthClient({ runtimeConfig, cspNonce: context?.cspNonce }), session: context?.session, + locale: context?.locale, }, }); @@ -181,6 +186,13 @@ export function createServerRouterModule session: renderOptions.session, cspNonce: renderOptions.cspNonce, pluginNav: renderOptions.pluginNav, + locale: options.locale + ? resolveLocale({ + ...options.locale, + cookie: request.headers.get("cookie") ?? "", + browserLocales: readAcceptLanguage(request.headers.get("accept-language")), + }) + : undefined, }, }); queryClientRef = built.queryClient ?? localQueryClient; @@ -201,6 +213,8 @@ export function createServerRouterModule }), ); + if (options.locale) response.headers.append("Vary", "Cookie, Accept-Language"); + return { stream: response.body!, statusCode: response.status, diff --git a/packages/everything-dev/src/ui/runtime.ts b/packages/everything-dev/src/ui/runtime.ts index 49d8cd1e..095ef24f 100644 --- a/packages/everything-dev/src/ui/runtime.ts +++ b/packages/everything-dev/src/ui/runtime.ts @@ -29,6 +29,12 @@ export function getCspNonce() { return window.__CSP_NONCE__; } +export function getAppName(config?: Partial): string { + const runtimeConfig = + config ?? (typeof window === "undefined" ? undefined : window.__RUNTIME_CONFIG__); + return runtimeConfig?.runtime?.title ?? runtimeConfig?.account ?? ""; +} + export function buildRuntimeHref(pathname: string, runtimeConfig?: Partial) { const basePath = runtimeConfig?.runtime?.runtimeBasePath ?? "/"; if (basePath === "/") { diff --git a/packages/everything-dev/src/ui/tenant.ts b/packages/everything-dev/src/ui/tenant.ts index 836f4287..d2be4ba9 100644 --- a/packages/everything-dev/src/ui/tenant.ts +++ b/packages/everything-dev/src/ui/tenant.ts @@ -112,53 +112,69 @@ export interface TenantUiOverride { export const INTEGRITY_PATTERN = /^sha384-[A-Za-z0-9+/=]+$/; -const optionalUrl = z - .string() - .trim() - .url("must be a valid URL") - .or(z.literal("")) - .transform((value) => value.trim()); - -const optionalIntegrity = z - .string() - .trim() - .regex(INTEGRITY_PATTERN, "must look like sha384-… (base64)") - .or(z.literal("")); - -const optionalManifestName = z - .string() - .trim() - .regex(/^versions\/[0-9a-f]{16}\.json$/, "must look like versions/.json") - .or(z.literal("")); - -export const tenantConfigDraftSchema = z - .object({ - title: z.string().trim().min(1, "title is required"), - description: z.string().trim().min(1, "description is required"), - repository: optionalUrl, - uiProduction: optionalUrl, - uiIntegrity: optionalIntegrity, - uiManifest: optionalManifestName, - uiPinIntegrity: optionalIntegrity, - ssrUrl: optionalUrl, - ssrIntegrity: optionalIntegrity, - }) - .refine((draft) => !!draft.uiProduction === !!(draft.uiIntegrity || draft.uiManifest), { - message: "a UI bundle needs both its URL and its integrity (direct hash or manifest pin)", - path: ["uiIntegrity"], - }) - .refine((draft) => !draft.uiIntegrity || !draft.uiManifest, { - message: "choose one integrity mode — a direct entry hash or a version-manifest pin, not both", - path: ["uiManifest"], - }) - .refine((draft) => !!draft.uiManifest === !!draft.uiPinIntegrity, { - message: "a version-manifest pin needs both the manifest filename and its integrity", - path: ["uiPinIntegrity"], - }) - .refine((draft) => !!draft.ssrUrl === !!draft.ssrIntegrity, { - message: "an SSR bundle needs both its URL and its integrity hash", - path: ["ssrIntegrity"], - }); +export const defaultTenantConfigDraftMessages = { + url: "must be a valid URL", + integrity: "must look like sha384-… (base64)", + manifest: "must look like versions/.json", + title: "title is required", + description: "description is required", + uiPair: "a UI bundle needs both its URL and its integrity (direct hash or manifest pin)", + integrityMode: + "choose one integrity mode — a direct entry hash or a version-manifest pin, not both", + pinPair: "a version-manifest pin needs both the manifest filename and its integrity", + ssrPair: "an SSR bundle needs both its URL and its integrity hash", +}; + +export function createTenantConfigDraftSchema( + messages: Partial = {}, +) { + const copy = { ...defaultTenantConfigDraftMessages, ...messages }; + const optionalUrl = z + .string() + .trim() + .url(copy.url) + .or(z.literal("")) + .transform((value) => value.trim()); + const optionalIntegrity = z + .string() + .trim() + .regex(INTEGRITY_PATTERN, copy.integrity) + .or(z.literal("")); + const optionalManifestName = z + .string() + .trim() + .regex(/^versions\/[0-9a-f]{16}\.json$/, copy.manifest) + .or(z.literal("")); + return z + .object({ + title: z.string().trim().min(1, copy.title), + description: z.string().trim().min(1, copy.description), + repository: optionalUrl, + uiProduction: optionalUrl, + uiIntegrity: optionalIntegrity, + uiManifest: optionalManifestName, + uiPinIntegrity: optionalIntegrity, + ssrUrl: optionalUrl, + ssrIntegrity: optionalIntegrity, + }) + .refine((draft) => !!draft.uiProduction === !!(draft.uiIntegrity || draft.uiManifest), { + message: copy.uiPair, + path: ["uiIntegrity"], + }) + .refine((draft) => !draft.uiIntegrity || !draft.uiManifest, { + message: copy.integrityMode, + path: ["uiManifest"], + }) + .refine((draft) => !!draft.uiManifest === !!draft.uiPinIntegrity, { + message: copy.pinPair, + path: ["uiPinIntegrity"], + }) + .refine((draft) => !!draft.ssrUrl === !!draft.ssrIntegrity, { + message: copy.ssrPair, + path: ["ssrIntegrity"], + }); +} +export const tenantConfigDraftSchema = createTenantConfigDraftSchema(); export type TenantConfigDraft = z.infer; diff --git a/packages/everything-dev/src/ui/types.ts b/packages/everything-dev/src/ui/types.ts index 3623aca7..edebdff6 100644 --- a/packages/everything-dev/src/ui/types.ts +++ b/packages/everything-dev/src/ui/types.ts @@ -8,6 +8,7 @@ export interface RouterContext { runtimeConfig?: Partial; session?: TSession; cspNonce?: string; + locale?: string; /** nav manifest derived from composed routes' staticData.nav */ pluginNav?: NavManifest; } diff --git a/packages/everything-dev/src/utils/theme.ts b/packages/everything-dev/src/utils/theme.ts index 7ccd6d4b..17370f34 100644 --- a/packages/everything-dev/src/utils/theme.ts +++ b/packages/everything-dev/src/utils/theme.ts @@ -11,7 +11,7 @@ const truecolorRamp = const rgb = (code: string) => [1, 3, 5].map((i) => parseInt(code.slice(i, i + 2), 16)); const [r1, g1, b1] = rgb(from); const [r2, g2, b2] = rgb(to); - const chars = [...text]; + const chars = Array.from(text); const total = chars.filter((ch) => ch !== "\n").length; let seen = 0; return chars diff --git a/packages/everything-dev/tests/integration/aborted-train.test.ts b/packages/everything-dev/tests/integration/aborted-train.test.ts index 9ec96dd5..6dee3119 100644 --- a/packages/everything-dev/tests/integration/aborted-train.test.ts +++ b/packages/everything-dev/tests/integration/aborted-train.test.ts @@ -204,6 +204,16 @@ describe("an aborted deploy train is a no-op (ticket 05)", () => { beforeEach(async () => { storage = new MockStorage(); await storage.ready; + const originalFetch = globalThis.fetch.bind(globalThis); + vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => { + const inputUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(inputUrl); + if (url.origin === "https://cdn.example.test") { + return originalFetch(new URL(`${url.pathname}${url.search}`, storage.origin), init); + } + return originalFetch(input, init); + }); configDir = mkdtempSync(join(tmpdir(), "bos-aborted-train-")); savedEnv = { BOS_BUNDLE_CDN_ORIGIN: process.env.BOS_BUNDLE_CDN_ORIGIN, @@ -241,6 +251,7 @@ describe("an aborted deploy train is a no-op (ticket 05)", () => { }); afterEach(() => { + vi.unstubAllGlobals(); storage.close(); rmSync(configDir, { recursive: true, force: true }); for (const [key, value] of Object.entries(savedEnv)) { diff --git a/packages/everything-dev/tests/ui/api.test.ts b/packages/everything-dev/tests/ui/api.test.ts index b55d11dc..9e418118 100644 --- a/packages/everything-dev/tests/ui/api.test.ts +++ b/packages/everything-dev/tests/ui/api.test.ts @@ -10,8 +10,11 @@ import { const contextHolder = vi.hoisted(() => ({ context: {} as ClientRouterContext, + notifyError: vi.fn(), })); +vi.mock("sonner", () => ({ toast: { error: contextHolder.notifyError } })); + vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({ options: { context: contextHolder.context } }), })); @@ -43,6 +46,7 @@ function pingClient(client: unknown) { beforeEach(() => { stubFetch(); + contextHolder.notifyError.mockClear(); }); afterEach(() => { @@ -82,6 +86,47 @@ describe("browser singleton", () => { (globalThis as { window?: unknown }).window = originalWindow; }); + it("resolves connection notices when the request fails so language changes are respected", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + let language = "es"; + const connectionError = vi.fn(() => ({ + title: language === "es" ? "Conexión no disponible" : "Connexion indisponible", + description: language === "es" ? "Inténtalo de nuevo." : "Réessayez.", + })); + const client = createApiClient({ + hostUrl: "https://translated.test", + rpcBase: "/api/rpc", + connectionError, + }); + expect(connectionError).not.toHaveBeenCalled(); + await pingClient(client); + await vi.waitFor(() => + expect(contextHolder.notifyError).toHaveBeenLastCalledWith("Conexión no disponible", { + id: "api-connection-error", + description: "Inténtalo de nuevo.", + }), + ); + language = "fr"; + await pingClient(client); + await vi.waitFor(() => + expect(contextHolder.notifyError).toHaveBeenLastCalledWith("Connexion indisponible", { + id: "api-connection-error", + description: "Réessayez.", + }), + ); + }); + + it("keeps the default connection notice for apps without a message resolver", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch"))); + await pingClient(createApiClient({ hostUrl: "https://offline.test", rpcBase: "/api/rpc" })); + await vi.waitFor(() => + expect(contextHolder.notifyError).toHaveBeenCalledWith("Unable to connect to API", { + id: "api-connection-error", + description: "The API is currently unavailable. Please try again later.", + }), + ); + }); + it("memoizes per endpoint", () => { const first = createApiClient(cfg); const second = createApiClient(cfg); @@ -126,3 +171,35 @@ describe("context hooks", () => { expect(usePluginClients()).toEqual({}); }); }); + +describe("rate-limit retry", () => { + it("retries a 429 with backoff until the request succeeds", async () => { + let calls = 0; + globalThis.fetch = vi.fn(async () => { + calls += 1; + if (calls < 3) { + return new Response("{}", { status: 429, headers: { "retry-after": "0" } }); + } + return new Response(JSON.stringify({ json: "pong" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const clients = createServiceClients(cfg, ["api"]); + await pingClient(clients.api); + expect(calls).toBe(3); + }); + + it("gives up after the retry budget and surfaces the 429", async () => { + let calls = 0; + globalThis.fetch = vi.fn(async () => { + calls += 1; + return new Response("{}", { status: 429, headers: { "retry-after": "0" } }); + }) as unknown as typeof fetch; + + const clients = createServiceClients(cfg, ["api"]); + await expect((clients.api as { ping(): Promise }).ping()).rejects.toThrow(); + expect(calls).toBe(4); + }); +}); diff --git a/packages/everything-dev/tests/ui/auth-guards.test.ts b/packages/everything-dev/tests/ui/auth-guards.test.ts index 581b08a1..5b2fd0db 100644 --- a/packages/everything-dev/tests/ui/auth-guards.test.ts +++ b/packages/everything-dev/tests/ui/auth-guards.test.ts @@ -25,7 +25,7 @@ const bannedSession = { session: { id: "session-1", activeOrganizationId: null }, } as unknown as SessionData; -function createAuthClientMock(responses: Array): AuthClient { +function createAuthClientMock(responses: Array): AuthClient { let call = 0; return { getSession: vi.fn(async () => { @@ -41,7 +41,7 @@ function createContext(queryClient: QueryClient, authClient: AuthClient) { queryClient, authClient, apiClient: {} as ApiClient, - session: undefined as SessionData | null | undefined, + session: undefined as SessionData, }; } diff --git a/packages/everything-dev/tests/ui/hydrate.test.tsx b/packages/everything-dev/tests/ui/hydrate.test.tsx index c1889b68..64698de1 100644 --- a/packages/everything-dev/tests/ui/hydrate.test.tsx +++ b/packages/everything-dev/tests/ui/hydrate.test.tsx @@ -8,6 +8,7 @@ const bootstrap = vi.hoisted(() => ({ createRouter: vi.fn(() => ({ router: {} })), render: vi.fn(), hydrateRoot: vi.fn(), + toastError: vi.fn(), coreRouteConfig: { routeConfigLoaders: {}, rootMeta: { head: () => ({ meta: [{ name: "core", content: "1" }] }) }, @@ -34,6 +35,8 @@ vi.mock("react-dom/client", () => ({ hydrateRoot: bootstrap.hydrateRoot, })); +vi.mock("sonner", () => ({ toast: { error: bootstrap.toastError } })); + const composeMocks = vi.hoisted(() => ({ loadRemote: vi.fn(), registerRemotes: vi.fn(), @@ -121,11 +124,30 @@ const runHydrate = async (config: Record) => { }; describe("client bootstrap", () => { + it("forwards the SSR document language into the composed router context", async () => { + const previousLocale = document.documentElement.lang; + document.documentElement.lang = "fr"; + document.documentElement.setAttribute("data-everything-ssr", ""); + try { + await runHydrate(bootstrap.config); + expect(bootstrap.createRouter).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ locale: "fr" }), + }), + ); + } finally { + document.documentElement.lang = previousLocale; + } + }); + it("rejects missing config before loading the router and can retry", async () => { bootstrap.config.hostUrl = ""; await expect(runHydrate(bootstrap.config)).rejects.toThrow("Missing hostUrl or rpcBase"); expect(bootstrap.routerLoads).toBe(0); expect(bootstrap.createRouter).not.toHaveBeenCalled(); + expect( + document.querySelector('[data-testid="application-startup-error"]')?.textContent, + ).toContain("Reload"); expect(window.__EVERYTHING_DEV_HYDRATE_PROMISE__).toBeUndefined(); bootstrap.config.hostUrl = "https://example.test"; @@ -333,6 +355,13 @@ describe("client bootstrap", () => { // the page client-renders the degraded tree. expect(bootstrap.hydrateRoot).not.toHaveBeenCalled(); expect(bootstrap.render).toHaveBeenCalledOnce(); + expect(bootstrap.toastError).toHaveBeenCalledWith( + "Some application features couldn't load", + expect.objectContaining({ + duration: Number.POSITIVE_INFINITY, + action: expect.objectContaining({ label: "Reload" }), + }), + ); }); it("drops only the failed remote and composes the healthy subset", async () => { @@ -416,5 +445,6 @@ describe("client bootstrap", () => { ); expect(bootstrap.hydrateRoot).not.toHaveBeenCalled(); expect(bootstrap.render).toHaveBeenCalledOnce(); + expect(bootstrap.toastError).toHaveBeenCalledOnce(); }); }); diff --git a/packages/everything-dev/tests/ui/i18n-browser.test.tsx b/packages/everything-dev/tests/ui/i18n-browser.test.tsx new file mode 100644 index 00000000..53115bef --- /dev/null +++ b/packages/everything-dev/tests/ui/i18n-browser.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createLocaleRuntime, readLocaleCookie } from "../../src/ui/i18n"; + +const locales = ["en", "es", "fr", "zh"] as const; +let container: HTMLDivElement; +let root: Root; +let sequence = 0; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("language persistence across browser bundles", () => { + it("shares account selection with the auth bundle, cookie, and document language", async () => { + const cookieName = `bundle_language_${sequence++}`; + const main = createLocaleRuntime({ locales, defaultLocale: "en", cookieName }); + const auth = createLocaleRuntime({ locales, defaultLocale: "en", cookieName }); + const save = vi.fn(async () => undefined); + let select: ReturnType["selectLocale"]; + function AuthSettings() { + const language = auth.useLocale(); + select = language.selectLocale; + return {language.locale}; + } + await act(async () => + root.render( + ({})} + initialLocale="es" + preferredLocale="fr" + onLocaleChange={save} + > + ({})}> + + + , + ), + ); + expect(container.textContent).toBe("fr"); + expect(readLocaleCookie(document.cookie, cookieName)).toBe("fr"); + await act(async () => select("zh")); + expect(save).toHaveBeenCalledExactlyOnceWith("zh"); + expect(container.textContent).toBe("zh"); + expect(main.getLocale()).toBe("zh"); + expect(document.documentElement.lang).toBe("zh"); + expect(readLocaleCookie(document.cookie, cookieName)).toBe("zh"); + }); + + it("keeps the previous language and cookie when saving the account preference fails", async () => { + const cookieName = `failed_language_${sequence++}`; + const runtime = createLocaleRuntime({ locales, defaultLocale: "en", cookieName }); + document.cookie = `${cookieName}=es; Path=/`; + let select: ReturnType["selectLocale"]; + function Settings() { + const language = runtime.useLocale(); + select = language.selectLocale; + return {language.locale}; + } + await act(async () => + root.render( + ({})} + onLocaleChange={async () => { + throw new Error("save failed"); + }} + > + + , + ), + ); + await act(async () => expect(select("fr")).rejects.toThrow("save failed")); + expect(container.textContent).toBe("es"); + expect(readLocaleCookie(document.cookie, cookieName)).toBe("es"); + }); + + it("keeps the last account language after signing out and remounting", async () => { + const cookieName = `signed_out_language_${sequence++}`; + const runtime = createLocaleRuntime({ locales, defaultLocale: "en", cookieName }); + document.cookie = `${cookieName}=es; Path=/`; + function Settings() { + return {runtime.useLocale().locale}; + } + await act(async () => + root.render( + ({})} preferredLocale="fr"> + + , + ), + ); + await act(async () => + root.render( + ({})}> + + , + ), + ); + expect(container.textContent).toBe("fr"); + expect(readLocaleCookie(document.cookie, cookieName)).toBe("fr"); + const reloaded = createLocaleRuntime({ locales, defaultLocale: "en", cookieName }); + expect(reloaded.detectLocale()).toBe("fr"); + }); +}); diff --git a/packages/everything-dev/tests/ui/i18n.test.ts b/packages/everything-dev/tests/ui/i18n.test.ts index 0b4cfeaf..11e6621d 100644 --- a/packages/everything-dev/tests/ui/i18n.test.ts +++ b/packages/everything-dev/tests/ui/i18n.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { createLocaleRuntime, matchLocale, + readAcceptLanguage, readLocaleCookie, resolveLocale, serializeLocaleCookie, @@ -18,24 +19,33 @@ function resolve(overrides: Partial[0]> = {}) { browserLocales: [], locales, defaultLocale: "en", - cookieName: "app_locale", + cookieName: "citynode_locale", ...overrides, }); } describe("shared locale resolution", () => { + it("honors quality preferences and excludes rejected header languages", () => { + expect(readAcceptLanguage("de;q=0, fr-CA;q=0.7, es;q=0.9, en;q=invalid")).toEqual([ + "es", + "fr-CA", + ]); + expect(resolve({ browserLocales: readAcceptLanguage("fr-CA, es;q=0.8") })).toBe("fr"); + }); it("uses account preference before cookie and browser language", () => { expect( resolve({ preferredLocale: "zh-CN", - cookie: "app_locale=fr", + cookie: "citynode_locale=fr", browserLocales: ["es-MX"], }), ).toBe("zh"); }); it("uses a persisted locale before browser language", () => { - expect(resolve({ cookie: "theme=dark; app_locale=fr", browserLocales: ["es-MX"] })).toBe("fr"); + expect(resolve({ cookie: "theme=dark; citynode_locale=fr", browserLocales: ["es-MX"] })).toBe( + "fr", + ); }); it("matches the first supported browser language", () => { @@ -43,8 +53,8 @@ describe("shared locale resolution", () => { }); it("falls back to English for unsupported or malformed preferences", () => { - expect(resolve({ cookie: "app_locale=invalid", browserLocales: ["de-DE"] })).toBe("en"); - expect(resolve({ cookie: "app_locale=%E0%A4%A", browserLocales: [] })).toBe("en"); + expect(resolve({ cookie: "citynode_locale=invalid", browserLocales: ["de-DE"] })).toBe("en"); + expect(resolve({ cookie: "citynode_locale=%E0%A4%A", browserLocales: [] })).toBe("en"); }); }); @@ -55,15 +65,61 @@ describe("shared locale persistence", () => { }); it("reads and serializes the locale cookie", () => { - expect(readLocaleCookie("theme=dark; app_locale=zh", "app_locale")).toBe("zh"); - expect(serializeLocaleCookie("app_locale", "es", false)).toBe( - "app_locale=es; Path=/; Max-Age=31536000; SameSite=Lax", + expect(readLocaleCookie("theme=dark; citynode_locale=zh", "citynode_locale")).toBe("zh"); + expect(serializeLocaleCookie("citynode_locale", "es", false)).toBe( + "citynode_locale=es; Path=/; Max-Age=31536000; SameSite=Lax", ); - expect(serializeLocaleCookie("app_locale", "es", true)).toContain("; Secure"); + expect(serializeLocaleCookie("citynode_locale", "es", true)).toContain("; Secure"); }); }); describe("shared locale provider", () => { + it("renders the request locale and account preference without leaking between requests", () => { + const runtime = createLocaleRuntime({ + locales, + defaultLocale: "en", + cookieName: "server_locale", + }); + function Greeting() { + const t = runtime.useTranslation<"greeting">(); + return createElement("span", null, t("greeting")); + } + const messages = (locale: string) => ({ + greeting: locale === "fr" ? "Bonjour" : locale === "es" ? "Hola" : "Hello", + }); + const render = (initialLocale: string, preferredLocale?: string) => + renderToString( + createElement( + runtime.LocaleProvider, + { messages, initialLocale, preferredLocale }, + createElement(Greeting), + ), + ); + expect(render("fr")).toContain("Bonjour"); + expect(render("fr", "es")).toContain("Hola"); + expect(render("en")).toContain("Hello"); + }); + + it("shares the server locale across separately configured bundle providers", () => { + const main = createLocaleRuntime({ locales, defaultLocale: "en", cookieName: "bundle_locale" }); + const auth = createLocaleRuntime({ locales, defaultLocale: "en", cookieName: "bundle_locale" }); + function AuthGreeting() { + const t = auth.useTranslation<"greeting">(); + return createElement("span", null, t("greeting")); + } + const html = renderToString( + createElement( + main.LocaleProvider, + { initialLocale: "fr", messages: () => ({}) }, + createElement( + auth.LocaleProvider, + { messages: (locale) => ({ greeting: locale === "fr" ? "Bonjour" : "Hello" }) }, + createElement(AuthGreeting), + ), + ), + ); + expect(html).toContain("Bonjour"); + }); it("renders when the server runtime exposes navigator without language preferences", () => { const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} }); @@ -72,7 +128,7 @@ describe("shared locale provider", () => { const runtime = createLocaleRuntime({ locales, defaultLocale: "en", - cookieName: "app_locale", + cookieName: "citynode_locale", }); const html = renderToString( createElement( diff --git a/packages/everything-dev/tests/ui/router-locale.test.tsx b/packages/everything-dev/tests/ui/router-locale.test.tsx new file mode 100644 index 00000000..ff9cf3ef --- /dev/null +++ b/packages/everything-dev/tests/ui/router-locale.test.tsx @@ -0,0 +1,64 @@ +import { createRootRouteWithContext, createRoute, Outlet } from "@tanstack/react-router"; +import { describe, expect, it } from "vitest"; +import { createServerRouterModule } from "../../src/ui/router-server"; +import type { RenderOptionsWithApi, RouterContextWithApi } from "../../src/ui/types"; + +const root = createRootRouteWithContext()({ + component: function Document() { + const { locale } = root.useRouteContext(); + return ( + + + + + + + ); + }, +}); +const home = createRoute({ getParentRoute: () => root, path: "/", component: () =>

Home

}); +const about = createRoute({ + getParentRoute: () => root, + path: "/about", + component: () =>

About

, +}); +const module = createServerRouterModule({ + defaultRouteTree: root.addChildren([home, about]), + locale: { locales: ["en", "es", "fr", "zh"], defaultLocale: "en", cookieName: "citynode_locale" }, +}); +const options = { + runtimeConfig: { + hostUrl: "http://localhost", + rpcBase: "/api", + authBase: "/api/auth", + networkId: "mainnet", + auth: { variables: { siwn: { recipient: "app.near" } } }, + }, + session: null, +} as RenderOptionsWithApi; + +async function render(path: string, headers: HeadersInit) { + const result = await module.renderToStream( + new Request(`http://localhost${path}`, { headers }), + options, + ); + expect(result.headers.get("Vary")).toContain("Cookie"); + expect(result.headers.get("Vary")).toContain("Accept-Language"); + return new Response(result.stream).text(); +} + +describe("request-scoped SSR locale", () => { + it("renders the requested route and cookie language independently for concurrent requests", async () => { + const [french, spanish, chinese] = await Promise.all([ + render("/about", { cookie: "citynode_locale=fr", "accept-language": "es" }), + render("/", { "accept-language": "es-MX, en;q=0.5" }), + render("/about", { cookie: "citynode_locale=zh" }), + ]); + expect(french).toContain(' { + it("does not cache a transient failure as a signed-out session and recovers on retry", async () => { + const session = { user: { id: "member" }, session: { activeOrganizationId: "org" } }; + const getSession = vi + .fn() + .mockResolvedValueOnce({ data: null, error: { message: "Auth temporarily unavailable" } }) + .mockResolvedValueOnce({ data: session, error: null }); + const auth = { getSession } as unknown as AuthClient; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await expect(client.fetchQuery(sessionQueryOptions(auth))).rejects.toThrow( + "Auth temporarily unavailable", + ); + expect(client.getQueryData(sessionQueryKey)).toBeUndefined(); + await client.fetchQuery(sessionQueryOptions(auth)); + expect(client.getQueryData(sessionQueryKey)).toEqual(session); + client.clear(); + }); + + it("preserves the current organization when a background session read fails", async () => { + const session = { user: { id: "member" }, session: { activeOrganizationId: "org" } }; + const auth = { + getSession: async () => ({ data: null, error: { message: "Network failure" } }), + } as unknown as AuthClient; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(sessionQueryKey, session); + await expect(client.fetchQuery({ ...sessionQueryOptions(auth), staleTime: 0 })).rejects.toThrow( + "Network failure", + ); + expect(client.getQueryData(sessionQueryKey)).toEqual(session); + client.clear(); + }); +}); diff --git a/packages/everything-dev/tests/ui/tenant.test.ts b/packages/everything-dev/tests/ui/tenant.test.ts index 66584bcd..a8767425 100644 --- a/packages/everything-dev/tests/ui/tenant.test.ts +++ b/packages/everything-dev/tests/ui/tenant.test.ts @@ -6,6 +6,7 @@ import { computeSsrEntryIntegrity, computeSubresourceIntegrity, computeUiEntryIntegrity, + createTenantConfigDraftSchema, diffDraft, draftUiOverride, emptyTenantConfigDraft, @@ -160,6 +161,40 @@ describe("gatewayForAccount", () => { }); describe("tenantConfigDraftSchema", () => { + it("uses localized pin validation while preserving bundle constraints", () => { + const schema = createTenantConfigDraftSchema({ + manifest: "Formato de manifiesto no válido", + pinPair: "Falta la integridad del manifiesto", + integrityMode: "Elige un modo de integridad", + }); + const draft = { + ...emptyTenantConfigDraft, + title: "Chicago", + description: "Chicago", + uiProduction: "https://cdn.example.com/ui", + uiManifest: "versions/8f3ac1d2feedbeef.json", + }; + const pin = schema.safeParse(draft); + expect(pin.error?.issues).toContainEqual( + expect.objectContaining({ + path: ["uiPinIntegrity"], + message: "Falta la integridad del manifiesto", + }), + ); + const malformed = schema.safeParse({ ...draft, uiManifest: "versions/invalid.json" }); + expect(malformed.error?.issues).toContainEqual( + expect.objectContaining({ path: ["uiManifest"], message: "Formato de manifiesto no válido" }), + ); + const mixed = schema.safeParse({ + ...draft, + uiPinIntegrity: "sha384-pin", + uiIntegrity: "sha384-direct", + }); + expect(mixed.error?.issues).toContainEqual( + expect.objectContaining({ path: ["uiManifest"], message: "Elige un modo de integridad" }), + ); + }); + it("accepts a metadata-only draft", () => { const result = tenantConfigDraftSchema.safeParse({ ...emptyTenantConfigDraft, @@ -431,7 +466,7 @@ describe("integrity preflight", () => { const stubBundle = (content: string) => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url.includes("remoteEntry.server.js")) return new Response(`${content}-server`); return new Response(content); }); @@ -473,7 +508,7 @@ describe("integrity preflight", () => { it("verifies a pinned slot by SRI-comparing the manifest document", async () => { const manifestBody = JSON.stringify({ entry: "remoteEntry.aaa.js" }); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url.endsWith("versions/8f3ac1d2feedbeef.json")) { return new Response(manifestBody); } @@ -502,7 +537,7 @@ describe("integrity preflight", () => { it("rejects the html landing page a bare base url serves", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; if (url === "https://cdn.example.com/ui/remoteEntry.js") return new Response("console.log('hi')"); return new Response("landing page"); diff --git a/packages/everything-dev/tests/unit/auth-login.test.ts b/packages/everything-dev/tests/unit/auth-login.test.ts index 2b6dc338..b14b6fc0 100644 --- a/packages/everything-dev/tests/unit/auth-login.test.ts +++ b/packages/everything-dev/tests/unit/auth-login.test.ts @@ -108,17 +108,21 @@ describe("device login client", () => { expect(approval.accountId).toBe("alice.near"); const claim = calls.find((call) => call.url.includes("/device-link/claim")); - expect(JSON.parse(String(claim?.init.body ?? "{}"))).toMatchObject({ - token: "session-token-1", - client_id: "bos-cli", - }); + expect(JSON.parse((await new Response(claim?.init.body ?? null).text()) || "{}")).toMatchObject( + { + token: "session-token-1", + client_id: "bos-cli", + }, + ); const keyCreate = calls.find((call) => call.url.includes("/api-key/create")); const keyHeaders = new Headers(keyCreate?.init.headers); expect(keyHeaders.get("cookie")).toBe(CLAIMED_COOKIE); expect(keyHeaders.get("origin")).toBe(SITE); - const keyBody = JSON.parse(String(keyCreate?.init.body ?? "{}")) as { + const keyBody = JSON.parse( + (await new Response(keyCreate?.init.body ?? null).text()) || "{}", + ) as { configId: string; name: string; expiresIn: number; diff --git a/packages/everything-dev/tests/unit/config-resolved.test.ts b/packages/everything-dev/tests/unit/config-resolved.test.ts index eae92e79..434ba496 100644 --- a/packages/everything-dev/tests/unit/config-resolved.test.ts +++ b/packages/everything-dev/tests/unit/config-resolved.test.ts @@ -13,6 +13,7 @@ import { writeResolvedConfig, } from "../../src/config"; import { openResolution } from "../../src/resolution/session"; +import { BosConfigInputSchema } from "../../src/types"; vi.mock("../../src/version-manifest-resolve", () => ({ resolveSlotVersion: vi.fn(async () => ({ @@ -55,6 +56,16 @@ vi.mock("../../src/http-client", async (importOriginal) => { }; }); +describe("authored staging configuration", () => { + it("preserves the staging account used by the key publication prompt", () => { + const parsed = BosConfigInputSchema.parse({ + account: "production.near", + staging: { domain: "staging.example.test", account: "staging.near" }, + }); + expect(parsed.staging).toEqual({ domain: "staging.example.test", account: "staging.near" }); + }); +}); + describe("findConfigPath cache", () => { afterEach(() => { resetConfigPathCache(); diff --git a/packages/everything-dev/tests/unit/db-run-migrations.test.ts b/packages/everything-dev/tests/unit/db-run-migrations.test.ts index 8cbf9800..b9e96cfb 100644 --- a/packages/everything-dev/tests/unit/db-run-migrations.test.ts +++ b/packages/everything-dev/tests/unit/db-run-migrations.test.ts @@ -1,7 +1,7 @@ import { PGlite } from "@electric-sql/pglite"; import { sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/pglite"; -import { Effect } from "effect"; +import { Cause, Effect, Exit } from "effect"; import { describe, expect, it } from "vitest"; import { migrate as apiMigrate } from "../../../../api/src/db/migrate"; import { migrate as proposalsMigrate } from "../../../../plugins/proposals/src/db/migrate"; @@ -23,6 +23,8 @@ type Runner = ( schemaName?: string, ) => Effect.Effect; +const apiMigrationRunner: Runner = apiMigrate; + function migration(idx: number, tag: string, statements: string[]): Migration { return { idx, @@ -40,6 +42,17 @@ function makeDb() { const JOURNAL = { schema: "drizzle", table: "__drizzle_migrations", slug: "test" } as const; +async function runMigration(effect: Effect.Effect): Promise { + const exit = await Effect.runPromiseExit(effect); + if (Exit.isSuccess(exit)) return exit.value; + throw Cause.squash(exit.cause); +} + +function runApiMigration(db: MigrationDatabase, migrations: Migration[]): Promise { + const effect = apiMigrationRunner(db, migrations, JOURNAL); + return runMigration(effect); +} + describe("db migration runners (008 characterization)", () => { it("fresh schema: sequential migrations apply, journal tracks hashes, rerun is a no-op", async () => { const db = makeDb(); @@ -51,10 +64,10 @@ describe("db migration runners (008 characterization)", () => { ]), ]; - const applied = await Effect.runPromise(apiMigrate(db as never, migrations, JOURNAL) as never); + const applied = await runApiMigration(db as unknown as MigrationDatabase, migrations); expect(applied).toBe(2); - const rerun = await Effect.runPromise(apiMigrate(db as never, migrations, JOURNAL) as never); + const rerun = await runApiMigration(db as unknown as MigrationDatabase, migrations); expect(rerun).toBe(0); const rows = (await db.execute( @@ -76,7 +89,7 @@ describe("db migration runners (008 characterization)", () => { ]), ]; - const applied = await Effect.runPromise(runner(db, migrations, JOURNAL)); + const applied = await runMigration(runner(db, migrations, JOURNAL)); expect(applied).toBe(1); const exists = (await db.execute( @@ -95,7 +108,7 @@ describe("db migration runners (008 characterization)", () => { migration(1, "fresh", ['CREATE TABLE "t_fresh" (id int)']), ]; - const applied = await Effect.runPromise(apiMigrate(db as never, migrations, JOURNAL) as never); + const applied = await runApiMigration(db as unknown as MigrationDatabase, migrations); expect(applied).toBe(2); const journal = (await db.execute( diff --git a/packages/everything-dev/tests/unit/docker-autostart.test.ts b/packages/everything-dev/tests/unit/docker-autostart.test.ts index 9a82ca7e..eec6e09b 100644 --- a/packages/everything-dev/tests/unit/docker-autostart.test.ts +++ b/packages/everything-dev/tests/unit/docker-autostart.test.ts @@ -1,7 +1,29 @@ -import { describe, expect, it } from "vitest"; -import { isDockerTestMode, shouldAutoStartDocker, takeTail } from "../../src/infra/docker"; +import { describe, expect, it, vi } from "vitest"; +import { + isDockerTestMode, + runDockerComposeUp, + shouldAutoStartDocker, + takeTail, +} from "../../src/infra/docker"; import type { PreflightFailure } from "../../src/infra/preflight"; +const command = vi.hoisted(() => vi.fn()); +vi.mock("execa", () => ({ execa: command })); + +describe("runDockerComposeUp", () => { + it("preserves line boundaries and failure diagnostics from both output streams", async () => { + command.mockResolvedValue({ + exitCode: 1, + stdout: "Starting database\n", + stderr: "\u001b[31mDatabase failed\u001b[0m\nCheck credentials\n", + }); + expect(await runDockerComposeUp("/test/project")).toEqual({ + ok: false, + tail: "Starting database\nDatabase failed\nCheck credentials", + }); + }); +}); + const unreachable = (secret: string): PreflightFailure => ({ secret, host: "localhost", diff --git a/packages/everything-dev/tests/unit/process-kill.test.ts b/packages/everything-dev/tests/unit/process-kill.test.ts index bd832a5b..e9d65404 100644 --- a/packages/everything-dev/tests/unit/process-kill.test.ts +++ b/packages/everything-dev/tests/unit/process-kill.test.ts @@ -13,7 +13,12 @@ import { killProcessGroupEscalating, reapGroup } from "../../src/process-kill"; import { isPidAlive } from "../../src/process-registry"; let tempDir = mkdtempSync(join(tmpdir(), "process-kill-")); -const cleanup: string[] = []; +const cleanup: number[] = []; + +function processGroupId(pid: number | undefined): number { + if (pid === undefined) throw new Error("Child process did not expose a pid"); + return -pid; +} const fakeLsof = (script: string): string => { const path = join(tempDir, `lsof-${Math.random().toString(36).slice(2)}`); @@ -23,7 +28,7 @@ const fakeLsof = (script: string): string => { const spawnSleeper = (): { pid: number; wait: Promise } => { const child = spawn("sleep", ["300"], { detached: true, stdio: "ignore" }); - cleanup.push(-child.pid); + cleanup.push(processGroupId(child.pid)); return { pid: child.pid, wait: new Promise((resolve) => child.once("exit", resolve)), @@ -119,7 +124,7 @@ describe("process-kill escalation", () => { const path = join(tempDir, `parent-${Date.now()}.sh`); writeFileSync(path, script, { mode: 0o755 }); const parent = spawn(path, { detached: true, stdio: "ignore" }); - cleanup.push(-parent.pid); + cleanup.push(processGroupId(parent.pid)); await new Promise((resolve) => setTimeout(resolve, 300)); const members = await groupMembersOf(parent.pid); expect(members.length).toBeGreaterThanOrEqual(2); @@ -155,7 +160,7 @@ function spawnSleeperWithSigtermIgnored(): { pid: number; wait: Promise } const path = join(tempDir, `sleeper-${Date.now()}.sh`); writeFileSync(path, script, { mode: 0o755 }); const child = spawn(path, { detached: true, stdio: "ignore" }); - cleanup.push(-child.pid); + cleanup.push(processGroupId(child.pid)); return { pid: child.pid, wait: new Promise((resolve) => child.once("exit", resolve)), diff --git a/packages/everything-dev/tests/unit/publish-pin-verification.test.ts b/packages/everything-dev/tests/unit/publish-pin-verification.test.ts new file mode 100644 index 00000000..ac501a4f --- /dev/null +++ b/packages/everything-dev/tests/unit/publish-pin-verification.test.ts @@ -0,0 +1,159 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BosConfig } from "../../src/types"; + +const { + verifyRollbackSnapshotMock, + fetchBosConfigFromFastKvMock, + resolveSigningStrategyMock, + submitRegistryWriteMock, + openResolutionMock, + buildWorkspaceTargetsMock, +} = vi.hoisted(() => ({ + verifyRollbackSnapshotMock: vi.fn(), + fetchBosConfigFromFastKvMock: vi.fn(), + resolveSigningStrategyMock: vi.fn(), + submitRegistryWriteMock: vi.fn(), + openResolutionMock: vi.fn(), + buildWorkspaceTargetsMock: vi.fn(), +})); + +vi.mock("../../src/rollback", () => ({ + verifyRollbackSnapshot: verifyRollbackSnapshotMock, +})); + +vi.mock("../../src/fastkv", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchBosConfigFromFastKv: fetchBosConfigFromFastKvMock }; +}); + +vi.mock("../../src/near-signer", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSigningStrategy: resolveSigningStrategyMock, + submitRegistryWrite: submitRegistryWriteMock, + }; +}); + +vi.mock("../../src/resolution/session", () => ({ + openResolution: openResolutionMock, +})); + +vi.mock("../../src/build", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, buildWorkspaceTargets: buildWorkspaceTargetsMock }; +}); + +import { publishToFastKv } from "../../src/publish"; + +const bosConfig = { + account: "dev.everything.near", + domain: "dev.everything.dev", + cdn: { origin: "https://cdn.example.test" }, + app: { + host: { + development: "local:host", + production: "https://host.example", + pin: { manifest: "versions/aaaaaaaaaaaaaaaa.json", integrity: "sha384-host" }, + }, + ui: { development: "local:ui", production: "https://ui.example" }, + api: { development: "local:api", production: "https://api.example" }, + }, + plugins: { + votes: { + development: "local:plugins/votes", + production: "https://votes.example", + }, + }, +} as BosConfig; + +const baseInput = { + bosConfig, + runtimeConfig: null, + env: "production" as const, + build: false, + dryRun: false, + verbose: false, + packages: "", + privateKey: "ed25519:0000000000000000000000000000000000000000000000000000000000000000", +}; + +let configDir: string; + +describe("publishToFastKv pin verification gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + configDir = mkdtempSync(join(tmpdir(), "bos-pin-verify-")); + writeFileSync(join(configDir, "bos.config.json"), JSON.stringify(bosConfig, null, 2)); + buildWorkspaceTargetsMock.mockResolvedValue({ built: [], skipped: [], deployResults: [] }); + openResolutionMock.mockResolvedValue({ config: bosConfig, rawConfig: bosConfig }); + fetchBosConfigFromFastKvMock.mockResolvedValue(JSON.parse(JSON.stringify(bosConfig))); + resolveSigningStrategyMock.mockResolvedValue({ + strategy: "near-kit", + privateKey: "ed25519:test", + source: "provided", + }); + }); + + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("blocks the publish when a pinned slot's bytes fail verification", async () => { + verifyRollbackSnapshotMock.mockResolvedValue({ + verifiable: true, + ok: false, + slots: [ + { + slot: "plugins.registry", + manifest: "versions/309a65a9735d13e7.json", + ok: false, + reason: "manifest fetch failed: 404", + }, + ], + }); + + const result = await publishToFastKv({ ...baseInput, configDir }); + + expect(result.status).toBe("error"); + expect(result.error).toContain("Publish blocked"); + expect(result.error).toContain("plugins.registry"); + expect(result.error).toContain("manifest fetch failed: 404"); + expect(verifyRollbackSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ account: "dev.everything.near" }), + ); + expect(submitRegistryWriteMock).not.toHaveBeenCalled(); + }); + + it("publishes when every pinned slot verifies", async () => { + verifyRollbackSnapshotMock.mockResolvedValue({ + verifiable: true, + ok: true, + slots: [ + { + slot: "app.host", + manifest: "versions/aaaaaaaaaaaaaaaa.json", + ok: true, + }, + ], + }); + + const result = await publishToFastKv({ ...baseInput, configDir }); + + expect(result.status).toBe("published"); + expect(submitRegistryWriteMock).not.toHaveBeenCalled(); + }); + + it("a config with no pins skips the gate", async () => { + verifyRollbackSnapshotMock.mockResolvedValue({ verifiable: false, ok: false, slots: [] }); + + const result = await publishToFastKv({ ...baseInput, configDir }); + + expect(result.status).toBe("published"); + expect(submitRegistryWriteMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/everything-dev/tests/unit/resolution-session.test.ts b/packages/everything-dev/tests/unit/resolution-session.test.ts index 91173df2..5b8551e6 100644 --- a/packages/everything-dev/tests/unit/resolution-session.test.ts +++ b/packages/everything-dev/tests/unit/resolution-session.test.ts @@ -200,7 +200,7 @@ describe("openResolution", () => { production: "https://stage.test/host", pin: { manifest: `versions/${hostEntry}.json`, - integrity: `sha384-${await createHash("sha384").update(JSON.stringify(hostManifest)).digest("base64")}`, + integrity: `sha384-${createHash("sha384").update(JSON.stringify(hostManifest)).digest("base64")}`, }, }, }, diff --git a/packages/everything-dev/tests/unit/storage-upload.test.ts b/packages/everything-dev/tests/unit/storage-upload.test.ts index e1959188..b5f7655c 100644 --- a/packages/everything-dev/tests/unit/storage-upload.test.ts +++ b/packages/everything-dev/tests/unit/storage-upload.test.ts @@ -64,7 +64,7 @@ describe("uploadBundle", () => { const headers = new Headers(captured!.init.headers); expect(headers.get("x-api-key")).toBe("edk_test"); expect(headers.get("content-type")).toBe("application/json"); - const body = JSON.parse(String(captured!.init.body)); + const body = JSON.parse(await new Response(captured!.init.body).text()); expect(body.account).toBe("v1.citynode.near"); expect(body.files[0].contentBase64).toBe(Buffer.from("entry").toString("base64")); expect(result.integrity["remoteEntry.js"]).toBe("sha384-abc"); @@ -219,7 +219,7 @@ describe("uploadWorkspaceDist", () => { it("batches files across requests under the per-request ceiling and merges integrity", async () => { const calls: number[][] = []; globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { - const body = JSON.parse(String(init!.body)); + const body = JSON.parse(await new Response(init!.body).text()); calls.push(body.files.map((f: { path: string }) => f.path.length)); return new Response( JSON.stringify({ diff --git a/tests/regression/http/security_headers_test.go b/tests/regression/http/security_headers_test.go index 651c385c..d05fba5f 100644 --- a/tests/regression/http/security_headers_test.go +++ b/tests/regression/http/security_headers_test.go @@ -118,46 +118,58 @@ func TestRateLimiting(t *testing.T) { time.Sleep(1200 * time.Millisecond) // Fire 150 requests concurrently so they all land inside a single 1000ms - // sliding window. The regression env sets RATE_LIMIT_MAX=100, so the burst - // must trip it. 150 > 100 guarantees at least one 429. + // window. The regression env pins RATE_LIMIT_WINDOW_MS=1000 and + // RATE_LIMIT_MAX=100. const workers = 10 const perWorker = 15 - var ( - mu sync.Mutex - got200, got429 bool - lastRateLimitBody string - wg sync.WaitGroup - ) - - // Use a plain client (no cookie jar) per worker; http.Client is safe for - // concurrent use but jars are not. - for w := 0; w < workers; w++ { - wg.Add(1) - go func() { - defer wg.Done() - plain := &http.Client{} - for i := 0; i < perWorker; i++ { - resp, err := plain.Get(baseURL + "/health") - if err != nil { - continue + // burst fires `path` concurrently with plain (no cookie jar) clients and + // records which statuses were observed. + burst := func(path string) (got200, got429 bool, lastRateLimitBody string) { + var ( + mu sync.Mutex + wg sync.WaitGroup + ) + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + plain := &http.Client{} + for i := 0; i < perWorker; i++ { + resp, err := plain.Get(baseURL + path) + if err != nil { + continue + } + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + mu.Lock() + if resp.StatusCode == 200 { + got200 = true + } + if resp.StatusCode == 429 { + got429 = true + lastRateLimitBody = string(b) + } + mu.Unlock() } - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - mu.Lock() - if resp.StatusCode == 200 { - got200 = true - } - if resp.StatusCode == 429 { - got429 = true - lastRateLimitBody = string(b) - } - mu.Unlock() - } - }() + }() + } + wg.Wait() + return got200, got429, lastRateLimitBody } - wg.Wait() + // Health probes are exempt from the limiter — the burst must never trip + // it, no matter how large (Kubernetes probes share the client budget). + probe200, probe429, _ := burst("/health") + if !probe200 { + t.Fatal("expected health probes to answer 200") + } + if probe429 { + t.Fatal("health probes must be exempt from the rate limit") + } + + // A regular API endpoint is limited: 150 > 100 guarantees at least one 429. + got200, got429, lastRateLimitBody := burst("/api/ping") if !got200 { t.Fatal("expected at least one 200 in the burst before the rate limit applies") } @@ -170,6 +182,6 @@ func TestRateLimiting(t *testing.T) { // After the sliding window passes the server must recover. time.Sleep(1200 * time.Millisecond) - status, _, body := regtest.GetRaw(t, client, baseURL+"/health") + status, _, body := regtest.GetRaw(t, client, baseURL+"/api/ping") regtest.MustStatus(t, status, 200, body) -} \ No newline at end of file +}