|
| 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. |
0 commit comments