Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/api-client-rate-limit-retry.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/better-near-auth-error-formatting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"better-near-auth": patch
---

Stringify unknown throwables safely in plugin error messages.
5 changes: 5 additions & 0 deletions .changeset/every-plugin-error-formatting.md
Original file line number Diff line number Diff line change
@@ -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]`.
11 changes: 11 additions & 0 deletions .changeset/host-rate-limit-health.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/locale-negotiation-ssr.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/publish-pin-verification-gate.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/session-query-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/staging-input-schema.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion host/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ try {
// server boots so SSR container loads and any config fetches route through it.
installBundleFetchFromEnv({});

runServerBlocking({ config });
await runServerBlocking({ config });
120 changes: 100 additions & 20 deletions host/src/middleware/security.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand All @@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

function rateLimitPage(path: string): string {
const safePath = escapeHtml(path);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Too many requests</title>
</head>
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b0d10;color:#e5e7eb;font-family:system-ui,-apple-system,sans-serif">
<main role="alert" style="text-align:center;padding:2rem;max-width:28rem">
<h1 style="font-size:1.5rem;font-weight:600;margin:0 0 .75rem">Too many requests</h1>
<p style="color:#9ca3af;margin:0 0 1.5rem">You&rsquo;re going faster than we can handle. Please wait a moment, then try again.</p>
<a href="${safePath}" style="color:#60a5fa">Try again</a>
</main>
</body>
</html>`;
}

export function getCspStrict(isDev: boolean): boolean {
return process.env.CSP_STRICT === "false" ? false : !isDev;
}
Expand All @@ -41,6 +125,7 @@ export class SecurityMiddleware extends Context.Service<
cors: MiddlewareHandler;
csrf: MiddlewareHandler;
rateLimit: MiddlewareHandler;
rateLimitMutation: MiddlewareHandler;
csp: MiddlewareHandler;
}
>()("host/SecurityMiddleware") {
Expand Down Expand Up @@ -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." },
});

Expand Down Expand Up @@ -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 };
}),
);
}
5 changes: 3 additions & 2 deletions host/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Response> => {
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");
Expand All @@ -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 }, () => {
Expand Down
18 changes: 16 additions & 2 deletions host/src/services/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
}
Expand All @@ -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}`;
}
}
Expand Down
4 changes: 3 additions & 1 deletion host/src/services/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions host/src/services/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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);
Expand Down Expand Up @@ -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);
}
Expand Down
9 changes: 7 additions & 2 deletions host/src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";
}
}

Expand All @@ -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" };
}
6 changes: 4 additions & 2 deletions host/tests/e2e/runtime-remote.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading