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.
- package/CHANGELOG.md +18 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +11 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/emails.d.ts +29 -0
- package/build/compiler/emails.js +205 -0
- package/build/compiler/generate.js +12 -1
- package/build/compiler/index.js +13 -5
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/envelope.d.ts +1 -0
- package/build/devserver/host.d.ts +1 -0
- package/build/devserver/host.js +40 -1
- package/build/devserver/index.js +3 -0
- package/build/devserver/module.js +3 -0
- package/build/devserver/ratelimit.d.ts +8 -0
- package/build/devserver/ratelimit.js +64 -0
- package/docs/README.md +6 -0
- package/docs/email.md +273 -0
- package/docs/ratelimit.md +95 -0
- package/package.json +3 -2
- package/server/globals/email.ts +188 -0
- package/server/globals/ratelimit.ts +89 -0
- package/server/globals/twofactor.ts +212 -0
- package/server/runtime/rest/RouteContext.ts +22 -0
- package/src/cli/create.ts +11 -0
- package/src/compiler/emails.ts +311 -0
- package/src/compiler/generate.ts +12 -1
- package/src/compiler/index.ts +21 -6
- package/src/devserver/envelope.ts +3 -0
- package/src/devserver/host.ts +66 -1
- package/src/devserver/index.ts +7 -0
- package/src/devserver/module.ts +3 -0
- package/src/devserver/ratelimit.ts +116 -0
package/build/devserver/host.js
CHANGED
|
@@ -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 {
|
|
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),
|
package/build/devserver/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|