toiljs 0.0.39 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { buildCryptoImports, freshCryptoState } from './crypto.js';
2
+ import { ratelimitCheck } from './ratelimit.js';
2
3
  const MAX_TOTAL_HEADERS_BYTES = 64 * 1024;
3
4
  const MAX_HEADER_NAME_LEN = 256;
4
5
  const MAX_HEADER_VALUE_LEN = 8192;
@@ -12,7 +13,14 @@ export class WasmAbortError extends Error {
12
13
  }
13
14
  }
14
15
  export function freshDispatchState() {
15
- return { status: null, headers: [], headerBytes: 0, sendfile: null, crypto: freshCryptoState() };
16
+ return {
17
+ status: null,
18
+ headers: [],
19
+ headerBytes: 0,
20
+ sendfile: null,
21
+ clientIp: '',
22
+ crypto: freshCryptoState(),
23
+ };
16
24
  }
17
25
  function mem(ref) {
18
26
  if (!ref.memory)
@@ -66,6 +74,37 @@ export function buildHostImports(ref, state) {
66
74
  throw new Error(`respond_file path too long: ${String(pathLen)} bytes`);
67
75
  state.sendfile = readBytes(ref, pathPtr, pathLen).toString('utf8');
68
76
  },
77
+ client_ip: (outPtr, cap) => {
78
+ const ip = state.clientIp;
79
+ if (ip.length === 0)
80
+ return 0;
81
+ const bytes = Buffer.from(ip, 'utf8');
82
+ if (bytes.length > cap)
83
+ return -1;
84
+ const m = mem(ref);
85
+ if (outPtr < 0 || outPtr + bytes.length > m.length)
86
+ throw new Error('client_ip write out of bounds');
87
+ bytes.copy(m, outPtr);
88
+ return bytes.length;
89
+ },
90
+ ratelimit_check: (routeId, strategy, limit, window, keyPtr, keyLen) => {
91
+ const identity = keyLen > 0
92
+ ? readBytes(ref, keyPtr, keyLen).toString('utf8')
93
+ : state.clientIp || '0';
94
+ const d = ratelimitCheck(routeId, strategy, limit, window, identity, Date.now());
95
+ return d.allowed ? 1 : -Math.max(1, d.retryAfterSecs);
96
+ },
97
+ email_send: (reqPtr, reqLen) => {
98
+ const raw = readBytes(ref, reqPtr, reqLen);
99
+ let to = '<unparsed>';
100
+ if (raw.length >= 14) {
101
+ const toLen = raw.readUInt16LE(0);
102
+ if (14 + toLen <= raw.length)
103
+ to = raw.toString('utf8', 14, 14 + toLen);
104
+ }
105
+ process.stdout.write(` ✉ dev email_send -> ${to} (not actually sent)\n`);
106
+ return 0;
107
+ },
69
108
  thread_spawn: (_startArg) => -1,
70
109
  'Date.now': () => Date.now(),
71
110
  ...buildCryptoImports(ref, state.crypto),
@@ -43,11 +43,14 @@ function resolveSendfile(root, file) {
43
43
  async function toEnvelopeRequest(request) {
44
44
  const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
45
45
  const body = hasBody ? new Uint8Array(await request.buffer()) : new Uint8Array(0);
46
+ const xff = request.headers['x-forwarded-for'];
47
+ const clientIp = typeof xff === 'string' && xff.length > 0 ? xff.split(',')[0].trim() : '127.0.0.1';
46
48
  return {
47
49
  method: request.method,
48
50
  path: request.url,
49
51
  headers: Object.entries(request.headers),
50
52
  body,
53
+ clientIp,
51
54
  };
52
55
  }
53
56
  function sendWasmResponse(response, root, result) {
@@ -6,6 +6,7 @@ export const UNHANDLED_HEADER = 'x-toil-unhandled';
6
6
  const WASM_PAGE = 65536;
7
7
  const PROVIDED_IMPORTS = new Set([
8
8
  'abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn', 'Date.now',
9
+ 'client_ip', 'ratelimit_check', 'email_send',
9
10
  'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
10
11
  'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
11
12
  'crypto.sign', 'crypto.verify', 'crypto.derive_bits',
@@ -47,6 +48,7 @@ export class WasmServerModule {
47
48
  const envelope = encodeRequestEnvelope(req);
48
49
  const ref = { memory: null };
49
50
  const state = freshDispatchState();
51
+ state.clientIp = req.clientIp ?? '';
50
52
  const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
51
53
  const exports = instance.exports;
52
54
  ref.memory = exports.memory;
@@ -76,6 +78,7 @@ export class WasmServerModule {
76
78
  const envelope = encodeRequestEnvelope(req);
77
79
  const ref = { memory: null };
78
80
  const state = freshDispatchState();
81
+ state.clientIp = req.clientIp ?? '';
79
82
  const instance = new WebAssembly.Instance(this.module, buildHostImports(ref, state));
80
83
  const exports = instance.exports;
81
84
  if (typeof exports.render !== 'function')
@@ -0,0 +1,8 @@
1
+ export declare const STRATEGY_FIXED = 0;
2
+ export declare const STRATEGY_SLIDING = 1;
3
+ export declare const STRATEGY_TOKEN_BUCKET = 2;
4
+ export interface DevDecision {
5
+ allowed: boolean;
6
+ retryAfterSecs: number;
7
+ }
8
+ export declare function ratelimitCheck(routeId: number, strategy: number, limit: number, window: number, identity: string, now: number): DevDecision;
@@ -0,0 +1,64 @@
1
+ export const STRATEGY_FIXED = 0;
2
+ export const STRATEGY_SLIDING = 1;
3
+ export const STRATEGY_TOKEN_BUCKET = 2;
4
+ const registry = new Map();
5
+ export function ratelimitCheck(routeId, strategy, limit, window, identity, now) {
6
+ let rl = registry.get(routeId);
7
+ if (rl === undefined) {
8
+ rl = { strategy, a: Math.max(1, limit), b: Math.max(1, window), keys: new Map() };
9
+ registry.set(routeId, rl);
10
+ }
11
+ if (rl.strategy === STRATEGY_TOKEN_BUCKET)
12
+ return checkBucket(rl, identity, now);
13
+ return checkWindow(rl, identity, now, rl.strategy === STRATEGY_SLIDING);
14
+ }
15
+ function checkBucket(rl, key, now) {
16
+ const capMilli = rl.a * 1000;
17
+ const refillPerSec = rl.b;
18
+ let st = rl.keys.get(key);
19
+ if (st === undefined) {
20
+ st = { tokensMilli: capMilli, window: 0, cur: 0, prev: 0, lastMs: now };
21
+ rl.keys.set(key, st);
22
+ }
23
+ const elapsed = Math.max(0, now - st.lastMs);
24
+ st.tokensMilli = Math.min(capMilli, st.tokensMilli + elapsed * refillPerSec);
25
+ st.lastMs = now;
26
+ if (st.tokensMilli >= 1000) {
27
+ st.tokensMilli -= 1000;
28
+ return { allowed: true, retryAfterSecs: 0 };
29
+ }
30
+ const needed = 1000 - st.tokensMilli;
31
+ const waitMs = Math.ceil(needed / refillPerSec);
32
+ return { allowed: false, retryAfterSecs: Math.max(1, Math.ceil(waitMs / 1000)) };
33
+ }
34
+ function checkWindow(rl, key, now, sliding) {
35
+ const limit = rl.a;
36
+ const windowMs = rl.b * 1000;
37
+ const curWindow = Math.floor(now / windowMs);
38
+ let st = rl.keys.get(key);
39
+ if (st === undefined) {
40
+ st = { tokensMilli: 0, window: curWindow, cur: 0, prev: 0, lastMs: now };
41
+ rl.keys.set(key, st);
42
+ }
43
+ if (curWindow === st.window + 1) {
44
+ st.prev = st.cur;
45
+ st.cur = 0;
46
+ st.window = curWindow;
47
+ }
48
+ else if (curWindow > st.window) {
49
+ st.prev = 0;
50
+ st.cur = 0;
51
+ st.window = curWindow;
52
+ }
53
+ st.lastMs = now;
54
+ const posInWindow = now % windowMs;
55
+ const effective = sliding
56
+ ? Math.floor((st.prev * (windowMs - posInWindow)) / windowMs) + st.cur
57
+ : st.cur;
58
+ if (effective < limit) {
59
+ st.cur += 1;
60
+ return { allowed: true, retryAfterSecs: 0 };
61
+ }
62
+ const waitMs = windowMs - posInWindow;
63
+ return { allowed: false, retryAfterSecs: Math.max(1, Math.ceil(waitMs / 1000)) };
64
+ }
package/docs/README.md CHANGED
@@ -27,9 +27,15 @@ and as a named export from `toiljs/server/runtime`.
27
27
  REST fetch client.
28
28
  - [Caching](./caching.md): the `@cache` decorator and `Response.cache(...)`
29
29
  (edge vs browser TTL, private scope, auth gating).
30
+ - [Rate limiting](./ratelimit.md): the `@ratelimit` decorator (FixedWindow /
31
+ SlidingWindow / TokenBucket), keyed on the unspoofable client IP, with
32
+ `429` + `Retry-After`.
30
33
  - [Auth, sessions, and `@user`](./auth.md): `@auth` route guards, the `@user`
31
34
  type, `AuthService` (post-quantum login, signed sessions, `getUser()`), and
32
35
  the client half.
36
+ - [Email](./email.md): `EmailService`, `EmailTemplate`, the `emails/` React
37
+ template pipeline, the stateless `TwoFactor` codes, provider config
38
+ (Resend / Gmail / SMTP), and limits.
33
39
  - [Cookies](./cookies.md): the `Cookie` builder, the `Cookies` parser/codec,
34
40
  `CookieMap`, `SecureCookies` (HMAC signing and AES-256-GCM encryption), the
35
41
  `base64url` helpers, and the `Request` / `Response` integration.
package/docs/email.md ADDED
@@ -0,0 +1,273 @@
1
+ # Email
2
+
3
+ toiljs can send transactional email from a route handler. A handler calls
4
+ `EmailService.send(...)` (or a typed `EmailTemplate` / `Emails.*` from the
5
+ `emails/` folder, or the stateless `TwoFactor` helper); the edge hands the
6
+ message to a single off-core mailer thread that talks to the provider over the
7
+ kernel network (never the worker cores), and **suspends** the wasm call until the
8
+ provider responds — so a slow send never blocks the worker.
9
+
10
+ Everything here is an ambient **global** (no import), like `crypto` and
11
+ `AuthService`. A tenant that never sends email pulls none of it into its build.
12
+
13
+ - **`EmailService`** — send one email.
14
+ - **`EmailTemplate`** — a reusable template with `{{placeholder}}` substitution
15
+ (plain text and/or HTML).
16
+ - **`emails/*.tsx`** — author emails as React components; the build renders them
17
+ to static HTML and generates a typed `Emails.<Name>.send(...)`.
18
+ - **`TwoFactor`** — stateless email verification codes (2FA / confirm), no DB.
19
+
20
+ > **The one rule of HTML email:** email clients run **no JavaScript** and strip
21
+ > `<style>`/external CSS. So HTML email is a static, inline-styled string, and
22
+ > any "rendering" (React, CSS files) happens at **build time**, not at send time.
23
+ > See [React email templates](#react-email-templates).
24
+
25
+ ## Configure email
26
+
27
+ Email is configured **per host on the edge** (it is a deployment capability, not
28
+ app code), in that host's TOML config — `hosts/<host>.toml`:
29
+
30
+ ```toml
31
+ [email]
32
+ enabled = true
33
+ from = "you@example.com" # validated; single address, no CRLF
34
+ provider = "resend" # "resend" | "gmail" | "smtp"
35
+ secret_ref = "resend_key" # a FILENAME, never the secret itself
36
+ max_per_min = 60 # per-tenant send budget
37
+ max_per_recipient_per_hour = 5 # anti-abuse cap per recipient
38
+ ```
39
+
40
+ When `enabled` is `false` (the default) the host has no email capability and
41
+ `EmailService.send` returns `Disabled`. If `enabled` is `true` but the config is
42
+ invalid or the secret can't be resolved, the **deploy fails** rather than
43
+ shipping a host that can never send.
44
+
45
+ ### Secrets
46
+
47
+ `secret_ref` is a **bare filename** (no path separators) under the secrets
48
+ directory `$TOIL_SECRETS_DIR` (default `/run/toil/secrets`). The file holds the
49
+ provider API key (Resend) or SMTP password. It is read once at load, zeroized in
50
+ memory, and never written to the config, logs, or `/_admin`.
51
+
52
+ ```
53
+ /run/toil/secrets/resend_key # contents: re_xxxxxxxxxxxx…
54
+ ```
55
+
56
+ ### Providers
57
+
58
+ **Resend** (`provider = "resend"`) — a JSON API; `secret_ref` holds the API key.
59
+
60
+ **Gmail** (`provider = "gmail"`) — SMTP with Gmail defaults: `smtp.gmail.com`,
61
+ port `587` (STARTTLS), username = `from`. `secret_ref` holds a Gmail **App
62
+ Password** (create one at <https://myaccount.google.com/apppasswords>; the
63
+ account needs 2-Step Verification). No extra keys needed:
64
+
65
+ ```toml
66
+ [email]
67
+ enabled = true
68
+ from = "you@gmail.com"
69
+ provider = "gmail"
70
+ secret_ref = "gmail_app_password"
71
+ ```
72
+
73
+ **Generic SMTP** (`provider = "smtp"`) — any submission server (Outlook,
74
+ SendGrid/Mailgun SMTP, your own). Requires `smtp_host`; port defaults to `587`
75
+ (STARTTLS), or set `465` for implicit TLS. `smtp_user` defaults to `from`.
76
+
77
+ ```toml
78
+ [email]
79
+ enabled = true
80
+ from = "noreply@example.com"
81
+ provider = "smtp"
82
+ secret_ref = "smtp_password"
83
+ smtp_host = "smtp.example.com"
84
+ smtp_port = 587
85
+ smtp_user = "noreply@example.com"
86
+ ```
87
+
88
+ ### In dev
89
+
90
+ `toiljs dev` has no real provider: `EmailService.send` logs `✉ dev email_send ->
91
+ <recipient> (not actually sent)` and returns `Sent`, so your flow proceeds. The
92
+ ABI is identical to the edge, so code that runs in dev runs on the edge.
93
+
94
+ ## Sending email
95
+
96
+ ```ts
97
+ import { Response, RouteContext } from 'toiljs/server/runtime';
98
+
99
+ @rest('notify')
100
+ class Notify {
101
+ @post('/welcome')
102
+ welcome(ctx: RouteContext): Response {
103
+ const status = EmailService.send(
104
+ 'alice@example.com',
105
+ 'Welcome!',
106
+ 'Thanks for signing up.', // plain-text body
107
+ 'welcome', // purpose tag (dedup / abuse keying)
108
+ '<h1>Thanks for signing up.</h1>', // optional HTML body
109
+ );
110
+ return status == EmailStatus.Sent
111
+ ? Response.text('sent\n')
112
+ : Response.text('could not send\n', 503);
113
+ }
114
+ }
115
+ ```
116
+
117
+ `send(to, subject, body, purpose = 'tx', html = '')` returns an **`EmailStatus`**:
118
+
119
+ | Status | Meaning | Retry? |
120
+ | --- | --- | --- |
121
+ | `Sent` | Accepted by the provider | — |
122
+ | `Deduped` | An identical recent `(recipient, purpose)` was collapsed | treat as sent |
123
+ | `Budget` | The host's per-minute budget is exhausted | yes, later |
124
+ | `TryLater` | The mailer was saturated / a queue was full | yes, back off |
125
+ | `RecipientCapped` | The per-recipient hourly cap was hit | no (this window) |
126
+ | `BadRecipient` | The address failed validation (CRLF, multiple addresses) | no |
127
+ | `Disabled` | This host has no `[email]` capability | no |
128
+ | `ProviderError` | The provider rejected it, or transport failed after retries | no |
129
+
130
+ `purpose` is a short, non-PII tag (`"welcome"`, `"reset"`, …). The mailer folds
131
+ it into the **dedup** key (identical `(host, recipient, purpose)` within ~30s is
132
+ collapsed to one send) and the abuse counters. It is never logged in the clear.
133
+
134
+ The recipient is validated host-side (exactly one address, no CR/LF/`<>`), so a
135
+ guest can never smuggle a second envelope recipient or a header into the send.
136
+
137
+ ## Templates
138
+
139
+ `EmailTemplate` is a reusable message with `{{placeholder}}` substitution, for
140
+ when the same email is sent with different values:
141
+
142
+ ```ts
143
+ const welcome = new EmailTemplate(
144
+ 'Welcome, {{name}}!', // subject
145
+ 'Hi {{name}}, your code is {{code}}.', // plain-text body
146
+ '<h1>Welcome, {{name}}</h1><p>Code: <b>{{code}}</b></p>', // html (optional)
147
+ );
148
+
149
+ const vars = new Map<string, string>();
150
+ vars.set('name', 'Alice');
151
+ vars.set('code', '123456');
152
+ const status = welcome.send('alice@example.com', vars, 'welcome');
153
+ ```
154
+
155
+ - `{{ name }}` (with surrounding spaces) is accepted; an unknown placeholder
156
+ renders to the empty string.
157
+ - Omit the `html` argument for a plain-text template.
158
+ - `template.render(vars)` returns the rendered `{ subject, body, html }` without
159
+ sending (useful for preview/testing).
160
+
161
+ For anything richer than `{{token}}` substitution — real layout, CSS, brand —
162
+ author the email as a React component instead.
163
+
164
+ ## React email templates
165
+
166
+ Write emails as React components in an **`emails/`** folder. At `toiljs build`
167
+ each one is rendered **once, at build time**, to static inline-CSS HTML (because
168
+ the inbox runs no JS), with the component's props turned into `{{token}}` holes;
169
+ the build then generates a typed `Emails.<Name>.send(...)` your server calls.
170
+
171
+ ```tsx
172
+ // emails/Welcome.tsx
173
+ export const subject = 'Welcome, {{name}}!';
174
+
175
+ export default function Welcome({ name, code }: { name: string; code: string }) {
176
+ return (
177
+ <table width="100%" style={{ fontFamily: 'Arial, sans-serif' }}>
178
+ <tbody>
179
+ <tr>
180
+ <td style={{ padding: '24px' }}>
181
+ <h1 style={{ color: '#111' }}>Welcome, {name}!</h1>
182
+ <p>Your code is <b>{code}</b>.</p>
183
+ </td>
184
+ </tr>
185
+ </tbody>
186
+ </table>
187
+ );
188
+ }
189
+ ```
190
+
191
+ The generated `Emails.Welcome.send(...)` takes the recipient, then one argument
192
+ per `{{token}}` **in alphabetical order**, then an optional `purpose`:
193
+
194
+ ```ts
195
+ // emails/Welcome.tsx uses {{code}} and {{name}} -> params are (code, name)
196
+ const status = Emails.Welcome.send('alice@example.com', '123456', 'Alice');
197
+ ```
198
+
199
+ Authoring notes:
200
+
201
+ - **Use inline `style={{ ... }}`.** Email clients strip `<style>`/external CSS;
202
+ inline styles render everywhere. A CSS file imported into the component is
203
+ inlined for you at build (via `juice`).
204
+ - **Optional exports:** `export const subject` (a token template; defaults to the
205
+ email name), `export const text` (a plain-text alternative; otherwise derived
206
+ from the HTML), `export const purpose`.
207
+ - **Build-time, field substitution only.** Because the component renders once at
208
+ build, per-send data is `{{token}}` substitution — a runtime `{items.map(...)}`
209
+ or conditional bakes in at build, it does not re-run per recipient. That covers
210
+ transactional / 2FA / confirmation email; dynamic lists need a different
211
+ approach.
212
+ - The generated `server/_emails.ts` is regenerated on `build`/`dev` and should be
213
+ gitignored.
214
+
215
+ ## Email verification codes (`TwoFactor`)
216
+
217
+ `TwoFactor` is a **stateless** email-code primitive (2FA, email confirmation,
218
+ magic codes) — no database. It emails a random code and returns a signed
219
+ **token** that commits to the code via HMAC, without putting the code in the
220
+ token (the code is only in the email). Verification recomputes the HMAC from the
221
+ token plus the user-entered code, so a valid `(token, code)` pair can only come
222
+ from someone who both received the email and holds the token.
223
+
224
+ ```ts
225
+ // 1. Issue + email a code; hand `token` to the client (a cookie or hidden field).
226
+ const challenge = TwoFactor.send('alice@example.com', 'login'); // emails the code
227
+ // challenge.token -> give to the client
228
+ // challenge.status -> the EmailStatus of the send
229
+
230
+ // 2. Later, verify what the user typed.
231
+ const ok: bool = TwoFactor.verify(challenge.token, 'alice@example.com', userEntered);
232
+ ```
233
+
234
+ - **`send(recipient, purpose, ttlSecs = 600, digits = 6)`** — issues a code,
235
+ emails it with a built-in template, returns `{ token, status }`.
236
+ - **`issue(recipient, purpose, ttlSecs, digits)`** — returns `{ code, token }`
237
+ **without** sending, so you can email `code` with your own `EmailTemplate` /
238
+ `Emails.*` for a branded message.
239
+ - **`verify(token, recipient, code)`** — `true` only for a code issued for that
240
+ recipient that hasn't expired. Constant-time compare.
241
+ - **`TwoFactor.setSecret(secret)`** — the HMAC secret for the tokens. Call once
242
+ at startup in `main.ts`; it must be identical on every edge instance and out of
243
+ any client bundle. (This is separate from the provider `secret_ref`.)
244
+
245
+ **Limitation:** this gives integrity + expiry but **not single-use** — a valid
246
+ code verifies repeatedly within its TTL, because there is no server state to burn
247
+ it. Keep the TTL short; for true single-use, store a per-recipient
248
+ last-verified-at and reject at or before it.
249
+
250
+ ## Limits and abuse controls
251
+
252
+ All enforced authoritatively in the single mailer (so the counts are exact across
253
+ all workers):
254
+
255
+ - **Per-tenant budget** — `max_per_min` (a token bucket). Over it → `Budget`.
256
+ - **Per-recipient cap** — `max_per_recipient_per_hour`. Over it →
257
+ `RecipientCapped`.
258
+ - **Dedup** — identical `(host, recipient, purpose)` within ~30s → `Deduped`.
259
+
260
+ Editing these in the host config takes effect on the next send (no restart).
261
+
262
+ ## Observability
263
+
264
+ `GET /_admin/email` returns process-wide counters by reason (JSON), e.g.
265
+ `submitted`, `sent`, `deduped`, `budget`, `recipient_capped`, `try_later`,
266
+ `bad_recipient`, `provider_error`. It exposes **counts only** — never a
267
+ recipient, code, subject, body, or secret.
268
+
269
+ ## See also
270
+
271
+ - [Rate limiting](./ratelimit.md) — protect your routes (including any email
272
+ trigger) with `@ratelimit`.
273
+ - [Web Crypto](./crypto.md) — the `crypto` global `TwoFactor` builds on.
@@ -0,0 +1,95 @@
1
+ # Rate limiting
2
+
3
+ The `@ratelimit` decorator throttles **any** `@rest` route — a login, a signup, a
4
+ public API, an email trigger, anything. It is enforced at the edge, before your
5
+ handler runs, and keyed by default on the connecting client's **unspoofable** IP,
6
+ so it works as an abuse / brute-force control out of the box.
7
+
8
+ It composes with the other route decorators and is independent of email or auth.
9
+
10
+ ## Using `@ratelimit`
11
+
12
+ Add it to a route alongside the verb decorator:
13
+
14
+ ```ts
15
+ import { Response, RouteContext } from 'toiljs/server/runtime';
16
+
17
+ @rest('auth')
18
+ class Auth {
19
+ // At most 5 login attempts per 60 seconds per client IP.
20
+ @ratelimit(RateLimit.SlidingWindow, 5, 60)
21
+ @post('/login')
22
+ login(ctx: RouteContext): Response {
23
+ // ... only runs if under the limit ...
24
+ return Response.text('ok\n');
25
+ }
26
+ }
27
+ ```
28
+
29
+ `@ratelimit(strategy, limit, window)`:
30
+
31
+ - **`strategy`** — a `RateLimit` value (ambient global, no import):
32
+ `RateLimit.FixedWindow`, `RateLimit.SlidingWindow`, or `RateLimit.TokenBucket`.
33
+ - **`limit`** and **`window`** — integer literals whose meaning depends on the
34
+ strategy (see below).
35
+
36
+ When a request is over the limit the edge returns **`429 Too Many Requests`**
37
+ with a **`Retry-After`** header (whole seconds), and your handler never runs. The
38
+ guard runs **before `@auth`**, so unauthenticated floods are limited too.
39
+
40
+ > Both arguments must be **integer literals** and the strategy a `RateLimit`
41
+ > member (or a bare integer tag). A malformed decorator emits no guard rather
42
+ > than miscompiling — the same fail-safe rule as `@cache`.
43
+
44
+ ## Strategies
45
+
46
+ | Strategy | `limit`, `window` mean | Behavior |
47
+ | --- | --- | --- |
48
+ | `FixedWindow` | `limit` events per `window` seconds | Cheapest. Counts in aligned wall-clock buckets; a caller hammering a boundary can briefly get up to ~2× `limit` across two adjacent windows. |
49
+ | `SlidingWindow` | `limit` events per `window` seconds | Smooths the fixed-window boundary by weighting the previous window. Best general choice for "N per period". |
50
+ | `TokenBucket` | `limit` = burst size, `window` = refill **per second** | Allows an initial burst of `limit`, then a steady `window` tokens/sec. Good for bursty-but-bounded APIs. |
51
+
52
+ Examples:
53
+
54
+ ```ts
55
+ // 100 requests / minute, smoothed:
56
+ @ratelimit(RateLimit.SlidingWindow, 100, 60)
57
+
58
+ // Burst of 20, then 5 per second sustained:
59
+ @ratelimit(RateLimit.TokenBucket, 20, 5)
60
+
61
+ // Exactly 3 per hour, cheapest:
62
+ @ratelimit(RateLimit.FixedWindow, 3, 3600)
63
+ ```
64
+
65
+ ## How requests are keyed
66
+
67
+ By default the limiter keys on the **client IP** — specifically the TCP peer
68
+ address the edge observed (`ctx.clientIp()`), **not** a header like
69
+ `X-Forwarded-For`, which a client can forge. That makes it a real abuse control:
70
+ a caller can't reset their bucket by spoofing a header.
71
+
72
+ The count is **exact across all 14 edge workers** (a given IP always maps to one
73
+ authoritative shard), so the limit is global per route, not per worker. Only
74
+ routes that opt in with `@ratelimit` ever pay anything — the lock-free fast path
75
+ for everything else is untouched.
76
+
77
+ > Each rate-limited route has its own independent limiter — a limit on `/login`
78
+ > does not consume the budget of `/signup`.
79
+
80
+ ## Notes and limits
81
+
82
+ - **Route-level only.** Put `@ratelimit` on each route you want limited; there is
83
+ no controller-wide form yet (unlike `@auth`).
84
+ - **Keyed on IP.** The decorator keys on the peer IP today. (A per-user / custom
85
+ key — limiting by account instead of IP — exists in the runtime but is not yet
86
+ exposed through the decorator.)
87
+ - **In dev.** `toiljs dev` runs a single-process mirror of the same three
88
+ strategies, so a limited route behaves the same locally as on the edge.
89
+
90
+ ## See also
91
+
92
+ - [Email](./email.md) — `@ratelimit` pairs well with email triggers (verification
93
+ codes, password resets) to blunt abuse.
94
+ - [Auth, sessions, and `@user`](./auth.md) — `@ratelimit` runs before the `@auth`
95
+ guard, so it protects the login itself.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.39",
4
+ "version": "0.0.41",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -126,9 +126,10 @@
126
126
  "eslint-plugin-react-hooks": "^7.1.1",
127
127
  "eslint-plugin-react-refresh": "^0.5.2",
128
128
  "hash-wasm": "^4.12.0",
129
+ "juice": "^12.1.0",
129
130
  "picocolors": "^1.1.1",
130
131
  "sharp": "^0.35.0",
131
- "toilscript": "^0.1.22",
132
+ "toilscript": "^0.1.23",
132
133
  "typescript-eslint": "^8.60.0",
133
134
  "vite": "^8.0.14",
134
135
  "vite-imagetools": "^10.0.0",