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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .changeset/inbound-rate-limit-seam.md
Original file line number Diff line number Diff line change
@@ -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.
107 changes: 80 additions & 27 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -958,39 +958,89 @@ ETag: "abc123def456"

## Rate Limiting

<Callout type="info">
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.
</Callout>
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.

<Callout type="warn">
**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).
</Callout>

## Best Practices

Expand Down Expand Up @@ -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.
}
```

Expand Down
1 change: 1 addition & 0 deletions content/docs/references/system/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ This section contains all protocol schemas for the system layer of ObjectStack.
<Card href="/docs/references/system/security-context" title="Security Context" description="Source: packages/spec/src/system/security-context.zod.ts" />
<Card href="/docs/references/system/settings-client" title="Settings Client" description="Source: packages/spec/src/system/settings-client.zod.ts" />
<Card href="/docs/references/system/settings-manifest" title="Settings Manifest" description="Source: packages/spec/src/system/settings-manifest.zod.ts" />
<Card href="/docs/references/system/stack-server" title="Stack Server" description="Source: packages/spec/src/system/stack-server.zod.ts" />
<Card href="/docs/references/system/supplier-security" title="Supplier Security" description="Source: packages/spec/src/system/supplier-security.zod.ts" />
<Card href="/docs/references/system/tenant" title="Tenant" description="Source: packages/spec/src/system/tenant.zod.ts" />
<Card href="/docs/references/system/tracing" title="Tracing" description="Source: packages/spec/src/system/tracing.zod.ts" />
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/system/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"doc",
"---More---",
"metadata-types",
"retry-policy"
"retry-policy",
"stack-server"
]
}
123 changes: 123 additions & 0 deletions content/docs/references/system/stack-server.mdx
Original file line number Diff line number Diff line change
@@ -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>` /

`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).

<Callout type="info">
**Source:** `packages/spec/src/system/stack-server.zod.ts`
</Callout>

## 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. |


---

Loading
Loading