diff --git a/.changeset/inbound-rate-limit-seam.md b/.changeset/inbound-rate-limit-seam.md
new file mode 100644
index 0000000000..5aea84cc8d
--- /dev/null
+++ b/.changeset/inbound-rate-limit-seam.md
@@ -0,0 +1,97 @@
+---
+"@objectstack/spec": minor
+"@objectstack/runtime": minor
+"@objectstack/plugin-hono-server": minor
+"@objectstack/plugin-auth": patch
+"@objectstack/cli": patch
+---
+
+feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937)
+
+Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec`
+declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any
+of them, so an author wrote a budget, it parsed, and nothing happened (#4686).
+`@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense,
+that the dispatcher called it and short-circuited with 429 — it had **zero call sites**
+outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to
+tune did not exist (#4937). Neither half was broken; they were simply never connected, and
+both were documented as if they were.
+
+They are connected now, along one narrow path.
+
+## What you write
+
+```ts
+export default defineStack({
+ manifest: { /* … */ },
+ server: {
+ security: {
+ rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 },
+ },
+ trustProxy: false,
+ },
+});
+```
+
+`server:` is a **new** top-level stack key. Nothing declared it before, so no existing
+stack changes behaviour on upgrade — there is no configuration that was inert yesterday
+and starts throttling today.
+
+It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and
+nothing else, because those are the two keys with a consumer. It is NOT the nine-key
+`HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and
+mounting them here would have made seven dead keys writable in one move (their
+enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled
+budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0`
+is refused at `defineStack` rather than at 3am.
+
+**No `server.port`.** The listening socket belongs to the deployment, not the artifact, and
+`objectstack serve -p` already owns it. The precedence rule is recorded in the schema and
+the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` >
+built-in default.**
+
+## What happens
+
+Every inbound request the server routes — REST, dispatcher, service routes, anything
+mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`,
+refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429**
+with a `Retry-After` computed from the bucket itself and the standard error envelope
+(`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered.
+
+The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for
+anonymous traffic — so one abusive session cannot spend another user's budget, and
+credential-stuffing traffic (which has no principal yet) is still metered per source. That
+IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**;
+otherwise it is the transport's own peer address. Undeclared, those headers are attacker
+input: honouring them by default would hand anyone an unlimited supply of fresh buckets and
+let them drain a chosen victim's.
+
+Counters live in the kernel `cache` service when one is registered, so a multi-node
+deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at
+consume time so a cache plugin that registers later is still picked up (#4772). With no
+cache service at all it falls back to a per-process store and says so once, naming the
+consequence: the effective limit becomes the declared budget multiplied by the number of
+nodes, and nothing about the deployment looks wrong.
+
+## Also in this change
+
+- **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation
+ passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered
+ middleware could not read the request, write a response, or decline to continue — a
+ declared seam with no execution behind it, unnoticed because nothing called it. It now
+ delivers method/path/query/headers plus the transport peer address
+ (`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be
+ registered before the routes it guards; the kernel's two-phase boot makes that automatic
+ (`init()` before every `start()`).
+- **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it
+ does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous
+ in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is
+ now labelled as the reference material it always was rather than as live defaults.
+
+## Explicitly NOT wired
+
+`ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain
+**known-unwired**. Declaring them still changes nothing. They are not retired here either:
+the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one
+key of a surface that may yet be implemented would only have to be undone. Tracked, not
+silent.
diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx
index 42b8a4ffe9..3130b9e810 100644
--- a/content/docs/protocol/kernel/http-protocol.mdx
+++ b/content/docs/protocol/kernel/http-protocol.mdx
@@ -958,39 +958,89 @@ ETag: "abc123def456"
## Rate Limiting
-
-ObjectStack ships a token-bucket `RateLimiter` primitive
-(`@objectstack/runtime`), but emission of the `X-RateLimit-*` response headers
-and the `429` envelope below is deployment-specific and not wired into the
-default REST response path. Treat the headers and response shape here as the
-intended contract.
-
+Inbound rate limiting is **off unless a stack declares it**, and it is declared in
+one place — the stack's `server` block:
+
+```ts
+import { defineStack } from '@objectstack/spec';
+
+export default defineStack({
+ manifest: { /* … */ },
+ server: {
+ security: {
+ rateLimit: {
+ enabled: true,
+ windowMs: 60_000, // budget window, in MILLISECONDS
+ maxRequests: 600, // requests permitted per window, per caller
+ },
+ },
+ // Believe `X-Forwarded-For` / `X-Real-IP`? Only behind a proxy you control.
+ trustProxy: false,
+ },
+});
+```
-When enabled, responses include rate limit headers:
+`objectstack serve` / `dev` forward that block to the dispatcher plugin, which arms a
+token bucket in front of **every route the server mounts** — not just the dispatcher's
+own. `capacity` is `maxRequests` (so a full bucket absorbs one window's worth of
+traffic as a burst) and it refills at `maxRequests / (windowMs / 1000)` tokens per
+second (so the sustained rate is exactly the declared one).
-```http
-HTTP/1.1 200 OK
-X-RateLimit-Limit: 1000
-X-RateLimit-Remaining: 847
-X-RateLimit-Reset: 1705324800
-```
+### What the bucket is keyed on
+
+1. **The resolved principal**, when the request carries a valid session. One user
+ cannot spend another's budget, and users behind a shared NAT do not throttle each
+ other.
+2. **The caller's IP**, for anonymous traffic — the case that most needs a limit
+ (credential stuffing, scraping) and has no identity yet.
+
+That IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `server.trustProxy` is
+declared `true`**. Left at its default, the address is the transport's own peer
+address, which a client cannot forge. This is deliberate: an attacker who can choose
+their own `X-Forwarded-For` otherwise gets an unlimited supply of fresh buckets *and*
+can drain a chosen victim's. Declare `trustProxy` only when a reverse proxy you
+control overwrites those headers on every inbound request.
+
+CORS preflights (`OPTIONS`) are never metered.
+
+### When the limit is exceeded
-**When limit exceeded:**
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 45
-X-RateLimit-Limit: 1000
-X-RateLimit-Remaining: 0
-X-RateLimit-Reset: 1705324800
+Content-Type: application/json
{
- "error": "Rate limit exceeded",
- "code": "THROTTLED",
- "retry_after": 45
+ "success": false,
+ "error": {
+ "code": "RATE_LIMIT_EXCEEDED",
+ "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.",
+ "httpStatus": 429,
+ "details": { "retryAfterSeconds": 45, "resetAt": "2026-08-03T12:00:45.000Z" }
+ }
}
```
-See [Error Handling](/docs/protocol/kernel/error-handling) for more details.
+`Retry-After` is computed from the bucket itself, so the wait it advertises is the
+wait the bucket will actually take to refill. The body is the standard error envelope
+— see [Error Handling](/docs/protocol/kernel/error-handling).
+
+### Counting across nodes
+
+Counters live in the kernel `cache` service when one is registered, so a multi-node
+deployment enforces **one** budget rather than one per node (ADR-0069 D2). With no
+cache service the limiter falls back to a per-process store and says so once, at
+`warn`, naming the consequence: until a shared cache is registered the effective limit
+is the declared budget multiplied by the number of nodes.
+
+
+**Not implemented, deliberately named rather than implied.** ObjectStack does **not**
+emit `X-RateLimit-Limit` / `-Remaining` / `-Reset` headers on successful responses —
+only `Retry-After` on a 429. And the per-endpoint `rateLimit` key on
+`ApiEndpointSchema` / `ApiEndpointRegistrationSchema` is **not wired to anything**;
+declaring it changes nothing today. Its fate travels with the declarative `apis:`
+surface as a whole, tracked by [#4936](https://github.com/objectstack-ai/objectstack/issues/4936).
+
## Best Practices
@@ -1021,14 +1071,17 @@ const tasks = await fetch('/api/data/task?expand=assignee');
```
### Respect Rate Limits
-✅ **Good:** Check headers and implement backoff
+❌ **Bad:** Poll `X-RateLimit-Remaining` — that header is not emitted, so the check
+always reads `null` and the backoff never runs.
+
+✅ **Good:** Handle the 429 and honour `Retry-After`
```javascript
const response = await fetch('/api/data/task');
-const remaining = response.headers.get('X-RateLimit-Remaining');
-if (remaining < 10) {
- console.warn('Approaching rate limit');
- await sleep(1000);
+if (response.status === 429) {
+ const retryAfter = Number(response.headers.get('Retry-After') ?? 1);
+ await sleep(retryAfter * 1000);
+ // …then retry once; the budget refills continuously, so a single wait is enough.
}
```
diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx
index c3cc7fdce0..1b7d6c1222 100644
--- a/content/docs/references/system/index.mdx
+++ b/content/docs/references/system/index.mdx
@@ -36,6 +36,7 @@ This section contains all protocol schemas for the system layer of ObjectStack.
+
diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json
index 52397a038f..e07635dbfa 100644
--- a/content/docs/references/system/meta.json
+++ b/content/docs/references/system/meta.json
@@ -45,6 +45,7 @@
"doc",
"---More---",
"metadata-types",
- "retry-policy"
+ "retry-policy",
+ "stack-server"
]
}
\ No newline at end of file
diff --git a/content/docs/references/system/stack-server.mdx b/content/docs/references/system/stack-server.mdx
new file mode 100644
index 0000000000..63fbc62910
--- /dev/null
+++ b/content/docs/references/system/stack-server.mdx
@@ -0,0 +1,123 @@
+---
+title: Stack Server
+description: Stack Server protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+`defineStack(\{ server \})` — the authorable server-facing configuration.
+
+## Why this is NOT `HttpServerConfigSchema`
+
+`[system/http-server.zod.ts](/docs/references/system/http-server)` declares nine keys (`port`, `host`, `cors`,
+
+`requestTimeout`, `bodyLimit`, `compression`, `security`, `static`,
+
+`trustProxy`). #4938 measured them: **none had a runtime reader and none was
+
+reachable from any authoring surface** — `stack.zod.ts` had no `server:` key,
+
+so the whole shape was unwritable as well as unread. Mounting it wholesale
+
+here would have made eight dead keys authorable in one move, which is the
+
+declared-≠-enforced defect (Prime Directive #10) manufactured on purpose.
+
+So this schema is deliberately NARROW: it carries only keys an executor
+
+actually consumes, and it grows one key at a time, each arriving with its
+
+consumer. Today that is exactly two:
+
+| key | consumed by |
+
+|---|---|
+
+| `security.rateLimit` | `createDispatcherPlugin` → the inbound token bucket (`@objectstack/runtime` `security/inbound-rate-limit.ts`) — an over-budget caller gets `429` + `Retry-After` |
+
+| `trustProxy` | the same limiter's IP resolution — see below |
+
+The other seven `HttpServerConfigSchema` keys stay unreachable, and their
+
+enforce-or-remove fate is tracked by #4938. Adding one here without an
+
+executor re-opens the hole this narrowness exists to close.
+
+## What `server:` is NOT for
+
+**Deployment knobs stay on the CLI.** There is no `server.port` / `server.host`
+
+on purpose: the listening socket is a property of *where* a stack runs, not of
+
+the stack itself, and it is already owned by `objectstack serve -p ` /
+
+`PORT`. Two authorities for one number is how a config becomes advisory. If a
+
+future need does add `server.port`, the precedence is settled in advance and
+
+recorded here so it cannot be re-litigated per-caller: **the CLI flag wins over
+
+`server:`, and `server:` wins over the built-in default** — an operator
+
+overriding a port at the command line must not be silently overruled by a file
+
+baked into the artifact.
+
+Related: #4910 (this seam), #4937 (the limiter that documented an execution
+
+chain it never had), #4936 (`apis:` endpoint-level `rateLimit`, still
+
+unwired), ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove).
+
+
+**Source:** `packages/spec/src/system/stack-server.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { ServerRateLimitConfigSchema, StackServerConfigSchema, StackServerSecuritySchema } from '@objectstack/spec/system';
+import type { ServerRateLimitConfig, StackServerConfig, StackServerSecurity } from '@objectstack/spec/system';
+
+// Validate data
+const result = ServerRateLimitConfigSchema.parse(data);
+```
+
+---
+
+## ServerRateLimitConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **enabled** | `boolean` | ✅ | Enable rate limiting |
+| **windowMs** | `integer` | ✅ | Time window in milliseconds |
+| **maxRequests** | `integer` | ✅ | Max requests per window |
+
+
+---
+
+## StackServerConfig
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **security** | `{ rateLimit?: object }` | optional | Server-level security configuration. Today: the global inbound rate limit. |
+| **trustProxy** | `boolean` | ✅ | Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is the transport's own peer address, which a client cannot forge. Consumed by the inbound rate limiter when `server.security.rateLimit.enabled` is set. |
+
+
+---
+
+## StackServerSecurity
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Global inbound rate limit. When `enabled`, every inbound request consumes from a token bucket derived from this budget (capacity = `maxRequests`, refill = `maxRequests / (windowMs / 1000)` tokens per second); an empty bucket answers 429 with a `Retry-After` header. The bucket is keyed by the RESOLVED PRINCIPAL, falling back to the caller IP for anonymous traffic — so one abusive session cannot exhaust another user's budget, and credential-stuffing traffic (which has no principal yet) is still metered per source. See `server.trustProxy` for how that IP is determined. |
+
+
+---
+
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 9a2a42f32d..b3cd569cb4 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -1959,6 +1959,24 @@ export default class Serve extends Command {
// (env-native auth IS the membership — ADR-0024 D9) by setting
// `api.enforceProjectMembership: false`. Undefined → dispatcher default.
const enforceProjectMembership = apiConfig.enforceProjectMembership;
+ // [#4910] The stack's top-level `server:` block — deliberately narrow:
+ // only keys with a consumer are declared, and both of these have one in
+ // the dispatcher's inbound rate limiter. Read here, next to `api:`, for
+ // the same reason that one is: this is the single place the authored
+ // stack is turned into plugin configuration.
+ //
+ // NOTE the budget is deliberately NOT validated here. An unusable one
+ // (`maxRequests: 0`) throws out of `createInboundRateLimitMiddleware`
+ // during the dispatcher plugin's `init()`, which the kernel runs at
+ // BOOTSTRAP — outside the optional-plugin `catch` below, which only
+ // guards the `import`/`use` registration. So a nonsense budget fails the
+ // boot with a prescriptive message instead of silently disarming the
+ // limiter or, worse, silently dropping the whole dispatcher.
+ const serverConfig = (config as any).server ?? {};
+ const rateLimitConfig = {
+ ...(serverConfig.security?.rateLimit ? { budget: serverConfig.security.rateLimit } : {}),
+ trustProxy: serverConfig.trustProxy === true,
+ };
// [#3963] Anonymous access to object data is denied unconditionally —
// there is no `api.requireAuth` opt-out any more (auth is a kernel
// concern; every legitimately session-less surface derives its own narrow
@@ -2007,6 +2025,7 @@ export default class Serve extends Command {
scoping: { enableProjectScoping, projectResolution },
enforceProjectMembership,
observability,
+ rateLimit: rateLimitConfig,
}),
);
trackPlugin('Dispatcher');
diff --git a/packages/plugins/plugin-auth/src/rate-limit-storage.ts b/packages/plugins/plugin-auth/src/rate-limit-storage.ts
index e17bc933a8..488b56e551 100644
--- a/packages/plugins/plugin-auth/src/rate-limit-storage.ts
+++ b/packages/plugins/plugin-auth/src/rate-limit-storage.ts
@@ -161,6 +161,17 @@ export interface LazyCounterStoreOptions extends LazyCacheRateLimitStorageOption
* self-explanatory when the currency is paid SMS.
*/
degradedImpact?: string;
+ /**
+ * Log-line prefix identifying the SUBSYSTEM that degraded, e.g. `'[auth]'`
+ * (the default) or `'[dispatcher]'`.
+ *
+ * Added in #4910, when the inbound rate limiter started counting through this
+ * same resolution path: without it the runtime's degradation announced itself
+ * as `[auth] …`, sending an operator to inspect the auth plugin for a
+ * dispatcher problem. A misattributed log is worse than a terse one — the
+ * whole value of these two lines is that they name what to go fix.
+ */
+ logPrefix?: string;
}
/**
@@ -182,6 +193,7 @@ export interface LazyCounterStoreOptions extends LazyCacheRateLimitStorageOption
*/
export function createLazyCounterStore(opts: LazyCounterStoreOptions): () => Promise {
const fallback = new InProcessCounterStore();
+ const prefix = opts.logPrefix ?? '[auth]';
let cache: CounterStore | undefined;
let boundAnnounced = false;
let degradedWarned = false;
@@ -196,7 +208,7 @@ export function createLazyCounterStore(opts: LazyCounterStoreOptions): () => Pro
if (cache && !boundAnnounced) {
boundAnnounced = true;
opts.logger?.info?.(
- `[auth] ${opts.subject} bound to the kernel cache service — enforced against ONE store across nodes iff the cache is shared (ADR-0069 D2)`,
+ `${prefix} ${opts.subject} bound to the kernel cache service — enforced against ONE store across nodes iff the cache is shared (ADR-0069 D2)`,
);
}
}
@@ -204,7 +216,7 @@ export function createLazyCounterStore(opts: LazyCounterStoreOptions): () => Pro
if (!degradedWarned) {
degradedWarned = true;
opts.logger?.warn?.(
- `[auth] ${opts.subject}: no cache service to count in — falling back to a per-process store. ` +
+ `${prefix} ${opts.subject}: no cache service to count in — falling back to a per-process store. ` +
'This deployment has no `cache` service registered at all; a multi-node deployment needs a shared cache ' +
'(Redis via @objectstack/service-cache) or each node enforces the limit independently (ADR-0069 D2)' +
(opts.degradedImpact ? `. ${opts.degradedImpact}` : ''),
diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts
index 20c3be769c..f28fc06b1d 100644
--- a/packages/plugins/plugin-hono-server/src/adapter.ts
+++ b/packages/plugins/plugin-hono-server/src/adapter.ts
@@ -78,6 +78,26 @@ export interface HonoCorsOptions {
maxAge?: number;
}
+/**
+ * The transport's peer address for a Hono context, when the runtime exposes one.
+ *
+ * `@hono/node-server` puts the Node `IncomingMessage` on `c.env.incoming`, so
+ * the socket's `remoteAddress` is reachable without adding a dependency on
+ * `hono/conninfo` (which resolves differently per runtime). Every access is
+ * guarded: on a runtime that exposes nothing, callers get `undefined` and must
+ * degrade deliberately rather than key security decisions off a fabricated
+ * value.
+ */
+function readRemoteAddress(c: any): string | undefined {
+ try {
+ const incoming = c?.env?.incoming;
+ const address = incoming?.socket?.remoteAddress ?? incoming?.connection?.remoteAddress;
+ return typeof address === 'string' && address.length > 0 ? address : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
/**
* Hono Implementation of IHttpServer
*/
@@ -93,6 +113,10 @@ export class HonoHttpServer implements IHttpServer {
* raw Hono app are intentionally NOT tracked, so they never produce a 405.
*/
private registeredRoutes: Array<{ method: string; pattern: string }> = [];
+ /** Registered {@link Middleware}s, in registration order. See `use()`. */
+ private middlewares: Array<{ path?: string; handler: Middleware }> = [];
+ /** Whether the Hono middleware that runs {@link middlewares} is mounted. */
+ private middlewareSeamInstalled = false;
constructor(
private port: number = 3000,
@@ -302,22 +326,154 @@ export class HonoHttpServer implements IHttpServer {
return Array.from(methods).sort();
}
+ /**
+ * Register middleware — see the CONTRACT on `IHttpServer.use` in
+ * `@objectstack/spec/contracts`.
+ *
+ * ## What this used to be, and why it matters (#4910)
+ *
+ * Until #4910 both branches here handed the middleware `{} as any` for BOTH
+ * `req` and `res`, and then ran `if (!nextCalled) await next()` — so a
+ * middleware could not read the request, could not write a response, and
+ * could not decline to continue. Every registered middleware was, in
+ * practice, an `await`ed no-op with a `next()` bolted on. `IHttpServer.use`
+ * was a declared seam with no execution behind it: exactly the
+ * declared-≠-enforced shape Prime Directive #10 names, one layer below the
+ * spec keys #4686 opened on. Nothing production caught it because nothing
+ * production called it — the inbound rate limiter is the first consumer, and
+ * building it is what surfaced this.
+ *
+ * ## Semantics now
+ *
+ * Middlewares run in registration order, before any route handler, and each
+ * one either:
+ *
+ * - calls `next()` — the chain continues; or
+ * - writes a response (`res.status(...).json(...)` / `.send(...)`) without
+ * calling `next()` — the chain SHORT-CIRCUITS and that response is
+ * returned. This is the branch that makes a 429 (or a 401, or a
+ * maintenance 503) possible at all.
+ *
+ * A middleware that does neither is treated as pass-through, so an
+ * early-return on some condition cannot silently black-hole a request.
+ *
+ * ## Two deliberate limits, stated so they are not discovered
+ *
+ * - **`req.body` is not populated.** Reading the body here would consume
+ * the request stream before the route handler that owns it, so a
+ * middleware sees headers/method/path/query only. Body-dependent policy
+ * belongs in a route handler or a dispatcher gate stage.
+ * - **The seam must be mounted before routes, but `use()` need not be
+ * called before them.** Hono composes the handlers that matched, in
+ * registration order, so a middleware Hono learns about after a route
+ * runs after that route's handler — useless for short-circuiting. This
+ * class therefore mounts ONE Hono middleware (the chain runner) and lets
+ * `use()` append to the chain it reads per request. {@link
+ * installMiddlewareSeam} places that runner; `HonoServerPlugin` calls it
+ * at the end of `init()`, after the transport's own built-ins and before
+ * any route exists, so every later `use()` — from any plugin, in either
+ * boot phase — gates everything. A standalone `HonoHttpServer` that never
+ * calls it gets the runner mounted on its first `use()` instead, and only
+ * that path carries the register-before-routes requirement.
+ */
use(pathOrHandler: string | Middleware, handler?: Middleware) {
if (typeof pathOrHandler === 'string' && handler) {
- this.app.use(pathOrHandler, async (c, next) => {
- let nextCalled = false;
- const wrappedNext = () => { nextCalled = true; return next(); };
- await handler({} as any, {} as any, wrappedNext);
- if (!nextCalled) await next();
- });
+ this.middlewares.push({ path: pathOrHandler, handler });
} else if (typeof pathOrHandler === 'function') {
- this.app.use('*', async (c, next) => {
- let nextCalled = false;
- const wrappedNext = () => { nextCalled = true; return next(); };
- await pathOrHandler({} as any, {} as any, wrappedNext);
- if (!nextCalled) await next();
- });
+ this.middlewares.push({ handler: pathOrHandler });
+ } else {
+ return;
}
+ this.installMiddlewareSeam();
+ }
+
+ /**
+ * Mount the single Hono middleware that runs the registered
+ * {@link Middleware} chain. Idempotent.
+ *
+ * WHERE this is called decides what the seam can gate, and the two callers
+ * are deliberate:
+ *
+ * - **`HonoServerPlugin.init()`, at the end** — after the transport's own
+ * built-ins (Server-Timing, CORS) so a 429 short-circuit still carries
+ * CORS headers (otherwise a browser reports an opaque network error
+ * instead of the status), and before any route exists, since every route
+ * in the platform is mounted in some plugin's `start()`. From there a
+ * `use()` at ANY later moment gates the whole server, which is what lets
+ * the dispatcher install the rate limiter in `start()` — where "no
+ * http.server" is a settled fact rather than a mid-Phase-1 guess that a
+ * later plugin could contradict.
+ * - **the first `use()`** — for a bare `HonoHttpServer` composed without
+ * the plugin, so the seam is never silently absent.
+ */
+ installMiddlewareSeam(): void {
+ if (this.middlewareSeamInstalled) return;
+ this.middlewareSeamInstalled = true;
+
+ this.app.use('*', async (c, next) => {
+ const chain = this.middlewares
+ .filter((m) => m.path === undefined || matchesRoutePattern(m.path, c.req.path))
+ .map((m) => m.handler);
+ if (chain.length === 0) return next();
+
+ const headers = c.req.header() as Record;
+ const req = {
+ params: {},
+ query: c.req.query(),
+ // Deliberately absent — see the `use()` contract above.
+ body: undefined,
+ headers,
+ method: c.req.method,
+ path: c.req.path,
+ /**
+ * The transport's own peer address. This is the value a client
+ * CANNOT forge, which is what makes it the safe default for
+ * identifying an anonymous caller when no proxy is trusted
+ * (`server.trustProxy`). `@hono/node-server` exposes the Node
+ * request as `c.env.incoming`; other Hono runtimes may not, and
+ * consumers must treat it as optional.
+ */
+ remoteAddress: readRemoteAddress(c),
+ } as any;
+
+ let responded = false;
+ let status = 200;
+ const outHeaders = new Headers();
+ let bodyJson: unknown;
+ let bodyRaw: string | Uint8Array | ArrayBuffer | undefined;
+
+ const res: any = {
+ status(code: number) { status = code; return res; },
+ header(name: string, value: string | string[]) {
+ for (const v of Array.isArray(value) ? value : [value]) outHeaders.append(name, v);
+ return res;
+ },
+ json(data: unknown) { responded = true; bodyJson = data; },
+ send(data: string | Uint8Array | ArrayBuffer) { responded = true; bodyRaw = data; },
+ };
+
+ let index = 0;
+ let continued = false;
+ const run = async (): Promise => {
+ if (index >= chain.length) { continued = true; return; }
+ const middleware = chain[index++];
+ let nextCalled = false;
+ await middleware(req, res, async () => { nextCalled = true; await run(); });
+ // Neither continued nor answered → treat as pass-through, so a
+ // middleware cannot black-hole a request by accident.
+ if (!nextCalled && !responded) await run();
+ };
+ await run();
+
+ if (responded && !continued) {
+ if (bodyJson !== undefined) {
+ outHeaders.set('Content-Type', 'application/json');
+ return new Response(JSON.stringify(bodyJson), { status, headers: outHeaders });
+ }
+ return new Response((bodyRaw ?? '') as any, { status, headers: outHeaders });
+ }
+ return next();
+ });
}
/**
diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts
index 72f12df0cc..55bda0d50d 100644
--- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts
+++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts
@@ -31,6 +31,10 @@ vi.mock('./adapter', async (importOriginal) => ({
listen: vi.fn(),
getPort: vi.fn().mockReturnValue(3000),
close: vi.fn(),
+ // [#4910] The plugin places the IHttpServer middleware seam at the
+ // end of init(). Real behaviour is covered against the REAL adapter
+ // in `middleware-seam.test.ts`; here it only has to exist.
+ installMiddlewareSeam: vi.fn(),
getRawApp: vi.fn().mockReturnValue({
get: vi.fn(),
use: vi.fn(),
diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts
index f336548e39..0dc86a076a 100644
--- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts
+++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts
@@ -380,6 +380,22 @@ export class HonoServerPlugin implements Plugin {
ctx.logger.debug('CORS middleware enabled', { origin: configuredOrigin, credentials });
}
}
+
+ // ─── IHttpServer middleware seam ──────────────────────────────────────
+ // Mounted LAST in init(), and that position is the whole point (#4910).
+ //
+ // After the built-ins above, so a middleware that short-circuits (the
+ // inbound rate limiter's 429) still gets CORS headers applied — a
+ // browser shown a header-less 429 reports an opaque network error and
+ // the operator debugs the wrong thing.
+ //
+ // Before any route, because every route on this server is mounted in
+ // some plugin's `start()` (Phase 2) and Phase 1 completes first. Hono
+ // composes matched handlers in registration order, so this is the last
+ // moment at which a gate can still precede all of them — and placing it
+ // here means a consumer's `use()` no longer has to win a race with route
+ // registration. It can register whenever it has the facts.
+ this.server.installMiddlewareSeam();
}
/**
diff --git a/packages/plugins/plugin-hono-server/src/middleware-seam.test.ts b/packages/plugins/plugin-hono-server/src/middleware-seam.test.ts
new file mode 100644
index 0000000000..1f62f273ee
--- /dev/null
+++ b/packages/plugins/plugin-hono-server/src/middleware-seam.test.ts
@@ -0,0 +1,216 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `HonoHttpServer.use()` — the middleware seam, exercised through the real Hono
+ * app (`app.fetch`), never a mock.
+ *
+ * ## Why this file exists (#4910)
+ *
+ * `IHttpServer.use` was DECLARED middleware and IMPLEMENTED as a no-op: both
+ * branches passed `{} as any` for `req` and `res`, then called `next()`
+ * unconditionally, so a middleware could not read the request, write a
+ * response, or decline to continue. Nothing caught it because nothing called
+ * it — `use()` had zero production call sites repo-wide. The inbound rate
+ * limiter is the first consumer, and it needs exactly the three capabilities
+ * the stub lacked. Each has an assertion below, so the seam cannot quietly
+ * regress to a no-op again.
+ *
+ * ## Two mounting positions, and why the fixtures differ
+ *
+ * Hono composes the handlers that matched in registration order, so what a
+ * middleware can gate depends on when Hono learned about it. This class mounts
+ * ONE Hono middleware — the chain runner — and `use()` appends to the chain it
+ * reads per request, so the question becomes "when was the RUNNER mounted":
+ *
+ * - `installMiddlewareSeam()` up front (what `HonoServerPlugin.init()` does) —
+ * every later `use()` gates the whole server, whenever it happens.
+ * - never called — the runner is mounted on the first `use()`, so that call
+ * still has to precede the routes it means to guard.
+ *
+ * Both are pinned below, including the negative case, so the decoupling is not
+ * mistaken for something `use()` provides on its own.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { IHttpRequest, IHttpResponse } from '@objectstack/core';
+
+import { HonoHttpServer } from './adapter';
+
+/** A server whose middlewares are registered first, then its routes. */
+function serverWith(...middlewares: Array[0]>) {
+ const server = new HonoHttpServer(0);
+ for (const middleware of middlewares) server.use(middleware as never);
+ server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ ok: true }); });
+ server.post('/api/v1/thing', (_req, res) => { res.status(201); res.json({ created: true }); });
+ server.get('/other', (_req, res) => { res.status(200); res.send('other'); });
+ return server;
+}
+
+const call = (server: HonoHttpServer, path: string, init?: RequestInit) =>
+ server.getRawApp().fetch(new Request(`http://localhost${path}`, init));
+
+describe('a middleware can SHORT-CIRCUIT a request', () => {
+ it('answers with its own status, headers and body when it does not call next()', async () => {
+ const server = serverWith((_req: IHttpRequest, res: IHttpResponse) => {
+ res.status(429);
+ res.header('Retry-After', '7');
+ res.json({ success: false, error: { code: 'RATE_LIMIT_EXCEEDED' } });
+ });
+
+ const res = await call(server, '/api/v1/thing');
+ expect(res.status).toBe(429);
+ expect(res.headers.get('retry-after')).toBe('7');
+ expect(await res.json()).toEqual({ success: false, error: { code: 'RATE_LIMIT_EXCEEDED' } });
+ });
+
+ it('short-circuits a path that matches NO route (the gate is in front of everything)', async () => {
+ const server = serverWith((_req: IHttpRequest, res: IHttpResponse) => {
+ res.status(503);
+ res.send('maintenance');
+ });
+
+ const res = await call(server, '/not-a-route');
+ expect(res.status).toBe(503);
+ expect(await res.text()).toBe('maintenance');
+ });
+});
+
+describe('a middleware can PASS THROUGH', () => {
+ it('reaches the route handler when it calls next()', async () => {
+ let seen = 0;
+ const server = serverWith(async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => {
+ seen++;
+ await next();
+ });
+
+ const res = await call(server, '/api/v1/thing');
+ expect(seen).toBe(1);
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true });
+ });
+
+ it('treats "neither next() nor a response" as pass-through, never a black hole', async () => {
+ const server = serverWith(() => { /* forgot both branches */ });
+ expect((await call(server, '/api/v1/thing')).status).toBe(200);
+ });
+
+ it('runs middlewares in registration order and stops at the first that answers', async () => {
+ const order: string[] = [];
+ const server = serverWith(
+ async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { order.push('a'); await next(); },
+ (_req: IHttpRequest, res: IHttpResponse) => { order.push('b'); res.status(418); res.send('no'); },
+ async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => { order.push('c'); await next(); },
+ );
+
+ const res = await call(server, '/api/v1/thing');
+ expect(res.status).toBe(418);
+ expect(order).toEqual(['a', 'b']);
+ });
+});
+
+describe('a middleware can READ the request', () => {
+ it('sees method, path, query and headers', async () => {
+ let captured: IHttpRequest | undefined;
+ const server = serverWith(async (req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => {
+ captured = req;
+ await next();
+ });
+
+ await call(server, '/api/v1/thing?top=2', { headers: { 'x-probe': 'yes' } });
+
+ expect(captured?.method).toBe('GET');
+ expect(captured?.path).toBe('/api/v1/thing');
+ expect(captured?.query).toMatchObject({ top: '2' });
+ expect(captured?.headers['x-probe']).toBe('yes');
+ });
+
+ it('does NOT populate the body — the route handler still owns the stream', async () => {
+ let body: unknown = 'sentinel';
+ const server = serverWith(async (req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => {
+ body = req.body;
+ await next();
+ });
+
+ const res = await call(server, '/api/v1/thing', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ hello: 'world' }),
+ });
+
+ expect(body).toBeUndefined();
+ // …and the handler behind it still ran, i.e. the stream was not consumed.
+ expect(res.status).toBe(201);
+ expect(await res.json()).toEqual({ created: true });
+ });
+});
+
+describe('path-scoped middleware', () => {
+ it('runs only for matching paths', async () => {
+ const server = new HonoHttpServer(0);
+ server.use('/api/v1/*', (_req: IHttpRequest, res: IHttpResponse) => { res.status(403); res.send('scoped'); });
+ server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ ok: true }); });
+ server.get('/other', (_req, res) => { res.status(200); res.send('other'); });
+
+ expect((await call(server, '/api/v1/thing')).status).toBe(403);
+ expect((await call(server, '/other')).status).toBe(200);
+ });
+});
+
+describe('installMiddlewareSeam decouples use() from route registration order', () => {
+ it('gates routes registered AFTER it', async () => {
+ const server = new HonoHttpServer(0);
+ server.use((_req: IHttpRequest, res: IHttpResponse) => { res.status(429); res.send('nope'); });
+ server.get('/late', (_req, res) => { res.status(200); res.send('late'); });
+
+ expect((await call(server, '/late')).status).toBe(429);
+ });
+
+ it('gates routes registered BEFORE the use() call, once the seam was placed up front', async () => {
+ // This is the composition `HonoServerPlugin` produces: it places the
+ // seam at the end of its own `init()`, and consumers `use()` later —
+ // from `start()`, after other plugins have already mounted routes. The
+ // gate must still cover those routes, or "server-level rate limit"
+ // would silently mean "whichever routes happened to be registered after
+ // the limiter", i.e. a different set per deployment.
+ const server = new HonoHttpServer(0);
+ server.installMiddlewareSeam();
+ server.get('/early', (_req, res) => { res.status(200); res.send('early'); });
+ server.use((_req: IHttpRequest, res: IHttpResponse) => { res.status(429); res.send('nope'); });
+
+ expect((await call(server, '/early')).status).toBe(429);
+ });
+
+ it('is idempotent — calling it twice does not double-run the chain', async () => {
+ let runs = 0;
+ const server = new HonoHttpServer(0);
+ server.installMiddlewareSeam();
+ server.installMiddlewareSeam();
+ server.use(async (_req: IHttpRequest, _res: IHttpResponse, next: () => void | Promise) => {
+ runs++;
+ await next();
+ });
+ server.get('/thing', (_req, res) => { res.status(200); res.send('ok'); });
+
+ await call(server, '/thing');
+ expect(runs).toBe(1);
+ });
+
+ it('without the seam placed up front, a use() after routes cannot gate them', async () => {
+ // Pinned so the requirement above is not mistaken for an accident: the
+ // decoupling comes from WHERE the seam is mounted, not from `use()`
+ // itself. Hono composes matched handlers in registration order.
+ const server = new HonoHttpServer(0);
+ server.get('/early', (_req, res) => { res.status(200); res.send('early'); });
+ server.use((_req: IHttpRequest, res: IHttpResponse) => { res.status(429); res.send('nope'); });
+
+ expect((await call(server, '/early')).status).toBe(200);
+ });
+});
+
+describe('the seam costs nothing when unused', () => {
+ it('does not mount a Hono middleware until the first use() call', async () => {
+ const server = new HonoHttpServer(0);
+ server.get('/api/v1/thing', (_req, res) => { res.status(200); res.json({ ok: true }); });
+ expect((await call(server, '/api/v1/thing')).status).toBe(200);
+ });
+});
diff --git a/packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts b/packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts
new file mode 100644
index 0000000000..1181b6a8ba
--- /dev/null
+++ b/packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts
@@ -0,0 +1,280 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The declared === enforced probe for inbound rate limiting (#4910).
+ *
+ * This is the acceptance criterion of #4910 and the answer to #4686: a budget
+ * an author WRITES, carried through the real composition, producing a real 429
+ * on a real socket. Everything else in this change is unit-testable and unit-
+ * tested; this file is the only place that proves the two ends are connected,
+ * which is precisely what nobody could prove before — the spec's three
+ * `RateLimitConfig` embeddings had zero readers (#4686) and the runtime's token
+ * bucket had zero call sites (#4937).
+ *
+ * ## Read the RED control first
+ *
+ * The first suite boots the SAME stack with the SAME budget and the wiring
+ * omitted, and asserts no request is ever refused. That is not a formality: it
+ * IS `origin/main` before this change, and without it a green "429 observed"
+ * could come from anywhere — a proxy, a coincidence, an assertion that would
+ * pass against an unmodified tree. A declared-≠-enforced probe that cannot fail
+ * on the unfixed code is not a probe.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { LiteKernel, Plugin, PluginContext } from '@objectstack/core';
+import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
+import type { IHttpServer } from '@objectstack/spec/contracts';
+
+import { createDispatcherPlugin } from './dispatcher-plugin.js';
+
+/** The authored budget, exactly as `defineStack({ server })` would carry it. */
+const BUDGET = { enabled: true, windowMs: 60_000, maxRequests: 3 };
+
+/**
+ * Minimal `auth` service: the `x-test-user` header names the principal. Enough
+ * for the limiter to resolve one, which is all the keying decision needs.
+ */
+function fakeAuthPlugin(): Plugin {
+ return {
+ name: 'com.objectstack.test.fake-auth',
+ version: '1.0.0',
+ init: async (ctx: PluginContext) => {
+ ctx.registerService('auth', {
+ api: {
+ getSession: async ({ headers }: { headers: any }) => {
+ const uid = typeof headers?.get === 'function'
+ ? headers.get('x-test-user')
+ : headers?.['x-test-user'];
+ return uid ? { user: { id: uid } } : undefined;
+ },
+ },
+ });
+ },
+ };
+}
+
+/** A `cache` service so counters land in the shared store, as in production. */
+function fakeCachePlugin(): Plugin {
+ const entries = new Map();
+ return {
+ name: 'com.objectstack.test.fake-cache',
+ version: '1.0.0',
+ init: async (ctx: PluginContext) => {
+ ctx.registerService('cache', {
+ get: async (key: string) => entries.get(key),
+ set: async (key: string, value: unknown) => { entries.set(key, value); },
+ delete: async (key: string) => entries.delete(key),
+ has: async (key: string) => entries.has(key),
+ clear: async () => entries.clear(),
+ stats: async () => ({ hits: 0, misses: 0, keys: entries.size }),
+ });
+ },
+ };
+}
+
+/**
+ * A plugin that mounts a route in `start()` and is REGISTERED BEFORE the
+ * dispatcher — so its route exists before the dispatcher installs the limiter.
+ * Stands in for `@objectstack/rest`, whose `/data` surface is mounted exactly
+ * this way. If the gate only covered routes registered after it,
+ * `server.security.rateLimit` would silently mean "some routes".
+ */
+function earlyRoutePlugin(): Plugin {
+ return {
+ name: 'com.objectstack.test.early-route',
+ version: '1.0.0',
+ init: async () => { /* nothing */ },
+ start: async (ctx: PluginContext) => {
+ const server = ctx.getService('http.server');
+ server.get('/early/route', (_req, res) => { res.status(200); res.json({ early: true }); });
+ },
+ };
+}
+
+async function boot(dispatcherConfig: Record) {
+ // LiteKernel, like `dispatcher-plugin.ready.integration.test.ts`: the same
+ // two-phase init/start contract the gate placement depends on, without a
+ // data engine this suite has no use for. Port 0 → an OS-assigned free port.
+ const kernel = new LiteKernel();
+ kernel.use(fakeAuthPlugin());
+ kernel.use(fakeCachePlugin());
+ kernel.use(new HonoServerPlugin({ port: 0 }));
+ // Registered — and therefore started — BEFORE the dispatcher, on purpose.
+ kernel.use(earlyRoutePlugin());
+ kernel.use(createDispatcherPlugin({
+ prefix: '/api/v1',
+ securityHeaders: false,
+ ...dispatcherConfig,
+ }));
+ await kernel.bootstrap();
+ const httpServer = kernel.getService('http.server');
+ return { kernel, baseUrl: `http://127.0.0.1:${httpServer.getPort!()}` };
+}
+
+async function shutdown(kernel: LiteKernel | undefined) {
+ if (!kernel) return;
+ await Promise.race([
+ kernel.shutdown(),
+ new Promise((resolve) => setTimeout(resolve, 10_000)),
+ ]);
+}
+
+describe('RED control — a declared budget with no wiring never refuses anything', () => {
+ let kernel: LiteKernel;
+ let baseUrl: string;
+
+ beforeAll(async () => {
+ // Same budget, deliberately NOT handed to the plugin. This is the state
+ // of the repo before #4910: parseable, unread, inert.
+ ({ kernel, baseUrl } = await boot({}));
+ }, 30_000);
+
+ afterAll(() => shutdown(kernel), 30_000);
+
+ it('serves far past the budget without a single 429', async () => {
+ const statuses: number[] = [];
+ for (let i = 0; i < BUDGET.maxRequests + 5; i++) {
+ statuses.push((await fetch(`${baseUrl}/api/v1/health`)).status);
+ }
+ expect(statuses).not.toContain(429);
+ expect(new Set(statuses)).toEqual(new Set([200]));
+ });
+});
+
+describe('inbound rate limit, wired (#4910)', () => {
+ let kernel: LiteKernel;
+ let baseUrl: string;
+
+ beforeAll(async () => {
+ ({ kernel, baseUrl } = await boot({ rateLimit: { budget: BUDGET, trustProxy: false } }));
+ }, 30_000);
+
+ afterAll(() => shutdown(kernel), 30_000);
+
+ it('answers 429 with Retry-After once the declared budget is spent', async () => {
+ const statuses: number[] = [];
+ let limited: Response | undefined;
+ for (let i = 0; i < BUDGET.maxRequests + 2; i++) {
+ const res = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_budget' } });
+ statuses.push(res.status);
+ if (res.status === 429 && !limited) limited = res;
+ }
+
+ expect(statuses.slice(0, BUDGET.maxRequests)).toEqual([200, 200, 200]);
+ expect(statuses.slice(BUDGET.maxRequests)).toEqual([429, 429]);
+
+ expect(limited).toBeDefined();
+ const retryAfter = Number(limited!.headers.get('retry-after'));
+ expect(Number.isFinite(retryAfter)).toBe(true);
+ expect(retryAfter).toBeGreaterThanOrEqual(1);
+
+ const body = await limited!.json() as { success: boolean; error: Record };
+ expect(body.success).toBe(false);
+ expect(body.error.code).toBe('RATE_LIMIT_EXCEEDED');
+ expect(body.error.httpStatus).toBe(429);
+ });
+
+ it('gates a route this plugin does NOT own — the budget is server-level, not dispatcher-level', async () => {
+ // `/nope` is served by nothing: without the gate it 404s. With the gate
+ // in front of every route it 429s once the caller's bucket is empty,
+ // which is what makes `server.security.rateLimit` an honest name. A
+ // limiter bolted onto this plugin's own mounts would leave `/data` (and
+ // everything else another plugin mounts) unmetered.
+ const first = await fetch(`${baseUrl}/nope`, { headers: { 'x-test-user': 'usr_unowned' } });
+ expect(first.status).toBe(404);
+
+ let last = first;
+ for (let i = 0; i < BUDGET.maxRequests + 2; i++) {
+ last = await fetch(`${baseUrl}/nope`, { headers: { 'x-test-user': 'usr_unowned' } });
+ }
+ expect(last.status).toBe(429);
+ });
+
+ it('gates a route another plugin mounted BEFORE the limiter was installed', async () => {
+ // The `@objectstack/rest` shape: a plugin registered earlier mounts its
+ // routes in `start()`, which runs before the dispatcher's. Registration
+ // order must not decide which paths are metered.
+ const first = await fetch(`${baseUrl}/early/route`, { headers: { 'x-test-user': 'usr_early' } });
+ expect(first.status).toBe(200);
+
+ let last = first;
+ for (let i = 0; i < BUDGET.maxRequests + 2; i++) {
+ last = await fetch(`${baseUrl}/early/route`, { headers: { 'x-test-user': 'usr_early' } });
+ }
+ expect(last.status).toBe(429);
+ });
+
+ it('meters each principal against its own bucket', async () => {
+ // usr_a spends its whole budget …
+ for (let i = 0; i < BUDGET.maxRequests + 1; i++) {
+ await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_a' } });
+ }
+ const aDenied = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_a' } });
+ expect(aDenied.status).toBe(429);
+
+ // … and usr_b, on the very same connection, is unaffected.
+ const bAllowed = await fetch(`${baseUrl}/api/v1/health`, { headers: { 'x-test-user': 'usr_b' } });
+ expect(bAllowed.status).toBe(200);
+ });
+
+ it('never meters a CORS preflight', async () => {
+ for (let i = 0; i < BUDGET.maxRequests + 3; i++) {
+ const res = await fetch(`${baseUrl}/api/v1/health`, {
+ method: 'OPTIONS',
+ headers: { origin: 'http://localhost:5173', 'access-control-request-method': 'GET' },
+ });
+ expect(res.status).not.toBe(429);
+ }
+ });
+});
+
+describe('trustProxy decides whether a forged header can mint fresh buckets', () => {
+ it('IGNORES X-Forwarded-For when trustProxy is not declared', async () => {
+ const { kernel, baseUrl } = await boot({ rateLimit: { budget: BUDGET, trustProxy: false } });
+ try {
+ // Every request claims a DIFFERENT client address. Undeclared, the
+ // header is worth nothing and all of them share the loopback bucket
+ // — so the limit still bites. Believing it here is the bypass.
+ const statuses: number[] = [];
+ for (let i = 0; i < BUDGET.maxRequests + 2; i++) {
+ const res = await fetch(`${baseUrl}/api/v1/health`, {
+ headers: { 'x-forwarded-for': `203.0.113.${i}` },
+ });
+ statuses.push(res.status);
+ }
+ expect(statuses).toContain(429);
+ } finally {
+ await shutdown(kernel);
+ }
+ }, 30_000);
+
+ it('honours X-Forwarded-For once trustProxy IS declared', async () => {
+ const { kernel, baseUrl } = await boot({ rateLimit: { budget: BUDGET, trustProxy: true } });
+ try {
+ // Declared, each distinct forwarded address is a distinct caller and
+ // gets its own budget — the behaviour an operator behind a real
+ // reverse proxy is asking for.
+ const statuses: number[] = [];
+ for (let i = 0; i < BUDGET.maxRequests + 2; i++) {
+ const res = await fetch(`${baseUrl}/api/v1/health`, {
+ headers: { 'x-forwarded-for': `203.0.113.${i}` },
+ });
+ statuses.push(res.status);
+ }
+ expect(statuses).not.toContain(429);
+
+ // …and one address that overspends is still cut off.
+ const repeated: number[] = [];
+ for (let i = 0; i < BUDGET.maxRequests + 2; i++) {
+ const res = await fetch(`${baseUrl}/api/v1/health`, {
+ headers: { 'x-forwarded-for': '198.51.100.1' },
+ });
+ repeated.push(res.status);
+ }
+ expect(repeated).toContain(429);
+ } finally {
+ await shutdown(kernel);
+ }
+ }, 30_000);
+});
diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts
index f6eff73d88..924cba48fb 100644
--- a/packages/runtime/src/dispatcher-plugin.ts
+++ b/packages/runtime/src/dispatcher-plugin.ts
@@ -4,14 +4,18 @@ import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
import { DispatcherErrorCode } from '@objectstack/spec/api';
import type { IAuthService } from '@objectstack/spec/contracts';
+import type { CounterStore } from '@objectstack/plugin-auth';
import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js';
import { isServiceServeable } from './service-serveable.js';
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js';
import { buildApiError } from './error-envelope.js';
import {
buildSecurityHeaders,
+ createInboundRateLimitMiddleware,
+ type InboundRateLimitBudget,
type SecurityHeadersOptions,
} from './security/index.js';
+import { resolveSessionData, resolveSessionPrincipalId } from './security/resolve-session-principal.js';
import {
NoopMetricsRegistry,
NoopErrorReporter,
@@ -95,6 +99,59 @@ export interface DispatcherPluginConfig {
generateRequestId?: () => string;
requestIdHeader?: string;
};
+
+ /**
+ * Inbound rate limiting, forwarded from the stack's authored
+ * `server:` block by `objectstack serve` (#4910).
+ *
+ * This field is the one `security/rate-limit.ts` used to name in the
+ * present tense while it did not exist (#4937) — it exists now, and it is
+ * the only way the limiter is armed: omit it, or leave
+ * `budget.enabled` false, and no middleware is registered at all
+ * (zero per-request cost, not a disabled check).
+ *
+ * When armed, the plugin installs the limiter as GLOBAL middleware on the
+ * `http.server` service. Global is load-bearing:
+ * `server.security.rateLimit` is a SERVER-level budget, and a limiter
+ * covering only this plugin's own routes while `/data` ran unmetered would
+ * be the same declared-≠-enforced half-truth the key was introduced to end.
+ *
+ * It goes in `start()` rather than `init()` so that "this kernel has no
+ * `http.server`" is a settled fact when it is reported, not a Phase-1 guess
+ * a later-initializing transport could contradict (#4771). That is only safe
+ * because the transport mounts the middleware SEAM at the end of its own
+ * `init()` — a `use()` at any later point still gates every route, so the
+ * gate does not have to win a race with route registration to be complete.
+ *
+ * Endpoint-level `ApiEndpointSchema.rateLimit` /
+ * `ApiEndpointRegistrationSchema.rateLimit` are NOT read here. They remain
+ * KNOWN-UNWIRED and are tracked by #4936, which owns the fate of the whole
+ * declarative `apis:` face — wiring one key of a surface whose existence is
+ * still undecided would have to be undone if that decision goes the other
+ * way.
+ */
+ rateLimit?: {
+ /** The authored `server.security.rateLimit` budget. */
+ budget?: InboundRateLimitBudget;
+ /** The authored `server.trustProxy`. */
+ trustProxy?: boolean;
+ };
+}
+
+/**
+ * `ctx.getService(name)` without the throw.
+ *
+ * The kernel's accessor raises for an unregistered name, and every consumer
+ * here treats absence as a legitimate composition ("no auth in this stack", "no
+ * cache service yet"). Spelled once so no branch quietly turns a missing
+ * OPTIONAL service into a boot failure.
+ */
+function safeGetService(ctx: PluginContext, name: string): T | undefined {
+ try {
+ return ctx.getService(name) ?? undefined;
+ } catch {
+ return undefined;
+ }
}
/**
@@ -450,7 +507,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
version: '1.0.0',
init: async (_ctx: PluginContext) => {
- // Consumer-only plugin — no services registered
+ // Consumer-only plugin — no services registered.
},
start: async (ctx: PluginContext) => {
@@ -459,8 +516,57 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
server = ctx.getService('http.server');
} catch {
// No HTTP server available — skip silently
- return;
+ server = undefined;
}
+
+ // ── Inbound rate limit (#4910) ──────────────────────────────
+ // Installed in `start()`, deliberately. Phase 1 is over, so "no
+ // `http.server`" is a FACT rather than a mid-boot guess a later
+ // plugin could contradict — the #4771 class of defect, which is
+ // exactly what makes the warning below safe to emit. The gate still
+ // precedes every route because the transport mounts the middleware
+ // SEAM at the end of its own `init()`; `use()` appends to a chain
+ // that seam reads per request, so registration order stops mattering
+ // (see `HonoHttpServer.installMiddlewareSeam`).
+ const rateLimitMiddleware = createInboundRateLimitMiddleware({
+ ...(config.rateLimit?.budget ? { budget: config.rateLimit.budget } : {}),
+ trustProxy: config.rateLimit?.trustProxy === true,
+ resolvePrincipalId: (headers) =>
+ resolveSessionPrincipalId(
+ safeGetService(ctx, 'auth'),
+ headers as Record,
+ ),
+ resolveCache: async () => safeGetService(ctx, 'cache'),
+ logger: ctx.logger,
+ });
+ // `null` = no budget declared, or declared disabled. Nothing is
+ // registered at all, so an unmetered deployment pays zero
+ // per-request cost — not a disabled check, no check.
+ if (rateLimitMiddleware) {
+ if (server) {
+ server.use(rateLimitMiddleware);
+ const budget = config.rateLimit?.budget;
+ ctx.logger.info('Inbound rate limit armed', {
+ maxRequests: budget?.maxRequests ?? 100,
+ windowMs: budget?.windowMs ?? 60_000,
+ // NOT `key:` — the logger redacts that field name, and
+ // `key: ***REDACTED***` tells an operator nothing about
+ // what the limit is actually keyed on.
+ keyedBy: 'principal, falling back to caller IP',
+ trustProxy: config.rateLimit?.trustProxy === true,
+ });
+ } else {
+ // Absence must be loud (route-ownership rule 3): a stack
+ // that ASKED to be rate limited and is not must never find
+ // out from a load test.
+ ctx.logger.warn(
+ '[dispatcher] `server.security.rateLimit` is enabled but this kernel has no `http.server` '
+ + 'service, so no request can be metered. Mount a transport plugin (e.g. '
+ + '@objectstack/plugin-hono-server), or remove the rate-limit declaration.',
+ );
+ }
+ }
+
if (!server) return;
const kernel = ctx.getKernel();
@@ -1149,15 +1255,12 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
// which claims the same thing while saying nothing.
const authService = ctx.getService('auth') as IAuthService | undefined;
if (!authService) return undefined;
- let api: any = authService.api;
- if (!api && typeof authService.getApi === 'function') {
- api = await authService.getApi();
- }
- if (!api?.getSession) return undefined;
- const headersInstance = headers instanceof Headers
- ? headers
- : new Headers(headers as Record);
- const sessionData = await api.getSession({ headers: headersInstance });
+ // [#4910] The session lookup itself lives in
+ // `security/resolve-session-principal.ts` — the inbound rate
+ // limiter asks the same question one kernel phase earlier,
+ // and two copies of "who is calling" would eventually
+ // disagree about what counts as authenticated.
+ const sessionData = await resolveSessionData(authService, headers);
const userId: string | undefined = sessionData?.user?.id ?? sessionData?.session?.userId;
if (!userId) return undefined;
// AI-route req.user permissions (incl. the synthesized `ai_seat`) are
diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts
index 713ab2b570..d3ec5706c7 100644
--- a/packages/runtime/src/error-envelope.conformance.test.ts
+++ b/packages/runtime/src/error-envelope.conformance.test.ts
@@ -229,6 +229,13 @@ describe('#3842 — no dispatcher module may reintroduce the drift', () => {
'./domain-handler-registry.ts',
'./domains/ai.ts',
'./domains/mcp.ts',
+ // [#4910] The inbound rate limiter writes a 429 body from a MIDDLEWARE
+ // rather than a route handler — a fifth way onto this wire surface, and
+ // therefore a fifth way to reintroduce a numeric `code`. Listed the day
+ // it was written, which is the whole point of the scan: the suite
+ // covering only the branches that existed when it was authored is how
+ // four sites drifted into three parking spots.
+ './security/inbound-rate-limit.ts',
];
for (const file of MODULES) {
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 2c648b435f..3daf787d6f 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -58,20 +58,38 @@ export type { DomainRoute, DomainHandler, DomainRequest, DomainHandlerDeps } fro
export { MiddlewareManager } from './middleware.js';
// ── Security primitives ───────────────────────────────────────────────
-// Adapter-agnostic helpers for response hardening (CSP/HSTS/XCTO/…)
-// and per-IP token-bucket rate limiting. The dispatcher plugin wires
-// security headers automatically; rate limiting is exposed as a
-// primitive so adapters can mount it at the appropriate layer (see
-// `docs/guide/hardening.md`).
+// Adapter-agnostic helpers for response hardening (CSP/HSTS/XCTO/…) and
+// token-bucket rate limiting.
+//
+// The dispatcher plugin wires BOTH automatically: security headers on
+// every response it mounts, and — when the stack declares
+// `server.security.rateLimit` — the inbound limiter as global middleware
+// on the `http.server` service (#4910). The pieces stay exported because
+// a host that composes its own transport still needs them; they are not
+// exported *instead* of being wired, which is what the previous version
+// of this comment claimed while pointing at a guide that did not exist
+// (#4937).
export {
buildSecurityHeaders,
type SecurityHeadersOptions,
RateLimiter,
DEFAULT_RATE_LIMITS,
+ applyTokenBucket,
+ bucketIdleTtlSeconds,
+ type BucketState,
type RateLimitBucketConfig,
type RateLimitDecision,
type RateLimitDefaults,
type RateLimitStore,
+ createInboundRateLimitMiddleware,
+ deriveBucketConfig,
+ resolveRateLimitKey,
+ SharedTokenBucketLimiter,
+ type InboundRateLimitBudget,
+ type InboundRateLimitOptions,
+ type RateLimitKeyInput,
+ type RateLimitKeyKind,
+ type RateLimitLogger,
} from './security/index.js';
// ── Observability primitives ──────────────────────────────────────────
diff --git a/packages/runtime/src/security/inbound-rate-limit.test.ts b/packages/runtime/src/security/inbound-rate-limit.test.ts
new file mode 100644
index 0000000000..014e78aaf3
--- /dev/null
+++ b/packages/runtime/src/security/inbound-rate-limit.test.ts
@@ -0,0 +1,376 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Unit coverage for the inbound rate-limit seam (#4910) — the derivation, the
+ * key shape, the shared counter, and the 429 the middleware writes.
+ *
+ * The end-to-end proof (a declared budget producing a real 429 over a real
+ * socket) lives in `../dispatcher-plugin.rate-limit.integration.test.ts`. Both
+ * are needed and neither substitutes for the other: #4937 happened because a
+ * bucket with excellent unit tests had no call site, and #4686 because a schema
+ * with excellent parse tests had no reader.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+import {
+ createInboundRateLimitMiddleware,
+ deriveBucketConfig,
+ resolveRateLimitKey,
+ SharedTokenBucketLimiter,
+} from './inbound-rate-limit.js';
+import type { CounterStore } from '@objectstack/plugin-auth';
+
+/** An in-test counter store that also records whether it was ever consulted. */
+function memoryStore() {
+ const entries = new Map();
+ return {
+ entries,
+ store: {
+ async get(key: string) { return entries.get(key) as T | undefined; },
+ async set(key: string, value: T) { entries.set(key, value); },
+ } satisfies CounterStore,
+ };
+}
+
+describe('deriveBucketConfig — authored budget → token bucket', () => {
+ it('maps maxRequests to capacity and derives the refill rate from the window', () => {
+ expect(deriveBucketConfig({ enabled: true, maxRequests: 60, windowMs: 60_000 }))
+ .toEqual({ capacity: 60, refillPerSec: 1 });
+ expect(deriveBucketConfig({ enabled: true, maxRequests: 10, windowMs: 1_000 }))
+ .toEqual({ capacity: 10, refillPerSec: 10 });
+ });
+
+ it('consumes every key the authoring surface exposes', () => {
+ // Per-key liveness, asserted rather than asserted-about: `enabled` arms
+ // it, and BOTH numeric keys move the derived bucket. A key that changed
+ // nothing here would be a silent rider — the defect #4686 opened on.
+ expect(deriveBucketConfig({ enabled: false, maxRequests: 60, windowMs: 60_000 })).toBeNull();
+ expect(deriveBucketConfig(undefined)).toBeNull();
+
+ const base = deriveBucketConfig({ enabled: true, maxRequests: 60, windowMs: 60_000 })!;
+ expect(deriveBucketConfig({ enabled: true, maxRequests: 120, windowMs: 60_000 })!.capacity)
+ .not.toBe(base.capacity);
+ expect(deriveBucketConfig({ enabled: true, maxRequests: 60, windowMs: 30_000 })!.refillPerSec)
+ .not.toBe(base.refillPerSec);
+ });
+
+ it('applies the schema defaults when only `enabled` reaches the runtime', () => {
+ expect(deriveBucketConfig({ enabled: true })).toEqual({ capacity: 100, refillPerSec: 100 / 60 });
+ });
+
+ it('refuses a budget that can never admit a request, prescriptively', () => {
+ expect(() => deriveBucketConfig({ enabled: true, maxRequests: 0 }))
+ .toThrow(/set `enabled: false`/);
+ expect(() => deriveBucketConfig({ enabled: true, windowMs: 0 }))
+ .toThrow(/MILLISECONDS/);
+ });
+});
+
+describe('resolveRateLimitKey — Q3=C, principal first, IP only as a fallback', () => {
+ it('keys an authenticated caller by principal, ignoring their address entirely', () => {
+ expect(resolveRateLimitKey({
+ principalId: 'usr_1',
+ remoteAddress: '10.0.0.5',
+ headers: { 'x-forwarded-for': '9.9.9.9' },
+ trustProxy: true,
+ })).toEqual({ key: 'principal:usr_1', kind: 'principal' });
+ });
+
+ it('gives two principals behind ONE address two buckets', () => {
+ const a = resolveRateLimitKey({ principalId: 'usr_a', remoteAddress: '10.0.0.5', trustProxy: false });
+ const b = resolveRateLimitKey({ principalId: 'usr_b', remoteAddress: '10.0.0.5', trustProxy: false });
+ expect(a.key).not.toBe(b.key);
+ });
+
+ it('falls back to the transport peer address for anonymous traffic', () => {
+ expect(resolveRateLimitKey({ remoteAddress: '10.0.0.5', trustProxy: false }))
+ .toEqual({ key: 'ip:10.0.0.5', kind: 'ip' });
+ });
+
+ it('IGNORES X-Forwarded-For when trustProxy was not declared', () => {
+ // The security property this key exists to protect: an undeclared proxy
+ // means the header is attacker input. Believing it would hand every
+ // caller an unlimited supply of fresh buckets AND let them spend a
+ // chosen victim's.
+ expect(resolveRateLimitKey({
+ remoteAddress: '10.0.0.5',
+ headers: { 'x-forwarded-for': '203.0.113.9' },
+ trustProxy: false,
+ })).toEqual({ key: 'ip:10.0.0.5', kind: 'ip' });
+ });
+
+ it('honours X-Forwarded-For once trustProxy IS declared', () => {
+ expect(resolveRateLimitKey({
+ remoteAddress: '10.0.0.5',
+ headers: { 'x-forwarded-for': '203.0.113.9' },
+ trustProxy: true,
+ })).toEqual({ key: 'ip:203.0.113.9', kind: 'ip' });
+ });
+
+ it('takes the LEFTMOST forwarded entry — the original client, not the last hop', () => {
+ expect(resolveRateLimitKey({
+ headers: { 'x-forwarded-for': '203.0.113.9, 70.41.3.18, 150.172.238.178' },
+ trustProxy: true,
+ }).key).toBe('ip:203.0.113.9');
+ });
+
+ it('accepts X-Real-IP as the fallback forwarded header', () => {
+ expect(resolveRateLimitKey({ headers: { 'x-real-ip': '198.51.100.7' }, trustProxy: true }).key)
+ .toBe('ip:198.51.100.7');
+ });
+
+ it('over-throttles rather than failing open when nothing identifies the caller', () => {
+ expect(resolveRateLimitKey({ trustProxy: false }))
+ .toEqual({ key: 'ip:unknown', kind: 'unknown' });
+ });
+});
+
+describe('SharedTokenBucketLimiter — counters live in the shared store (ADR-0069 D2)', () => {
+ it('spends the bucket down and then denies with a real retry hint', async () => {
+ const { store } = memoryStore();
+ let clock = 1_000_000;
+ const limiter = new SharedTokenBucketLimiter(
+ { capacity: 2, refillPerSec: 1 },
+ async () => store,
+ () => clock,
+ );
+
+ expect((await limiter.consume('k')).allowed).toBe(true);
+ expect((await limiter.consume('k')).allowed).toBe(true);
+
+ const denied = await limiter.consume('k');
+ expect(denied.allowed).toBe(false);
+ expect(denied.retryAfterMs).toBe(1000);
+ expect(denied.resetAt).toBe(clock + 1000);
+ });
+
+ it('refills over time, so a denied caller recovers without intervention', async () => {
+ const { store } = memoryStore();
+ let clock = 1_000_000;
+ const limiter = new SharedTokenBucketLimiter({ capacity: 1, refillPerSec: 1 }, async () => store, () => clock);
+
+ expect((await limiter.consume('k')).allowed).toBe(true);
+ expect((await limiter.consume('k')).allowed).toBe(false);
+ clock += 1000;
+ expect((await limiter.consume('k')).allowed).toBe(true);
+ });
+
+ it('counts two nodes sharing a store against ONE budget', async () => {
+ // The #4772 lesson, as an assertion: with a shared store the effective
+ // limit is the declared limit; with per-process stores it silently
+ // becomes `declared × nodes` and nothing looks wrong.
+ const { store } = memoryStore();
+ const clock = () => 1_000_000;
+ const nodeA = new SharedTokenBucketLimiter({ capacity: 2, refillPerSec: 1 }, async () => store, clock);
+ const nodeB = new SharedTokenBucketLimiter({ capacity: 2, refillPerSec: 1 }, async () => store, clock);
+
+ expect((await nodeA.consume('principal:u')).allowed).toBe(true);
+ expect((await nodeB.consume('principal:u')).allowed).toBe(true);
+ expect((await nodeB.consume('principal:u')).allowed).toBe(false);
+ });
+
+ it('treats a foreign value under the key as absent instead of throwing on a live request', async () => {
+ const { store, entries } = memoryStore();
+ entries.set('k', 'not json');
+ const limiter = new SharedTokenBucketLimiter({ capacity: 1, refillPerSec: 1 }, async () => store);
+ expect((await limiter.consume('k')).allowed).toBe(true);
+ });
+});
+
+/** A request/response pair shaped like the `IHttpServer` middleware contract. */
+function fakeExchange(req: Record = {}) {
+ const captured: { status: number; headers: Record; body?: unknown } = {
+ status: 200,
+ headers: {},
+ };
+ let nexted = false;
+ const res: any = {
+ status(code: number) { captured.status = code; return res; },
+ header(name: string, value: string) { captured.headers[name] = value; return res; },
+ json(data: unknown) { captured.body = data; },
+ send(data: unknown) { captured.body = data; },
+ };
+ return {
+ req: { method: 'GET', path: '/api/v1/health', headers: {}, query: {}, params: {}, ...req } as any,
+ res,
+ captured,
+ next: async () => { nexted = true; },
+ get nexted() { return nexted; },
+ };
+}
+
+describe('createInboundRateLimitMiddleware', () => {
+ it('registers NOTHING when the stack declared no budget', () => {
+ expect(createInboundRateLimitMiddleware({ resolveCache: async () => undefined })).toBeNull();
+ expect(createInboundRateLimitMiddleware({
+ budget: { enabled: false, maxRequests: 1 },
+ resolveCache: async () => undefined,
+ })).toBeNull();
+ });
+
+ it('answers 429 with Retry-After once the bucket is empty', async () => {
+ const { store } = memoryStore();
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 1, windowMs: 10_000 },
+ resolveCache: async () => store,
+ })!;
+
+ const first = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(first.req, first.res, first.next);
+ expect(first.nexted).toBe(true);
+ expect(first.captured.body).toBeUndefined();
+
+ const second = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(second.req, second.res, second.next);
+ expect(second.nexted).toBe(false);
+ expect(second.captured.status).toBe(429);
+ expect(Number(second.captured.headers['Retry-After'])).toBeGreaterThanOrEqual(1);
+
+ const body = second.captured.body as { success: boolean; error: Record };
+ expect(body.success).toBe(false);
+ expect(body.error.code).toBe('RATE_LIMIT_EXCEEDED');
+ expect(body.error.httpStatus).toBe(429);
+ });
+
+ it('meters an authenticated caller separately from an anonymous one on the same address', async () => {
+ const { store } = memoryStore();
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 1, windowMs: 10_000 },
+ resolvePrincipalId: async (headers) => (headers['x-test-user'] as string | undefined),
+ resolveCache: async () => store,
+ })!;
+
+ const anon = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(anon.req, anon.res, anon.next);
+ expect(anon.nexted).toBe(true);
+
+ // Same address, but a principal — its own bucket, so it is not already
+ // spent by the anonymous request above.
+ const authed = fakeExchange({ remoteAddress: '10.0.0.1', headers: { 'x-test-user': 'usr_1' } });
+ await middleware(authed.req, authed.res, authed.next);
+ expect(authed.nexted).toBe(true);
+
+ const anonAgain = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(anonAgain.req, anonAgain.res, anonAgain.next);
+ expect(anonAgain.captured.status).toBe(429);
+ });
+
+ it('never meters a CORS preflight', async () => {
+ const { store, entries } = memoryStore();
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 1, windowMs: 10_000 },
+ resolveCache: async () => store,
+ })!;
+
+ for (let i = 0; i < 5; i++) {
+ const exchange = fakeExchange({ method: 'OPTIONS', remoteAddress: '10.0.0.1' });
+ await middleware(exchange.req, exchange.res, exchange.next);
+ expect(exchange.nexted).toBe(true);
+ }
+ // Not merely allowed — never counted, so preflights cannot exhaust the
+ // budget a real request needs.
+ expect(entries.size).toBe(0);
+ });
+
+ it('treats an auth service that throws as anonymous, not as an outage', async () => {
+ const { store } = memoryStore();
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 5, windowMs: 10_000 },
+ resolvePrincipalId: async () => { throw new Error('auth is down'); },
+ resolveCache: async () => store,
+ })!;
+
+ const exchange = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(exchange.req, exchange.res, exchange.next);
+ expect(exchange.nexted).toBe(true);
+ await expect(store.get('ip:10.0.0.1')).resolves.toBeDefined();
+ });
+});
+
+describe('degradation is announced once, with consequence and remedy (ADR-0069 D2)', () => {
+ it('warns exactly once when there is no cache service to count in', async () => {
+ const warn = vi.fn();
+ const info = vi.fn();
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 10, windowMs: 10_000 },
+ resolveCache: async () => undefined,
+ logger: { warn, info },
+ })!;
+
+ for (let i = 0; i < 3; i++) {
+ const exchange = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(exchange.req, exchange.res, exchange.next);
+ }
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ const message = String(warn.mock.calls[0]![0]);
+ // Names the subsystem that degraded (not `[auth]` — the resolution
+ // helper is shared with plugin-auth and used to hardcode that prefix),
+ // the consequence, and the remedy.
+ expect(message).toMatch(/^\[dispatcher\]/);
+ expect(message).toMatch(/inbound rate-limit buckets/);
+ expect(message).toMatch(/MULTIPLIED BY the number of nodes/);
+ expect(message).toMatch(/Redis via @objectstack\/service-cache/);
+ });
+
+ it('announces the healthy bind once instead of staying silent about it', async () => {
+ const { store } = memoryStore();
+ const info = vi.fn();
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 10, windowMs: 10_000 },
+ resolveCache: async () => store,
+ logger: { info, warn: vi.fn() },
+ })!;
+
+ const exchange = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(exchange.req, exchange.res, exchange.next);
+ await middleware(exchange.req, exchange.res, exchange.next);
+
+ expect(info).toHaveBeenCalledTimes(1);
+ expect(String(info.mock.calls[0]![0])).toMatch(/bound to the kernel cache service/);
+ });
+
+ it('resolves the cache LAZILY, so a cache plugin that registers later still counts (#4772)', async () => {
+ const { store, entries } = memoryStore();
+ let cacheAvailable = false;
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 10, windowMs: 10_000 },
+ resolveCache: async () => (cacheAvailable ? store : undefined),
+ logger: { warn: vi.fn(), info: vi.fn() },
+ })!;
+
+ // Boot-time request: no cache yet, counted in the per-process fallback.
+ const early = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(early.req, early.res, early.next);
+ expect(entries.size).toBe(0);
+
+ // The cache service comes up …
+ cacheAvailable = true;
+ const later = fakeExchange({ remoteAddress: '10.0.0.1' });
+ await middleware(later.req, later.res, later.next);
+ // … and the very next count lands in it, with no restart.
+ await expect(store.get('ip:10.0.0.1')).resolves.toBeDefined();
+ });
+
+ it('warns once when the transport cannot identify anonymous callers at all', async () => {
+ const { store } = memoryStore();
+ const warn = vi.fn();
+ const middleware = createInboundRateLimitMiddleware({
+ budget: { enabled: true, maxRequests: 10, windowMs: 10_000 },
+ resolveCache: async () => store,
+ logger: { warn, info: vi.fn() },
+ })!;
+
+ for (let i = 0; i < 3; i++) {
+ const exchange = fakeExchange({});
+ await middleware(exchange.req, exchange.res, exchange.next);
+ }
+
+ const unknownWarnings = warn.mock.calls
+ .map((c) => String(c[0]))
+ .filter((m) => m.includes('cannot identify anonymous callers'));
+ expect(unknownWarnings).toHaveLength(1);
+ expect(unknownWarnings[0]).toMatch(/trustProxy/);
+ });
+});
diff --git a/packages/runtime/src/security/inbound-rate-limit.ts b/packages/runtime/src/security/inbound-rate-limit.ts
new file mode 100644
index 0000000000..64af1b12b6
--- /dev/null
+++ b/packages/runtime/src/security/inbound-rate-limit.ts
@@ -0,0 +1,362 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Inbound rate limiting — the seam between the authored
+ * `server.security.rateLimit` budget and a request that actually gets a 429.
+ *
+ * ## Why this file exists (#4910, #4937, #4686)
+ *
+ * `packages/spec` declared three `RateLimitConfig` embeddings and the repo had
+ * ZERO readers for any of them: an author wrote a budget, it parsed, and nothing
+ * happened (#4686). `runtime/security/rate-limit.ts` held a token bucket whose
+ * comments claimed, in the present tense, to be wired into the dispatcher — it
+ * had no call sites at all (#4937). Two halves of one mechanism, neither
+ * touching the other, each documented as if it did.
+ *
+ * This module is the connective tissue the maintainer adjudicated on
+ * 2026-08-03: a NARROW authorable `server:` key (Q1=B), server-level only
+ * (Q2=B — endpoint-level `rateLimit` stays knowingly unwired, tracked by
+ * #4936), keyed principal-first with IP fallback and forwarded headers believed
+ * only under an explicit `trustProxy` (Q3=C), counting in the kernel cache with
+ * an announced per-process fallback (Q4=B / ADR-0069 D2).
+ *
+ * ## Shape of the thing
+ *
+ * ```
+ * authored budget derived bucket per-request
+ * ──────────────── ────────────── ───────────
+ * { enabled, windowMs, → { capacity, → resolveRateLimitKey()
+ * maxRequests } refillPerSec } → consume() → 429?
+ * ```
+ *
+ * Every key the authoring surface exposes is consumed here — `enabled` arms it,
+ * `windowMs` + `maxRequests` size it, `trustProxy` decides whose address counts.
+ * That is the per-key liveness bar #4910 set: no key arrives without its reader.
+ */
+
+import type { Middleware, IHttpRequest, IHttpResponse } from '@objectstack/core';
+import { createLazyCounterStore, type CounterStore } from '@objectstack/plugin-auth';
+
+import { buildApiError } from '../error-envelope.js';
+import {
+ applyTokenBucket,
+ bucketIdleTtlSeconds,
+ type BucketState,
+ type RateLimitBucketConfig,
+ type RateLimitDecision,
+} from './rate-limit.js';
+
+/**
+ * The authored budget, as it arrives from `server.security.rateLimit`
+ * (`@objectstack/spec` `ServerRateLimitConfigSchema`). Restated structurally
+ * rather than imported as a type so `packages/runtime` keeps depending on the
+ * spec's SHAPE, not on a Zod inference chain.
+ */
+export interface InboundRateLimitBudget {
+ /** Arm the limiter. `false` (the default) leaves every request unmetered. */
+ enabled?: boolean;
+ /** Budget window in milliseconds. */
+ windowMs?: number;
+ /** Requests permitted per window. */
+ maxRequests?: number;
+}
+
+/**
+ * Derive the token bucket from an authored budget.
+ *
+ * The mapping (#4686's, confirmed against `RateLimitConfigSchema` on
+ * `origin/main`):
+ *
+ * capacity = maxRequests — a full bucket absorbs
+ * one whole window's worth
+ * of traffic as a burst
+ * refillPerSec = maxRequests / (windowMs / 1000) — and sustains exactly the
+ * declared long-run rate
+ *
+ * Returns `null` when the budget is absent or `enabled` is not set — "not
+ * armed" is a normal state, not an error.
+ *
+ * Throws when the budget is armed but unusable. `maxRequests: 0` or
+ * `windowMs: 0` would produce a bucket that can never admit anything or a
+ * division by zero, and the schema's `.int()` alone does not exclude them. The
+ * error is prescriptive because it surfaces at boot, where the author can still
+ * act on it — silently clamping to a default would be the "parses, then does
+ * something other than what you wrote" failure this whole issue exists to end.
+ */
+export function deriveBucketConfig(budget: InboundRateLimitBudget | undefined): RateLimitBucketConfig | null {
+ if (!budget?.enabled) return null;
+
+ const maxRequests = budget.maxRequests ?? 100;
+ const windowMs = budget.windowMs ?? 60_000;
+
+ if (!Number.isFinite(maxRequests) || maxRequests <= 0) {
+ throw new Error(
+ `server.security.rateLimit.maxRequests must be greater than 0 (got ${String(budget.maxRequests)}). `
+ + 'A zero budget rejects every request including your own health checks; to turn rate limiting off, '
+ + 'set `enabled: false` instead.',
+ );
+ }
+ if (!Number.isFinite(windowMs) || windowMs <= 0) {
+ throw new Error(
+ `server.security.rateLimit.windowMs must be greater than 0 (got ${String(budget.windowMs)}). `
+ + 'It is the budget window in MILLISECONDS — 60000 is one minute.',
+ );
+ }
+
+ return {
+ capacity: maxRequests,
+ refillPerSec: maxRequests / (windowMs / 1000),
+ };
+}
+
+/** How a rate-limit key was determined — reported once at boot, and in tests. */
+export type RateLimitKeyKind = 'principal' | 'ip' | 'unknown';
+
+export interface RateLimitKeyInput {
+ /** Resolved principal id, when the request carried a valid session. */
+ principalId?: string;
+ /** Request headers, lower-cased as every adapter delivers them. */
+ headers?: Record;
+ /** The transport's peer address (`IHttpRequest.remoteAddress`). */
+ remoteAddress?: string;
+ /** `server.trustProxy` — believe forwarded headers. */
+ trustProxy: boolean;
+}
+
+function firstHeader(
+ headers: Record | undefined,
+ name: string,
+): string | undefined {
+ if (!headers) return undefined;
+ const raw = headers[name] ?? headers[name.toLowerCase()];
+ const value = Array.isArray(raw) ? raw[0] : raw;
+ if (typeof value !== 'string') return undefined;
+ // `X-Forwarded-For: client, proxy1, proxy2` — the LEFTMOST entry is the
+ // original client. It is also the entry a client controls, which is exactly
+ // why reading it at all requires `trustProxy`.
+ const first = value.split(',')[0]?.trim();
+ return first ? first : undefined;
+}
+
+/**
+ * Q3=C, in code: the bucket is keyed by **who**, and only falls back to
+ * **where** when there is no who.
+ *
+ * - a resolved principal keys its own bucket, so one abusive session cannot
+ * spend another user's budget, and a user behind a shared NAT is not
+ * throttled by their neighbours;
+ * - anonymous traffic — credential stuffing, scraping, the traffic that most
+ * needs a limit and has no identity yet — keys off the caller address;
+ * - that address comes from `X-Forwarded-For` / `X-Real-IP` **only** when
+ * `server.trustProxy` was declared. Untrusted, those headers are attacker
+ * input: honouring them by default would let anyone mint unlimited buckets
+ * (bypass) or drain a chosen victim's (weaponise). Undeclared, the address is
+ * the transport peer, which a client cannot influence.
+ *
+ * When neither is available (a runtime that exposes no peer address, no trusted
+ * header) the key is a single shared `ip:unknown` bucket. That deliberately
+ * over-throttles rather than failing open: an unlimited unknown is the one
+ * outcome a rate limiter must never produce, and the caller announces the
+ * condition once — see {@link createInboundRateLimitMiddleware}.
+ */
+export function resolveRateLimitKey(input: RateLimitKeyInput): { key: string; kind: RateLimitKeyKind } {
+ if (input.principalId) return { key: `principal:${input.principalId}`, kind: 'principal' };
+
+ const forwarded = input.trustProxy
+ ? firstHeader(input.headers, 'x-forwarded-for') ?? firstHeader(input.headers, 'x-real-ip')
+ : undefined;
+ const address = forwarded ?? input.remoteAddress;
+
+ if (address) return { key: `ip:${address}`, kind: 'ip' };
+ return { key: 'ip:unknown', kind: 'unknown' };
+}
+
+/** The stored bucket envelope. Private to this module — every read goes through {@link parseBucket}. */
+interface StoredBucket {
+ /** tokens */
+ t: number;
+ /** lastRefill (epoch ms) */
+ r: number;
+}
+
+/**
+ * Read back a bucket envelope. Cache adapters differ on whether a value comes
+ * back as the stored string (Redis) or the original object (memory), so both
+ * are accepted; anything else is treated as absent, which restarts the bucket
+ * full rather than throwing on a live request. Same tolerance, and the same
+ * reason, as `plugin-auth`'s `parseCounter`.
+ */
+function parseBucket(raw: unknown): BucketState | undefined {
+ if (raw === undefined || raw === null) return undefined;
+ let value: unknown = raw;
+ if (typeof raw === 'string') {
+ try { value = JSON.parse(raw); } catch { return undefined; }
+ }
+ if (typeof value !== 'object' || value === null) return undefined;
+ const { t, r } = value as Partial;
+ if (typeof t !== 'number' || !Number.isFinite(t)) return undefined;
+ if (typeof r !== 'number' || !Number.isFinite(r)) return undefined;
+ return { tokens: t, lastRefill: r };
+}
+
+/**
+ * A token bucket whose state lives in a {@link CounterStore} — the kernel
+ * `cache` service when one is registered, a bounded per-process map otherwise.
+ *
+ * ADR-0069 D2, and the #4772 lesson it was written from: per-instance counters
+ * mean the effective limit is `declared × instances`, which is a SILENT
+ * under-enforcement — nothing looks wrong, the limit simply is not the limit.
+ * The store is resolved at CONSUME time, never at plugin init, because a cache
+ * plugin can register after this one (that exact ordering bug is #4772).
+ *
+ * Like plugin-auth's fixed-window counter this stays read-modify-write in the
+ * absence of an atomic primitive, so two nodes can both admit a request at the
+ * boundary and the limit can over-admit slightly under concurrency. That is
+ * strictly better than counting per node, and the day `ICacheService` grows an
+ * atomic increment this is where it plugs in.
+ */
+export class SharedTokenBucketLimiter {
+ constructor(
+ private readonly config: RateLimitBucketConfig,
+ private readonly resolveStore: () => Promise,
+ private readonly now: () => number = Date.now,
+ ) {}
+
+ async consume(key: string, cost = this.config.defaultCost ?? 1): Promise {
+ const store = await this.resolveStore();
+ const now = this.now();
+ const current = parseBucket(await store.get(key));
+ const { state, decision } = applyTokenBucket(current, this.config, now, cost);
+ const envelope: StoredBucket = { t: state.tokens, r: state.lastRefill };
+ await store.set(key, JSON.stringify(envelope), bucketIdleTtlSeconds(this.config));
+ return decision;
+ }
+}
+
+/** Minimal logger surface — matches the kernel logger without importing it. */
+export interface RateLimitLogger {
+ info?(message: string, meta?: unknown): void;
+ warn?(message: string, meta?: unknown): void;
+}
+
+export interface InboundRateLimitOptions {
+ /** The authored `server.security.rateLimit` budget. */
+ budget?: InboundRateLimitBudget;
+ /** The authored `server.trustProxy`. */
+ trustProxy?: boolean;
+ /**
+ * Resolve the caller's principal id from request headers. Returning
+ * `undefined` means anonymous, which is a normal outcome and keys by IP.
+ */
+ resolvePrincipalId?: (headers: Record) => Promise;
+ /** Resolve the kernel `cache` service. Called per consume — see ADR-0069 D2. */
+ resolveCache: () => Promise;
+ logger?: RateLimitLogger;
+ /** Injectable clock — tests only. */
+ now?: () => number;
+}
+
+/**
+ * HTTP methods that are never metered.
+ *
+ * A CORS preflight carries no credentials and performs no work; throttling it
+ * does not slow an attacker down (they can skip it) but does break every
+ * cross-origin caller of a legitimately busy app, in a way that surfaces in the
+ * browser as an opaque CORS failure rather than a 429.
+ */
+const UNMETERED_METHODS = new Set(['OPTIONS']);
+
+/**
+ * Build the inbound rate-limit middleware, or `null` when the budget is not
+ * armed (so a caller can skip registering anything at all).
+ *
+ * The 429 body goes through {@link buildApiError} like every other error on
+ * this wire surface (#3842), and carries `Retry-After` in seconds — computed
+ * from the bucket's own `retryAfterMs`, so the number a client is told to wait
+ * is the number the bucket will actually take to refill.
+ */
+export function createInboundRateLimitMiddleware(opts: InboundRateLimitOptions): Middleware | null {
+ const config = deriveBucketConfig(opts.budget);
+ if (!config) return null;
+
+ const trustProxy = opts.trustProxy === true;
+ const resolveStore = createLazyCounterStore({
+ resolveCache: opts.resolveCache,
+ ...(opts.logger ? { logger: opts.logger as { info?(m: string): void; warn?(m: string): void } } : {}),
+ subject: 'inbound rate-limit buckets',
+ degradedImpact:
+ 'Until a shared cache is registered the effective limit is the declared budget MULTIPLIED BY the number '
+ + 'of nodes, and nothing about the deployment will look wrong.',
+ logPrefix: '[dispatcher]',
+ });
+ const limiter = new SharedTokenBucketLimiter(config, resolveStore, opts.now ?? Date.now);
+
+ let unknownKeyWarned = false;
+
+ return async (req: IHttpRequest, res: IHttpResponse, next: () => void | Promise) => {
+ if (UNMETERED_METHODS.has(String(req.method ?? '').toUpperCase())) {
+ await next();
+ return;
+ }
+
+ let principalId: string | undefined;
+ if (opts.resolvePrincipalId) {
+ try {
+ principalId = await opts.resolvePrincipalId(req.headers ?? {});
+ } catch {
+ // Unresolvable identity is anonymous, not an error — the route's
+ // own auth gate still runs. Failing the request here would turn
+ // an auth hiccup into an outage.
+ principalId = undefined;
+ }
+ }
+
+ const { key, kind } = resolveRateLimitKey({
+ ...(principalId ? { principalId } : {}),
+ headers: req.headers ?? {},
+ ...(req.remoteAddress ? { remoteAddress: req.remoteAddress } : {}),
+ trustProxy,
+ });
+
+ if (kind === 'unknown' && !unknownKeyWarned) {
+ unknownKeyWarned = true;
+ // Functional degradation, not durability — the limit is still
+ // enforced, just coarsely — so `warn` per the repo's log-level rule.
+ opts.logger?.warn?.(
+ '[dispatcher] inbound rate limit cannot identify anonymous callers: this transport exposes no peer '
+ + 'address and no trusted forwarded header, so ALL anonymous traffic shares ONE bucket and will '
+ + 'throttle each other. Fix by terminating on a proxy that sets X-Forwarded-For and declaring '
+ + '`server.trustProxy: true`, or by serving through an adapter that reports the socket address.',
+ );
+ }
+
+ let decision: RateLimitDecision;
+ try {
+ decision = await limiter.consume(key);
+ } catch {
+ // A store that throws must not take the API down with it. This is
+ // the one fail-open in the file, and it is bounded: it can only
+ // happen when the cache backend itself is erroring, at which point
+ // the deployment has a louder problem than an unmetered request.
+ await next();
+ return;
+ }
+
+ if (decision.allowed) {
+ await next();
+ return;
+ }
+
+ const retryAfterSec = Math.max(1, Math.ceil(decision.retryAfterMs / 1000));
+ res.status(429);
+ res.header('Retry-After', String(retryAfterSec));
+ res.json({
+ success: false,
+ error: buildApiError({
+ message: 'Rate limit exceeded. Retry after the interval in the Retry-After header.',
+ httpStatus: 429,
+ details: { retryAfterSeconds: retryAfterSec, resetAt: new Date(decision.resetAt).toISOString() },
+ }),
+ });
+ };
+}
diff --git a/packages/runtime/src/security/index.ts b/packages/runtime/src/security/index.ts
index cfbbf57dec..bb467a6d82 100644
--- a/packages/runtime/src/security/index.ts
+++ b/packages/runtime/src/security/index.ts
@@ -7,11 +7,25 @@ export {
export {
RateLimiter,
DEFAULT_RATE_LIMITS,
+ applyTokenBucket,
+ bucketIdleTtlSeconds,
+ type BucketState,
type RateLimitBucketConfig,
type RateLimitDecision,
type RateLimitDefaults,
type RateLimitStore,
} from './rate-limit.js';
+export {
+ createInboundRateLimitMiddleware,
+ deriveBucketConfig,
+ resolveRateLimitKey,
+ SharedTokenBucketLimiter,
+ type InboundRateLimitBudget,
+ type InboundRateLimitOptions,
+ type RateLimitKeyInput,
+ type RateLimitKeyKind,
+ type RateLimitLogger,
+} from './inbound-rate-limit.js';
export {
API_KEY_PREFIX,
hashApiKey,
diff --git a/packages/runtime/src/security/rate-limit.ts b/packages/runtime/src/security/rate-limit.ts
index 0f903c24bc..e76c37d4b1 100644
--- a/packages/runtime/src/security/rate-limit.ts
+++ b/packages/runtime/src/security/rate-limit.ts
@@ -1,15 +1,40 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
- * In-memory token-bucket rate limiter.
+ * Token-bucket rate limiting — the algorithm, and its synchronous
+ * in-process front end.
*
- * Designed to be adapter-agnostic — the dispatcher calls `consume(key)`
- * with a request fingerprint (IP, IP+route bucket, or user id) and
- * short-circuits with 429 if the bucket is empty.
+ * ## Read this before trusting a comment in this file (#4937)
*
- * For production multi-instance deploys, swap the in-memory store via
- * `RateLimitStore`. The shape is intentionally narrow so a Redis-backed
- * implementation is straightforward.
+ * Until #4910 the header here asserted, in the present tense, that "the
+ * dispatcher calls `consume(key)` … and short-circuits with 429", that a
+ * deployment could "tune via `DispatcherPluginConfig.rateLimit`", and that "the
+ * dispatcher constructs the key from `${ip}:${bucket}`". **None of it was true.**
+ * `RateLimiter` had exactly zero call sites outside its own unit test, no
+ * inbound path answered 429 anywhere in the repo, and `DispatcherPluginConfig`
+ * had no `rateLimit` field. The file described a design, in the tense reserved
+ * for behaviour, and cost a full investigation to disprove (#4937).
+ *
+ * It is true now, and the sentences below are worded so a future reader can
+ * check each one against a call site rather than take it on faith:
+ *
+ * - `createDispatcherPlugin({ rateLimit })` (`../dispatcher-plugin.ts`) builds
+ * a limiter and installs it as global middleware on the `http.server`
+ * service, so every request that server routes passes through it.
+ * - the key is built by `resolveRateLimitKey` in `./inbound-rate-limit.ts` —
+ * resolved principal first, caller IP for anonymous traffic — NOT
+ * `${ip}:${bucket}`.
+ * - the budget comes from the authored `server.security.rateLimit` block
+ * (`@objectstack/spec` `StackServerConfigSchema`), forwarded by
+ * `objectstack serve`.
+ *
+ * ## This module vs `./inbound-rate-limit.ts`
+ *
+ * The bucket ARITHMETIC lives here, in {@link applyTokenBucket}, and has two
+ * front ends over it so there is one algorithm and no second code path to
+ * drift: {@link RateLimiter} (synchronous, in-process — used by embedders and
+ * by the tests that pin the maths) and the async, shared-store limiter in
+ * `./inbound-rate-limit.ts` (counters in the kernel cache, ADR-0069 D2).
*/
export interface RateLimitDecision {
@@ -31,12 +56,88 @@ export interface RateLimitBucketConfig {
defaultCost?: number;
}
-interface BucketState {
+export interface BucketState {
tokens: number;
/** Last refill timestamp (ms). */
lastRefill: number;
}
+/**
+ * The token-bucket step, as a pure function: refill for elapsed time, then
+ * spend `cost` if the bucket can afford it.
+ *
+ * Extracted (#4910) so the synchronous {@link RateLimiter} and the async
+ * shared-store limiter in `./inbound-rate-limit.ts` cannot disagree about what
+ * a bucket does. A divergent second implementation is how "the limit works in
+ * tests but not in production" happens — the same reasoning
+ * `plugin-auth/rate-limit-storage.ts` records for its fixed-window counter.
+ *
+ * @param state current bucket state, or `undefined` for a bucket that has
+ * never been touched (starts full).
+ * @param config capacity + refill rate.
+ * @param now wall-clock ms.
+ * @param cost tokens this request wants to spend.
+ * @returns the state to persist and the decision to answer with. The state is
+ * returned in BOTH branches on purpose: a denied request must still
+ * write back its refill, or a caller that retries in a tight loop
+ * never accrues tokens.
+ */
+export function applyTokenBucket(
+ state: BucketState | undefined,
+ config: RateLimitBucketConfig,
+ now: number,
+ cost: number,
+): { state: BucketState; decision: RateLimitDecision } {
+ const { capacity, refillPerSec } = config;
+
+ let next: BucketState;
+ if (!state) {
+ next = { tokens: capacity, lastRefill: now };
+ } else {
+ const elapsedSec = (now - state.lastRefill) / 1000;
+ next = elapsedSec > 0
+ ? { tokens: Math.min(capacity, state.tokens + elapsedSec * refillPerSec), lastRefill: now }
+ : { tokens: state.tokens, lastRefill: state.lastRefill };
+ }
+
+ if (next.tokens >= cost) {
+ next.tokens -= cost;
+ return {
+ state: next,
+ decision: {
+ allowed: true,
+ remaining: Math.floor(next.tokens),
+ retryAfterMs: 0,
+ resetAt: now + Math.ceil(((capacity - next.tokens) / refillPerSec) * 1000),
+ },
+ };
+ }
+
+ const retryAfterMs = Math.ceil(((cost - next.tokens) / refillPerSec) * 1000);
+ return {
+ state: next,
+ decision: {
+ allowed: false,
+ remaining: Math.floor(next.tokens),
+ retryAfterMs,
+ resetAt: now + retryAfterMs,
+ },
+ };
+}
+
+/**
+ * Seconds after which a bucket for `config` is indistinguishable from an
+ * untouched one (it has refilled to capacity), plus one second of slack.
+ *
+ * This is the TTL a shared store should give a bucket entry: past it, dropping
+ * the row and re-creating it full is not an approximation, it is the same
+ * answer — which is what keeps a per-principal keyspace from growing without
+ * bound in Redis.
+ */
+export function bucketIdleTtlSeconds(config: RateLimitBucketConfig): number {
+ return Math.max(1, Math.ceil(config.capacity / config.refillPerSec) + 1);
+}
+
/**
* Storage interface — swap for Redis/Memcached in clustered deploys.
* Implementations MUST be safe under concurrent access.
@@ -105,41 +206,9 @@ export class RateLimiter {
*/
consume(key: string, cost = this.config.defaultCost ?? 1): RateLimitDecision {
const now = this.now();
- const { capacity, refillPerSec } = this.config;
-
- let state = this.store.get(key);
- if (!state) {
- state = { tokens: capacity, lastRefill: now };
- } else {
- const elapsedSec = (now - state.lastRefill) / 1000;
- if (elapsedSec > 0) {
- state = {
- tokens: Math.min(capacity, state.tokens + elapsedSec * refillPerSec),
- lastRefill: now,
- };
- }
- }
-
- if (state.tokens >= cost) {
- state.tokens -= cost;
- this.store.set(key, state);
- return {
- allowed: true,
- remaining: Math.floor(state.tokens),
- retryAfterMs: 0,
- resetAt: now + Math.ceil(((capacity - state.tokens) / refillPerSec) * 1000),
- };
- }
-
- const tokensNeeded = cost - state.tokens;
- const retryAfterMs = Math.ceil((tokensNeeded / refillPerSec) * 1000);
+ const { state, decision } = applyTokenBucket(this.store.get(key), this.config, now, cost);
this.store.set(key, state);
- return {
- allowed: false,
- remaining: Math.floor(state.tokens),
- retryAfterMs,
- resetAt: now + retryAfterMs,
- };
+ return decision;
}
/** Force-reset a key (e.g. after a successful auth flow). */
@@ -149,19 +218,22 @@ export class RateLimiter {
}
/**
- * Curated default buckets for the three traffic classes ObjectStack
- * dispatches. Conservative — tune via `DispatcherPluginConfig.rateLimit`
- * for your deployment.
+ * Reference budgets for three traffic classes, as ORDERS OF MAGNITUDE to size
+ * an authored `server.security.rateLimit` against — not a live configuration.
*
- * - auth: 10 req / minute / IP — guards /auth/* against credential
- * stuffing and password-spray.
- * - write: 60 req / minute / IP — POST/PUT/PATCH/DELETE.
- * - read: 600 req / minute / IP — GET, including discovery and
- * metadata.
+ * - auth: 10 req / minute — the scale that inconveniences credential-stuffing
+ * and password-spray without troubling a human logging in.
+ * - write: 60 req / minute — POST/PUT/PATCH/DELETE.
+ * - read: 600 req / minute — GET, including discovery and metadata.
*
- * "Per-IP" is just the suggested key shape; the dispatcher constructs
- * the key from `${ip}:${bucket}` so a single noisy IP can saturate
- * one bucket without blocking the others.
+ * ⚠️ **Nothing in the repo reads this constant.** It is exported reference
+ * material, and this comment says so in the tense that matches (#4937: the
+ * previous version described per-class dispatcher buckets keyed `${ip}:${bucket}`
+ * as if they existed — they never did, and the seam #4910 built is a SINGLE
+ * bucket per caller, not one per traffic class). Per-class budgets are a real
+ * and reasonable want; when one is implemented it should arrive with its
+ * executor and its authoring key, at which point this constant either becomes
+ * that feature's default or is deleted.
*/
export interface RateLimitDefaults {
auth: RateLimitBucketConfig;
diff --git a/packages/runtime/src/security/resolve-session-principal.ts b/packages/runtime/src/security/resolve-session-principal.ts
new file mode 100644
index 0000000000..f3c58e8166
--- /dev/null
+++ b/packages/runtime/src/security/resolve-session-principal.ts
@@ -0,0 +1,67 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * "Who is calling?", asked of the auth service from OUTSIDE a dispatch.
+ *
+ * Two consumers need this and they run in different kernel phases, which is why
+ * it is a module rather than a closure: `dispatcher-plugin`'s concrete route
+ * mounts build a slim `req.user` from it in `start()`, and the inbound rate
+ * limiter (#4910) needs only the principal id, in `init()`, to key a bucket
+ * before any route runs. One lookup, one set of defensive rules, so the two
+ * cannot drift on what counts as "authenticated".
+ *
+ * Every failure mode here resolves to `undefined` — meaning ANONYMOUS, not
+ * "error". Both callers treat anonymous as a legitimate state (the route's own
+ * auth gate still runs; the limiter keys by IP instead), so throwing would turn
+ * an auth hiccup into an outage on surfaces that never needed auth to begin
+ * with.
+ */
+
+import type { IAuthService } from '@objectstack/spec/contracts';
+
+/** Headers as adapters deliver them, or an already-built `Headers`. */
+export type HeaderBag = Record | Headers;
+
+function toHeaders(headers: HeaderBag): Headers {
+ if (headers instanceof Headers) return headers;
+ const out = new Headers();
+ for (const key of Object.keys(headers)) {
+ const value = (headers as Record)[key];
+ if (value == null) continue;
+ out.set(String(key), Array.isArray(value) ? value.join(',') : String(value));
+ }
+ return out;
+}
+
+/**
+ * The better-auth session payload for a request, or `undefined` when there is
+ * no auth service, no session API, or no session.
+ */
+export async function resolveSessionData(
+ authService: IAuthService | undefined,
+ headers: HeaderBag,
+): Promise {
+ try {
+ if (!authService) return undefined;
+ let api: any = (authService as any).api;
+ if (!api && typeof (authService as any).getApi === 'function') {
+ api = await (authService as any).getApi();
+ }
+ if (!api?.getSession) return undefined;
+ return await api.getSession({ headers: toHeaders(headers) });
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * The calling principal's user id, or `undefined` for anonymous traffic.
+ */
+export async function resolveSessionPrincipalId(
+ authService: IAuthService | undefined,
+ headers: HeaderBag,
+): Promise {
+ const sessionData = await resolveSessionData(authService, headers);
+ const userId: unknown = sessionData?.user?.id ?? sessionData?.session?.userId;
+ return typeof userId === 'string' && userId.length > 0 ? userId : undefined;
+}
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 3217d7fab0..f7384b5ec8 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -1208,6 +1208,8 @@
"ServerEvent (type)",
"ServerEventSchema (const)",
"ServerEventType (type)",
+ "ServerRateLimitConfig (type)",
+ "ServerRateLimitConfigSchema (const)",
"ServerStatus (type)",
"ServerStatusSchema (const)",
"ServiceConfigSchema (const)",
@@ -1254,6 +1256,11 @@
"SpecifierScope (type)",
"SpecifierScopeSchema (const)",
"SpecifierType (type)",
+ "StackServerConfig (type)",
+ "StackServerConfigInput (type)",
+ "StackServerConfigSchema (const)",
+ "StackServerSecurity (type)",
+ "StackServerSecuritySchema (const)",
"StorageAcl (type)",
"StorageAclSchema (const)",
"StorageClass (type)",
diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json
index 902015d532..d88c4db1dd 100644
--- a/packages/spec/authorable-surface.json
+++ b/packages/spec/authorable-surface.json
@@ -6547,6 +6547,9 @@
"system/ServerEvent:data",
"system/ServerEvent:timestamp",
"system/ServerEvent:type",
+ "system/ServerRateLimitConfig:enabled",
+ "system/ServerRateLimitConfig:maxRequests",
+ "system/ServerRateLimitConfig:windowMs",
"system/ServerStatus:connections",
"system/ServerStatus:requests",
"system/ServerStatus:server",
@@ -6654,6 +6657,9 @@
"system/SpecifierOption:icon",
"system/SpecifierOption:label",
"system/SpecifierOption:value",
+ "system/StackServerConfig:security",
+ "system/StackServerConfig:trustProxy",
+ "system/StackServerSecurity:rateLimit",
"system/StorageConnection:accessKeyId",
"system/StorageConnection:accountKey",
"system/StorageConnection:accountName",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 88c49721ab..43cd34e443 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -1388,6 +1388,7 @@
"system/ServerCapabilities",
"system/ServerEvent",
"system/ServerEventType",
+ "system/ServerRateLimitConfig",
"system/ServerStatus",
"system/ServiceConfig",
"system/ServiceCriticality",
@@ -1412,6 +1413,8 @@
"system/SpecifierOption",
"system/SpecifierScope",
"system/SpecifierType",
+ "system/StackServerConfig",
+ "system/StackServerSecurity",
"system/StorageAcl",
"system/StorageClass",
"system/StorageConnection",
diff --git a/packages/spec/src/contracts/http-server.ts b/packages/spec/src/contracts/http-server.ts
index 35b4ab1a2b..9985ae221c 100644
--- a/packages/spec/src/contracts/http-server.ts
+++ b/packages/spec/src/contracts/http-server.ts
@@ -35,6 +35,22 @@ export interface IHttpRequest {
* undefined when the underlying framework cannot expose the raw stream.
*/
rawBody?: () => Promise;
+
+ /**
+ * The TRANSPORT's own peer address for this request — the socket's remote
+ * address, never a header.
+ *
+ * CONTRACT (#4910): this member is the unforgeable half of caller
+ * identification. `X-Forwarded-For` and friends live in {@link headers} and
+ * are believable only when the deployment declares `server.trustProxy`;
+ * this one a client cannot influence, which is why the inbound rate limiter
+ * keys anonymous traffic off it by default.
+ *
+ * Optional because not every runtime exposes it (an edge/Workers host may
+ * have no socket at all). Consumers MUST degrade deliberately when it is
+ * absent — never substitute a header for it silently, and never fall open.
+ */
+ remoteAddress?: string;
}
/**
@@ -95,7 +111,25 @@ export type RouteHandler = (
) => void | Promise;
/**
- * Middleware function
+ * Middleware function.
+ *
+ * ## Contract (#4910)
+ *
+ * A middleware either **continues** the chain by calling `next()`, or
+ * **short-circuits** it by writing a response (`res.status(…).json(…)` /
+ * `.send(…)`) and NOT calling `next()`. Doing neither is pass-through, so a
+ * forgotten branch cannot black-hole a request. Implementations MUST honour the
+ * short-circuit: the whole point of the seam is that a gate — rate limiting,
+ * maintenance mode — can answer instead of the route.
+ *
+ * Two limits every implementation shares, stated here so consumers do not
+ * discover them per adapter:
+ *
+ * - `req.body` is NOT populated. Parsing it here would consume the stream
+ * before the route handler that owns it.
+ * - Middleware must be registered BEFORE the routes it should guard. Register
+ * in a plugin's `init()` (kernel Phase 1); every route is mounted in some
+ * plugin's `start()` (Phase 2), so this ordering is automatic.
*/
export type Middleware = (
req: IHttpRequest,
diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts
index 9f9270631a..a275829747 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -7,6 +7,7 @@ import { validateObjectNamespacePrefix } from './kernel/namespace-prefix';
import { PLATFORM_CAPABILITY_TOKENS } from './kernel/platform-capabilities';
import { DatasourceSchema } from './data/datasource.zod';
import { TranslationBundleSchema, TranslationConfigSchema } from './system/translation.zod';
+import { StackServerConfigSchema } from './system/stack-server.zod';
import { hasPlatformObjectPrefix } from './system/constants/platform-object-names';
import { objectStackErrorMap, formatZodError } from './shared/error-map.zod';
import { normalizeStackInput, type MetadataCollectionInput, type MapSupportedField } from './shared/metadata-collection.zod';
@@ -294,6 +295,21 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({
enforceProjectMembership: z.boolean().optional(),
}).optional().describe('Server-facing API config consumed by objectstack serve/dev'),
+ /**
+ * Server-level runtime configuration read by `objectstack serve` / `dev`.
+ *
+ * DELIBERATELY NARROW (#4910): it carries only keys an executor consumes —
+ * today `security.rateLimit` (the inbound token bucket that answers 429) and
+ * `trustProxy` (how that limiter identifies a caller). It is NOT the nine-key
+ * `HttpServerConfigSchema`: seven of those keys have no reader and no
+ * authoring surface, and mounting them here would make dead keys writable
+ * (their enforce-or-remove fate is #4938). Port/host stay a deployment
+ * concern owned by `objectstack serve -p`; see the schema file for the
+ * precedence rule and the rest of the rationale.
+ */
+ server: StackServerConfigSchema.optional()
+ .describe('Server-level runtime config consumed by objectstack serve/dev (inbound rate limit, proxy trust)'),
+
/**
* ObjectAI: Artificial Intelligence Layer
*
diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts
index c0ac38762e..8c4dd42c30 100644
--- a/packages/spec/src/system/index.ts
+++ b/packages/spec/src/system/index.ts
@@ -17,6 +17,7 @@ export * from './message-queue.zod';
export * from './object-storage.zod';
export * from './search-engine.zod';
export * from './http-server.zod';
+export * from './stack-server.zod';
// Observability & Operations
// audit.zod (AuditConfig/AuditStorageConfig/AuditRetentionPolicy/AuditEventFilter/
diff --git a/packages/spec/src/system/stack-server.test.ts b/packages/spec/src/system/stack-server.test.ts
new file mode 100644
index 0000000000..86f78c5105
--- /dev/null
+++ b/packages/spec/src/system/stack-server.test.ts
@@ -0,0 +1,143 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `server:` — the authorable half of the inbound rate-limit seam (#4910).
+ *
+ * These assertions are about the AUTHORING contract only: what a stack may
+ * write, what it may not, and what the parse hands the runtime. That the
+ * declaration then produces a 429 is proven where it happens —
+ * `packages/runtime/src/dispatcher-plugin.rate-limit.integration.test.ts`
+ * boots a real server and drives it over a socket. Two halves, deliberately:
+ * this issue exists because a schema and an executor were each tested alone
+ * and nobody tested that they were connected.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { defineStack } from '../stack.zod';
+import {
+ StackServerConfigSchema,
+ ServerRateLimitConfigSchema,
+} from './stack-server.zod';
+import { ObjectStackDefinitionSchema } from '../stack.zod';
+
+const manifest = {
+ id: 'com.example.rate-limit',
+ name: 'Rate limit fixture',
+ version: '1.0.0',
+ type: 'app' as const,
+};
+
+describe('server: reaches the runtime through defineStack', () => {
+ it('survives the strict stack parse instead of being stripped', () => {
+ const stack = defineStack({
+ manifest,
+ server: {
+ security: { rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 5 } },
+ trustProxy: true,
+ },
+ } as never);
+
+ // The whole point of declaring the key: before #4910 `server:` was an
+ // undeclared key, so `defineStack` dropped it silently and the CLI had
+ // nothing to read.
+ expect((stack as Record).server).toEqual({
+ security: { rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 5 } },
+ trustProxy: true,
+ });
+ });
+
+ it('is optional — a stack that declares nothing gets nothing', () => {
+ const stack = defineStack({ manifest } as never);
+ expect((stack as Record).server).toBeUndefined();
+ });
+
+ it('is declared on the stack schema itself (so the unknown-key lint stays quiet)', () => {
+ expect(Object.keys((ObjectStackDefinitionSchema as never as { shape: object }).shape))
+ .toContain('server');
+ });
+});
+
+describe('server: carries only keys with a consumer (#4938 stays shut)', () => {
+ it('declares exactly `security` and `trustProxy`', () => {
+ // The hard constraint from the 2026-08-03 adjudication. If this list grows,
+ // the new key must have arrived with an executor — the whole reason the
+ // nine-key HttpServerConfigSchema was NOT mounted here.
+ expect(Object.keys((StackServerConfigSchema as never as { shape: object }).shape).sort())
+ .toEqual(['security', 'trustProxy']);
+ });
+
+ it.each([
+ ['port', /objectstack serve -p/],
+ ['host', /belongs to the deployment/],
+ ['compression', /#4938/],
+ ['requestTimeout', /#4938/],
+ ['bodyLimit', /#4938/],
+ ['static', /transport plugin/],
+ ['cors', /OS_CORS_ORIGIN/],
+ ])('rejects the unconsumed HttpServerConfig key `%s` with a prescription', (key, expected) => {
+ const result = StackServerConfigSchema.safeParse({ [key]: 1 });
+ expect(result.success).toBe(false);
+ const message = result.error!.issues.map((i) => i.message).join('\n');
+ expect(message).toMatch(/Unrecognized key/);
+ expect(message).toMatch(expected);
+ });
+});
+
+describe('server.security.rateLimit is strict from birth (#4001)', () => {
+ it('accepts every declared key', () => {
+ expect(ServerRateLimitConfigSchema.parse({ enabled: true, windowMs: 1000, maxRequests: 3 }))
+ .toEqual({ enabled: true, windowMs: 1000, maxRequests: 3 });
+ });
+
+ it('applies the shared defaults when only `enabled` is written', () => {
+ expect(ServerRateLimitConfigSchema.parse({ enabled: true }))
+ .toEqual({ enabled: true, windowMs: 60_000, maxRequests: 100 });
+ });
+
+ it('rejects a near-miss and names the key it meant', () => {
+ const result = ServerRateLimitConfigSchema.safeParse({ enabled: true, max: 5 });
+ expect(result.success).toBe(false);
+ expect(result.error!.issues[0]!.message).toMatch(/`max` → `maxRequests`/);
+ });
+
+ it('rejects a budget that can never admit a request', () => {
+ // `.int()` alone accepts 0 — and a zero-capacity bucket denies everything,
+ // including health checks. Caught at AUTHORING, where the fix is one edit
+ // away, rather than at boot or (worse) in production traffic.
+ const result = ServerRateLimitConfigSchema.safeParse({ enabled: true, maxRequests: 0 });
+ expect(result.success).toBe(false);
+ expect(result.error!.issues[0]!.path).toEqual(['maxRequests']);
+ expect(result.error!.issues[0]!.message).toMatch(/set `enabled: false`/);
+ });
+
+ it('rejects a zero window', () => {
+ const result = ServerRateLimitConfigSchema.safeParse({ enabled: true, windowMs: 0 });
+ expect(result.success).toBe(false);
+ expect(result.error!.issues[0]!.path).toEqual(['windowMs']);
+ expect(result.error!.issues[0]!.message).toMatch(/MILLISECONDS/);
+ });
+});
+
+describe('server.trustProxy defaults to not believing the caller', () => {
+ it('is false when unwritten', () => {
+ expect(StackServerConfigSchema.parse({})).toEqual({ trustProxy: false });
+ });
+
+ it('describes what declaring it means, so the security choice is reviewable', () => {
+ const description = (StackServerConfigSchema as never as {
+ shape: Record;
+ }).shape.trustProxy.description ?? '';
+ expect(description).toMatch(/X-Forwarded-For/);
+ expect(description).toMatch(/reverse proxy you control/);
+ });
+
+ it('documents the key shape on the rateLimit describe (Q3, reviewable in the schema)', () => {
+ const description = (StackServerConfigSchema as never as {
+ shape: Record } }>;
+ }).shape.security.unwrap().shape.rateLimit.description ?? '';
+ expect(description).toMatch(/RESOLVED PRINCIPAL/);
+ expect(description).toMatch(/falling back to the caller IP/);
+ expect(description).toMatch(/Retry-After/);
+ });
+});
diff --git a/packages/spec/src/system/stack-server.zod.ts b/packages/spec/src/system/stack-server.zod.ts
new file mode 100644
index 0000000000..73942dc166
--- /dev/null
+++ b/packages/spec/src/system/stack-server.zod.ts
@@ -0,0 +1,204 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `defineStack({ server })` — the authorable server-facing configuration.
+ *
+ * ## Why this is NOT `HttpServerConfigSchema`
+ *
+ * `system/http-server.zod.ts` declares nine keys (`port`, `host`, `cors`,
+ * `requestTimeout`, `bodyLimit`, `compression`, `security`, `static`,
+ * `trustProxy`). #4938 measured them: **none had a runtime reader and none was
+ * reachable from any authoring surface** — `stack.zod.ts` had no `server:` key,
+ * so the whole shape was unwritable as well as unread. Mounting it wholesale
+ * here would have made eight dead keys authorable in one move, which is the
+ * declared-≠-enforced defect (Prime Directive #10) manufactured on purpose.
+ *
+ * So this schema is deliberately NARROW: it carries only keys an executor
+ * actually consumes, and it grows one key at a time, each arriving with its
+ * consumer. Today that is exactly two:
+ *
+ * | key | consumed by |
+ * |---|---|
+ * | `security.rateLimit` | `createDispatcherPlugin` → the inbound token bucket (`@objectstack/runtime` `security/inbound-rate-limit.ts`) — an over-budget caller gets `429` + `Retry-After` |
+ * | `trustProxy` | the same limiter's IP resolution — see below |
+ *
+ * The other seven `HttpServerConfigSchema` keys stay unreachable, and their
+ * enforce-or-remove fate is tracked by #4938. Adding one here without an
+ * executor re-opens the hole this narrowness exists to close.
+ *
+ * ## What `server:` is NOT for
+ *
+ * **Deployment knobs stay on the CLI.** There is no `server.port` / `server.host`
+ * on purpose: the listening socket is a property of *where* a stack runs, not of
+ * the stack itself, and it is already owned by `objectstack serve -p ` /
+ * `PORT`. Two authorities for one number is how a config becomes advisory. If a
+ * future need does add `server.port`, the precedence is settled in advance and
+ * recorded here so it cannot be re-litigated per-caller: **the CLI flag wins over
+ * `server:`, and `server:` wins over the built-in default** — an operator
+ * overriding a port at the command line must not be silently overruled by a file
+ * baked into the artifact.
+ *
+ * Related: #4910 (this seam), #4937 (the limiter that documented an execution
+ * chain it never had), #4936 (`apis:` endpoint-level `rateLimit`, still
+ * unwired), ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove).
+ */
+
+import { z } from 'zod';
+
+import { lazySchema } from '../shared/lazy-schema';
+import { strictObject } from '../shared/strict-object';
+import { RateLimitConfigSchema } from '../shared/http.zod';
+
+/**
+ * `server.security.rateLimit` — the shared {@link RateLimitConfigSchema} shape,
+ * closed against unknown keys for this authoring surface.
+ *
+ * The SHAPE is reused verbatim (`RateLimitConfigSchema.shape`) rather than
+ * retyped, so there is no fourth rate-limit shape in the repo and no drift to
+ * police — #4686 opened on there already being three. What is added is
+ * strictness: this key is new, so it joins the #4001 ratchet at birth instead of
+ * being tightened later, and a misspelled budget (`maxRequest`, `window`) is
+ * rejected at parse rather than silently defaulted to 100 req/min.
+ */
+export const ServerRateLimitConfigSchema = lazySchema(() => strictObject(
+ {
+ surface: 'server.security.rateLimit',
+ history:
+ 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.',
+ aliases: {
+ window: 'windowMs',
+ windowSeconds: 'windowMs',
+ max: 'maxRequests',
+ maxRequest: 'maxRequests',
+ limit: 'maxRequests',
+ },
+ guidance: {
+ keyBy:
+ 'The rate-limit key is not authorable. It is the resolved principal, falling back to the caller IP for '
+ + 'anonymous traffic; whether the IP is read from forwarded headers is decided by `server.trustProxy`.',
+ store:
+ 'The counter store is not authorable. Counters live in the kernel `cache` service when one is registered '
+ + '(ADR-0069 D2) and degrade to a per-process store otherwise, announced once at boot.',
+ },
+ },
+ RateLimitConfigSchema.shape,
+).superRefine((value, ctx) => {
+ // The shared shape declares `.int()` but no lower bound, so `0` and negatives
+ // parse. They are not budgets: `maxRequests: 0` rejects every request
+ // including your own health checks, and either zero makes the derived refill
+ // rate undefined (`maxRequests / (windowMs / 1000)`).
+ //
+ // Rejecting HERE rather than at boot is the point (#4910 axis 2): an AI-
+ // authored stack finds out at `defineStack`, in the file it is writing, with
+ // the fix in the message — not from a 429 storm, and not from a boot log
+ // nobody reads. The runtime derivation keeps its own guard for callers that
+ // build a budget programmatically; this is the one authors hit.
+ if (value.maxRequests <= 0) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['maxRequests'],
+ message:
+ `maxRequests must be greater than 0 (got ${value.maxRequests}). A zero budget rejects every request; `
+ + 'to turn rate limiting off set `enabled: false`.',
+ });
+ }
+ if (value.windowMs <= 0) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['windowMs'],
+ message:
+ `windowMs must be greater than 0 (got ${value.windowMs}). It is the budget window in MILLISECONDS — `
+ + '60000 is one minute.',
+ });
+ }
+}));
+
+export type ServerRateLimitConfig = z.infer;
+
+/**
+ * `server.security` — security configuration consumed by the inbound seam.
+ */
+export const StackServerSecuritySchema = lazySchema(() => strictObject(
+ {
+ surface: 'server.security',
+ history:
+ 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.',
+ guidance: {
+ helmet:
+ 'Not authorable here. Response hardening headers are configured on the dispatcher plugin '
+ + '(`securityHeaders`), which is on by default — see `buildSecurityHeaders` in @objectstack/runtime.',
+ cors:
+ 'Not authorable here. CORS is owned by the transport adapter and configured by '
+ + 'OS_CORS_ORIGIN / OS_CORS_CREDENTIALS / OS_CORS_MAX_AGE.',
+ },
+ },
+ {
+ /**
+ * Global inbound rate limit. `enabled: false` (the default) leaves every
+ * request unmetered; `enabled: true` arms a token bucket in front of every
+ * route this server mounts.
+ */
+ rateLimit: ServerRateLimitConfigSchema.optional().describe(
+ 'Global inbound rate limit. When `enabled`, every inbound request consumes from a token bucket derived from '
+ + 'this budget (capacity = `maxRequests`, refill = `maxRequests / (windowMs / 1000)` tokens per second); an '
+ + 'empty bucket answers 429 with a `Retry-After` header. The bucket is keyed by the RESOLVED PRINCIPAL, '
+ + 'falling back to the caller IP for anonymous traffic — so one abusive session cannot exhaust another '
+ + "user's budget, and credential-stuffing traffic (which has no principal yet) is still metered per source. "
+ + 'See `server.trustProxy` for how that IP is determined.',
+ ),
+ },
+));
+
+export type StackServerSecurity = z.infer;
+
+/**
+ * The `server:` block of a stack definition.
+ */
+export const StackServerConfigSchema = lazySchema(() => strictObject(
+ {
+ surface: 'the stack `server` block',
+ history:
+ 'This key is new in v17 (#4910) and strict from birth — an unknown key here was never accepted.',
+ guidance: {
+ port:
+ 'Not authorable. The listening port belongs to the deployment, not the stack — pass '
+ + '`objectstack serve -p ` or set PORT.',
+ host:
+ 'Not authorable. The bind address belongs to the deployment, not the stack — pass it to '
+ + '`objectstack serve`.',
+ cors:
+ 'Not authorable here. CORS is owned by the transport adapter and configured by '
+ + 'OS_CORS_ORIGIN / OS_CORS_CREDENTIALS / OS_CORS_MAX_AGE.',
+ compression: 'Not authorable — no runtime reads it (#4938).',
+ requestTimeout: 'Not authorable — no runtime reads it (#4938).',
+ bodyLimit: 'Not authorable — no runtime reads it (#4938).',
+ static: 'Not authorable. Static mounts are configured on the transport plugin (`staticMounts`).',
+ },
+ },
+ {
+ security: StackServerSecuritySchema.optional().describe(
+ 'Server-level security configuration. Today: the global inbound rate limit.',
+ ),
+
+ /**
+ * Whether an `X-Forwarded-For` / `X-Real-IP` header may be believed.
+ *
+ * This is a SECURITY declaration, not a convenience toggle. Anything a
+ * client can send, a client can forge: an attacker who can pick their own
+ * `X-Forwarded-For` gets an unlimited supply of fresh rate-limit buckets
+ * (limit bypassed) AND can spend another caller's budget by claiming their
+ * address (limit weaponised). Trusting the header therefore has to be an act
+ * of authorship — the operator asserting "a proxy I control rewrites this
+ * header on every request" — and never a default.
+ */
+ trustProxy: z.boolean().default(false).describe(
+ 'Believe `X-Forwarded-For` / `X-Real-IP` when identifying a caller. Declare `true` ONLY when a reverse proxy '
+ + 'you control overwrites those headers on every inbound request. Left `false` (the default) the caller IP is '
+ + "the transport's own peer address, which a client cannot forge. Consumed by the inbound rate limiter when "
+ + '`server.security.rateLimit.enabled` is set.',
+ ),
+ },
+));
+
+export type StackServerConfig = z.infer;
+export type StackServerConfigInput = z.input;