Skip to content

Commit 55dbbba

Browse files
feat(spec,runtime,hono)!: 入站 rateLimit 从零建 seam —— 授权预算真正产生 429 (#4910, #4937) (#5006)
* feat(spec,runtime,hono): wire an authored rate-limit budget to a real 429 (#4910, #4937) `packages/spec` declared three `RateLimitConfig` embeddings with zero readers repo-wide, and `runtime/security/rate-limit.ts` held a token bucket with zero call sites whose comments described, in the present tense, an execution chain that did not exist. Neither half was broken; they were never connected, and both were documented as if they were. This builds the seam the 2026-08-03 adjudication specified: - new NARROW `server:` stack key (`security.rateLimit` + `trustProxy` only — the other seven `HttpServerConfigSchema` keys stay unreachable, #4938), strict from birth, rejecting an unusable budget at `defineStack`; - `createDispatcherPlugin({ rateLimit })` builds the limiter and installs it as global middleware in `init()`, so it gates every route the server mounts; - keyed by resolved principal, falling back to caller IP; forwarded headers honoured only under an explicit `trustProxy`; - counters in the kernel cache (ADR-0069 D2) via plugin-auth's lazy resolution, with an announced per-process fallback naming the consequence; - `IHttpServer.use()` made a real middleware seam — the Hono adapter passed `{}` for req/res and always called `next()`, so no middleware could ever act. Endpoint-level `rateLimit` stays knowingly unwired (#4936), registered as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * refactor(hono,runtime): place the middleware seam up front so the limiter can install in start() (#4910) Registering the rate-limit middleware in the dispatcher's `init()` meant reading the `http.server` registry while Phase 1 was still filling, drawing a terminal "no transport" conclusion from it, and recording that conclusion in a warn — the exact three-part shape `check:startup-registry-verdict` exists to stop (#4771). Cured structurally rather than tolerated. `HonoHttpServer` now mounts its chain runner via `installMiddlewareSeam()`, which `HonoServerPlugin.init()` calls at the very end — after CORS/Server-Timing (so a 429 still carries CORS headers) and before any route exists. `use()` appends to a chain that runner reads per request, so registration order stops deciding coverage, and the dispatcher can install the limiter in `start()` where "no http.server" is a settled fact. Covered both ways: a route mounted by an earlier plugin's start() is still gated (integration), and the adapter pins the negative case too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 * chore(spec): regenerate artifacts after merging origin/main (os-regen four-step) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7055c22 commit 55dbbba

28 files changed

Lines changed: 2523 additions & 112 deletions
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/plugin-hono-server": minor
5+
"@objectstack/plugin-auth": patch
6+
"@objectstack/cli": patch
7+
---
8+
9+
feat(spec,runtime,hono): `server.security.rateLimit` — an authored budget that actually returns 429 (#4910, #4937)
10+
11+
Rate limiting in ObjectStack was three shapes with nothing between them. `packages/spec`
12+
declared `RateLimitConfig` in three places and the whole repo had **zero readers** for any
13+
of them, so an author wrote a budget, it parsed, and nothing happened (#4686).
14+
`@objectstack/runtime` shipped a token bucket whose comments claimed, in the present tense,
15+
that the dispatcher called it and short-circuited with 429 — it had **zero call sites**
16+
outside its own unit test, and the `DispatcherPluginConfig.rateLimit` field it told you to
17+
tune did not exist (#4937). Neither half was broken; they were simply never connected, and
18+
both were documented as if they were.
19+
20+
They are connected now, along one narrow path.
21+
22+
## What you write
23+
24+
```ts
25+
export default defineStack({
26+
manifest: { /**/ },
27+
server: {
28+
security: {
29+
rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 600 },
30+
},
31+
trustProxy: false,
32+
},
33+
});
34+
```
35+
36+
`server:` is a **new** top-level stack key. Nothing declared it before, so no existing
37+
stack changes behaviour on upgrade — there is no configuration that was inert yesterday
38+
and starts throttling today.
39+
40+
It is deliberately **narrow**: it carries `security.rateLimit` and `trustProxy` and
41+
nothing else, because those are the two keys with a consumer. It is NOT the nine-key
42+
`HttpServerConfigSchema` — the other seven have no reader and no authoring surface, and
43+
mounting them here would have made seven dead keys writable in one move (their
44+
enforce-or-remove fate stays with #4938). It is strict from birth (#4001), so a misspelled
45+
budget is rejected with the correction rather than silently defaulted, and `maxRequests: 0`
46+
is refused at `defineStack` rather than at 3am.
47+
48+
**No `server.port`.** The listening socket belongs to the deployment, not the artifact, and
49+
`objectstack serve -p` already owns it. The precedence rule is recorded in the schema and
50+
the docs in advance, so it cannot be re-litigated per caller: **CLI flag > `server:` >
51+
built-in default.**
52+
53+
## What happens
54+
55+
Every inbound request the server routes — REST, dispatcher, service routes, anything
56+
mounted on that transport — consumes from a token bucket sized `capacity = maxRequests`,
57+
refilling at `maxRequests / (windowMs / 1000)` per second. An empty bucket answers **429**
58+
with a `Retry-After` computed from the bucket itself and the standard error envelope
59+
(`code: "RATE_LIMIT_EXCEEDED"`). `OPTIONS` preflights are never metered.
60+
61+
The bucket is keyed by **resolved principal**, falling back to the caller's **IP** for
62+
anonymous traffic — so one abusive session cannot spend another user's budget, and
63+
credential-stuffing traffic (which has no principal yet) is still metered per source. That
64+
IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `trustProxy: true` is declared**;
65+
otherwise it is the transport's own peer address. Undeclared, those headers are attacker
66+
input: honouring them by default would hand anyone an unlimited supply of fresh buckets and
67+
let them drain a chosen victim's.
68+
69+
Counters live in the kernel `cache` service when one is registered, so a multi-node
70+
deployment enforces one budget instead of one per node (ADR-0069 D2), resolved lazily at
71+
consume time so a cache plugin that registers later is still picked up (#4772). With no
72+
cache service at all it falls back to a per-process store and says so once, naming the
73+
consequence: the effective limit becomes the declared budget multiplied by the number of
74+
nodes, and nothing about the deployment looks wrong.
75+
76+
## Also in this change
77+
78+
- **`IHttpServer.use()` is a real middleware seam.** The Hono adapter's implementation
79+
passed `{}` for both `req` and `res` and called `next()` unconditionally, so a registered
80+
middleware could not read the request, write a response, or decline to continue — a
81+
declared seam with no execution behind it, unnoticed because nothing called it. It now
82+
delivers method/path/query/headers plus the transport peer address
83+
(`IHttpRequest.remoteAddress`, new), and honours a short-circuit. Middleware must be
84+
registered before the routes it guards; the kernel's two-phase boot makes that automatic
85+
(`init()` before every `start()`).
86+
- **`packages/runtime/src/security/rate-limit.ts` no longer describes an execution chain it
87+
does not have** (#4937). The token-bucket arithmetic is extracted so the synchronous
88+
in-process limiter and the new shared-store one cannot drift, and `DEFAULT_RATE_LIMITS` is
89+
now labelled as the reference material it always was rather than as live defaults.
90+
91+
## Explicitly NOT wired
92+
93+
`ApiEndpointSchema.rateLimit` and `ApiEndpointRegistrationSchema.rateLimit` remain
94+
**known-unwired**. Declaring them still changes nothing. They are not retired here either:
95+
the fate of the whole declarative `apis:` surface is undecided (#4936), and retiring one
96+
key of a surface that may yet be implemented would only have to be undone. Tracked, not
97+
silent.

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -958,39 +958,89 @@ ETag: "abc123def456"
958958

959959
## Rate Limiting
960960

961-
<Callout type="info">
962-
ObjectStack ships a token-bucket `RateLimiter` primitive
963-
(`@objectstack/runtime`), but emission of the `X-RateLimit-*` response headers
964-
and the `429` envelope below is deployment-specific and not wired into the
965-
default REST response path. Treat the headers and response shape here as the
966-
intended contract.
967-
</Callout>
961+
Inbound rate limiting is **off unless a stack declares it**, and it is declared in
962+
one place — the stack's `server` block:
963+
964+
```ts
965+
import { defineStack } from '@objectstack/spec';
966+
967+
export default defineStack({
968+
manifest: { /**/ },
969+
server: {
970+
security: {
971+
rateLimit: {
972+
enabled: true,
973+
windowMs: 60_000, // budget window, in MILLISECONDS
974+
maxRequests: 600, // requests permitted per window, per caller
975+
},
976+
},
977+
// Believe `X-Forwarded-For` / `X-Real-IP`? Only behind a proxy you control.
978+
trustProxy: false,
979+
},
980+
});
981+
```
968982

969-
When enabled, responses include rate limit headers:
983+
`objectstack serve` / `dev` forward that block to the dispatcher plugin, which arms a
984+
token bucket in front of **every route the server mounts** — not just the dispatcher's
985+
own. `capacity` is `maxRequests` (so a full bucket absorbs one window's worth of
986+
traffic as a burst) and it refills at `maxRequests / (windowMs / 1000)` tokens per
987+
second (so the sustained rate is exactly the declared one).
970988

971-
```http
972-
HTTP/1.1 200 OK
973-
X-RateLimit-Limit: 1000
974-
X-RateLimit-Remaining: 847
975-
X-RateLimit-Reset: 1705324800
976-
```
989+
### What the bucket is keyed on
990+
991+
1. **The resolved principal**, when the request carries a valid session. One user
992+
cannot spend another's budget, and users behind a shared NAT do not throttle each
993+
other.
994+
2. **The caller's IP**, for anonymous traffic — the case that most needs a limit
995+
(credential stuffing, scraping) and has no identity yet.
996+
997+
That IP comes from `X-Forwarded-For` / `X-Real-IP` **only when `server.trustProxy` is
998+
declared `true`**. Left at its default, the address is the transport's own peer
999+
address, which a client cannot forge. This is deliberate: an attacker who can choose
1000+
their own `X-Forwarded-For` otherwise gets an unlimited supply of fresh buckets *and*
1001+
can drain a chosen victim's. Declare `trustProxy` only when a reverse proxy you
1002+
control overwrites those headers on every inbound request.
1003+
1004+
CORS preflights (`OPTIONS`) are never metered.
1005+
1006+
### When the limit is exceeded
9771007

978-
**When limit exceeded:**
9791008
```http
9801009
HTTP/1.1 429 Too Many Requests
9811010
Retry-After: 45
982-
X-RateLimit-Limit: 1000
983-
X-RateLimit-Remaining: 0
984-
X-RateLimit-Reset: 1705324800
1011+
Content-Type: application/json
9851012
9861013
{
987-
"error": "Rate limit exceeded",
988-
"code": "THROTTLED",
989-
"retry_after": 45
1014+
"success": false,
1015+
"error": {
1016+
"code": "RATE_LIMIT_EXCEEDED",
1017+
"message": "Rate limit exceeded. Retry after the interval in the Retry-After header.",
1018+
"httpStatus": 429,
1019+
"details": { "retryAfterSeconds": 45, "resetAt": "2026-08-03T12:00:45.000Z" }
1020+
}
9901021
}
9911022
```
9921023

993-
See [Error Handling](/docs/protocol/kernel/error-handling) for more details.
1024+
`Retry-After` is computed from the bucket itself, so the wait it advertises is the
1025+
wait the bucket will actually take to refill. The body is the standard error envelope
1026+
— see [Error Handling](/docs/protocol/kernel/error-handling).
1027+
1028+
### Counting across nodes
1029+
1030+
Counters live in the kernel `cache` service when one is registered, so a multi-node
1031+
deployment enforces **one** budget rather than one per node (ADR-0069 D2). With no
1032+
cache service the limiter falls back to a per-process store and says so once, at
1033+
`warn`, naming the consequence: until a shared cache is registered the effective limit
1034+
is the declared budget multiplied by the number of nodes.
1035+
1036+
<Callout type="warn">
1037+
**Not implemented, deliberately named rather than implied.** ObjectStack does **not**
1038+
emit `X-RateLimit-Limit` / `-Remaining` / `-Reset` headers on successful responses —
1039+
only `Retry-After` on a 429. And the per-endpoint `rateLimit` key on
1040+
`ApiEndpointSchema` / `ApiEndpointRegistrationSchema` is **not wired to anything**;
1041+
declaring it changes nothing today. Its fate travels with the declarative `apis:`
1042+
surface as a whole, tracked by [#4936](https://github.com/objectstack-ai/objectstack/issues/4936).
1043+
</Callout>
9941044

9951045
## Best Practices
9961046

@@ -1021,14 +1071,17 @@ const tasks = await fetch('/api/data/task?expand=assignee');
10211071
```
10221072

10231073
### Respect Rate Limits
1024-
**Good:** Check headers and implement backoff
1074+
**Bad:** Poll `X-RateLimit-Remaining` — that header is not emitted, so the check
1075+
always reads `null` and the backoff never runs.
1076+
1077+
**Good:** Handle the 429 and honour `Retry-After`
10251078
```javascript
10261079
const response = await fetch('/api/data/task');
1027-
const remaining = response.headers.get('X-RateLimit-Remaining');
10281080

1029-
if (remaining < 10) {
1030-
console.warn('Approaching rate limit');
1031-
await sleep(1000);
1081+
if (response.status === 429) {
1082+
const retryAfter = Number(response.headers.get('Retry-After') ?? 1);
1083+
await sleep(retryAfter * 1000);
1084+
// …then retry once; the budget refills continuously, so a single wait is enough.
10321085
}
10331086
```
10341087

content/docs/references/system/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ This section contains all protocol schemas for the system layer of ObjectStack.
3636
<Card href="/docs/references/system/security-context" title="Security Context" description="Source: packages/spec/src/system/security-context.zod.ts" />
3737
<Card href="/docs/references/system/settings-client" title="Settings Client" description="Source: packages/spec/src/system/settings-client.zod.ts" />
3838
<Card href="/docs/references/system/settings-manifest" title="Settings Manifest" description="Source: packages/spec/src/system/settings-manifest.zod.ts" />
39+
<Card href="/docs/references/system/stack-server" title="Stack Server" description="Source: packages/spec/src/system/stack-server.zod.ts" />
3940
<Card href="/docs/references/system/supplier-security" title="Supplier Security" description="Source: packages/spec/src/system/supplier-security.zod.ts" />
4041
<Card href="/docs/references/system/tenant" title="Tenant" description="Source: packages/spec/src/system/tenant.zod.ts" />
4142
<Card href="/docs/references/system/tracing" title="Tracing" description="Source: packages/spec/src/system/tracing.zod.ts" />

content/docs/references/system/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"doc",
4646
"---More---",
4747
"metadata-types",
48-
"retry-policy"
48+
"retry-policy",
49+
"stack-server"
4950
]
5051
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
---
2+
title: Stack Server
3+
description: Stack Server protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
`defineStack(\{ server \})` — the authorable server-facing configuration.
9+
10+
## Why this is NOT `HttpServerConfigSchema`
11+
12+
`[system/http-server.zod.ts](/docs/references/system/http-server)` declares nine keys (`port`, `host`, `cors`,
13+
14+
`requestTimeout`, `bodyLimit`, `compression`, `security`, `static`,
15+
16+
`trustProxy`). #4938 measured them: **none had a runtime reader and none was
17+
18+
reachable from any authoring surface**`stack.zod.ts` had no `server:` key,
19+
20+
so the whole shape was unwritable as well as unread. Mounting it wholesale
21+
22+
here would have made eight dead keys authorable in one move, which is the
23+
24+
declared-≠-enforced defect (Prime Directive #10) manufactured on purpose.
25+
26+
So this schema is deliberately NARROW: it carries only keys an executor
27+
28+
actually consumes, and it grows one key at a time, each arriving with its
29+
30+
consumer. Today that is exactly two:
31+
32+
| key | consumed by |
33+
34+
|---|---|
35+
36+
| `security.rateLimit` | `createDispatcherPlugin` → the inbound token bucket (`@objectstack/runtime` `security/inbound-rate-limit.ts`) — an over-budget caller gets `429` + `Retry-After` |
37+
38+
| `trustProxy` | the same limiter's IP resolution — see below |
39+
40+
The other seven `HttpServerConfigSchema` keys stay unreachable, and their
41+
42+
enforce-or-remove fate is tracked by #4938. Adding one here without an
43+
44+
executor re-opens the hole this narrowness exists to close.
45+
46+
## What `server:` is NOT for
47+
48+
**Deployment knobs stay on the CLI.** There is no `server.port` / `server.host`
49+
50+
on purpose: the listening socket is a property of *where* a stack runs, not of
51+
52+
the stack itself, and it is already owned by `objectstack serve -p <port>` /
53+
54+
`PORT`. Two authorities for one number is how a config becomes advisory. If a
55+
56+
future need does add `server.port`, the precedence is settled in advance and
57+
58+
recorded here so it cannot be re-litigated per-caller: **the CLI flag wins over
59+
60+
`server:`, and `server:` wins over the built-in default** — an operator
61+
62+
overriding a port at the command line must not be silently overruled by a file
63+
64+
baked into the artifact.
65+
66+
Related: #4910 (this seam), #4937 (the limiter that documented an execution
67+
68+
chain it never had), #4936 (`apis:` endpoint-level `rateLimit`, still
69+
70+
unwired), ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove).
71+
72+
<Callout type="info">
73+
**Source:** `packages/spec/src/system/stack-server.zod.ts`
74+
</Callout>
75+
76+
## TypeScript Usage
77+
78+
```typescript
79+
import { ServerRateLimitConfigSchema, StackServerConfigSchema, StackServerSecuritySchema } from '@objectstack/spec/system';
80+
import type { ServerRateLimitConfig, StackServerConfig, StackServerSecurity } from '@objectstack/spec/system';
81+
82+
// Validate data
83+
const result = ServerRateLimitConfigSchema.parse(data);
84+
```
85+
86+
---
87+
88+
## ServerRateLimitConfig
89+
90+
### Properties
91+
92+
| Property | Type | Required | Description |
93+
| :--- | :--- | :--- | :--- |
94+
| **enabled** | `boolean` || Enable rate limiting |
95+
| **windowMs** | `integer` || Time window in milliseconds |
96+
| **maxRequests** | `integer` || Max requests per window |
97+
98+
99+
---
100+
101+
## StackServerConfig
102+
103+
### Properties
104+
105+
| Property | Type | Required | Description |
106+
| :--- | :--- | :--- | :--- |
107+
| **security** | `{ rateLimit?: object }` | optional | Server-level security configuration. Today: the global inbound rate limit. |
108+
| **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. |
109+
110+
111+
---
112+
113+
## StackServerSecurity
114+
115+
### Properties
116+
117+
| Property | Type | Required | Description |
118+
| :--- | :--- | :--- | :--- |
119+
| **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. |
120+
121+
122+
---
123+

0 commit comments

Comments
 (0)