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.
@@ -0,0 +1,188 @@
1
+ // EmailService + EmailTemplate + the `EmailStatus` enum: the guest surface for
2
+ // the per-tenant outbound email primitive, available as a no-import global (the
3
+ // toilscript `--lib` mechanism, like `AuthService` and `RateLimitService`). A
4
+ // handler calls `EmailService.send(...)` for a one-off, or defines a reusable
5
+ // `EmailTemplate` (with `{{placeholder}}` interpolation, plain text and/or HTML)
6
+ // and calls `template.send(to, vars)`.
7
+ //
8
+ // The host hands the message to the off-core mailer and SUSPENDS the call until
9
+ // the provider responds (on the async edge executor) without blocking the
10
+ // worker. `purpose` is a short tag ("verify", "reset", ...) the host folds into
11
+ // its dedup + per-recipient abuse keys; it is never logged raw. The recipient is
12
+ // validated at the host boundary (no header injection / multiple addresses) and
13
+ // the send is capped per tenant.
14
+ //
15
+ // Backed by the `email_send` host import (toil-backend `email_send_import.rs`,
16
+ // and the toiljs dev-server mock). A tenant that never sends email never imports
17
+ // `email_send`, so AssemblyScript tree-shakes it; a module that does must be
18
+ // deployed to an edge built with the `email` feature.
19
+
20
+ // Host import: submit one email and resolve to its status code. `reqPtr`/`reqLen`
21
+ // is the length-prefixed request blob (header below). Suspends the wasm call
22
+ // until the off-core send completes.
23
+ // @ts-ignore: decorator
24
+ @external('env', 'email_send')
25
+ declare function __toilEmailSend(reqPtr: usize, reqLen: i32): i32;
26
+
27
+ /**
28
+ * The result of a send. Kept in sync with `EmailStatus` in toil-backend
29
+ * `email.rs` (`#[repr(i32)]`). `Sent` and `Deduped` are success; the rest say
30
+ * why it was not delivered and whether a retry could help.
31
+ */
32
+ export enum EmailStatus {
33
+ /** Accepted by the provider. */
34
+ Sent = 0,
35
+ /** This host has no `[email]` capability (or it is disabled / not on this path). */
36
+ Disabled = 1,
37
+ /** The tenant's per-minute budget is exhausted. Retriable later. */
38
+ Budget = 2,
39
+ /** The per-recipient hourly cap was hit. Terminal for this recipient/window. */
40
+ RecipientCapped = 3,
41
+ /** An identical recent (recipient, purpose) send was collapsed. Treat as sent. */
42
+ Deduped = 4,
43
+ /** The mailer was saturated / a queue was full. Retriable; back off. */
44
+ TryLater = 5,
45
+ /** The recipient failed host-side validation (CRLF, multiple addresses, malformed). */
46
+ BadRecipient = 6,
47
+ /** The provider rejected the send, or transport failed after retries. Terminal. */
48
+ ProviderError = 7,
49
+ }
50
+
51
+ export namespace EmailService {
52
+ /** Header: u16 to_len | u16 subject_len | u16 purpose_len | u32 body_len | u32 html_len. */
53
+ const HEADER_LEN: i32 = 14;
54
+
55
+ /**
56
+ * Send one email to `to` and return its {@link EmailStatus}. Suspends until
57
+ * the off-core mailer reports a result.
58
+ *
59
+ * `body` is the plain-text body; pass a non-empty `html` to send an HTML
60
+ * message (then `body` is the plain-text alternative — set both for the best
61
+ * deliverability, or leave `body` empty for HTML-only). `purpose` is a short,
62
+ * non-PII tag used for host-side dedup/abuse keying.
63
+ */
64
+ export function send(
65
+ to: string,
66
+ subject: string,
67
+ body: string,
68
+ purpose: string = 'tx',
69
+ html: string = '',
70
+ ): EmailStatus {
71
+ const toB = Uint8Array.wrap(String.UTF8.encode(to));
72
+ const subjB = Uint8Array.wrap(String.UTF8.encode(subject));
73
+ const purpB = Uint8Array.wrap(String.UTF8.encode(purpose));
74
+ const bodyB = Uint8Array.wrap(String.UTF8.encode(body));
75
+ const htmlB = Uint8Array.wrap(String.UTF8.encode(html));
76
+
77
+ const total =
78
+ HEADER_LEN + toB.length + subjB.length + purpB.length + bodyB.length + htmlB.length;
79
+ const buf = new Uint8Array(total);
80
+ const base = buf.dataStart;
81
+
82
+ // Little-endian header (wasm stores are LE), then the five payloads in
83
+ // the order the host parser expects: to, subject, purpose, body, html.
84
+ store<u16>(base, <u16>toB.length, 0);
85
+ store<u16>(base, <u16>subjB.length, 2);
86
+ store<u16>(base, <u16>purpB.length, 4);
87
+ store<u32>(base, <u32>bodyB.length, 6);
88
+ store<u32>(base, <u32>htmlB.length, 10);
89
+
90
+ let off = base + HEADER_LEN;
91
+ memory.copy(off, toB.dataStart, toB.length);
92
+ off += toB.length;
93
+ memory.copy(off, subjB.dataStart, subjB.length);
94
+ off += subjB.length;
95
+ memory.copy(off, purpB.dataStart, purpB.length);
96
+ off += purpB.length;
97
+ memory.copy(off, bodyB.dataStart, bodyB.length);
98
+ off += bodyB.length;
99
+ memory.copy(off, htmlB.dataStart, htmlB.length);
100
+
101
+ return <EmailStatus>__toilEmailSend(base, total);
102
+ }
103
+ }
104
+
105
+ /** One template rendered against a variable map: the concrete parts to send. */
106
+ export class RenderedEmail {
107
+ subject: string;
108
+ body: string;
109
+ html: string;
110
+ constructor(subject: string, body: string, html: string) {
111
+ this.subject = subject;
112
+ this.body = body;
113
+ this.html = html;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * A reusable email template with `{{placeholder}}` interpolation, defined once
119
+ * and sent many times with different variables:
120
+ *
121
+ * const welcome = new EmailTemplate(
122
+ * 'Welcome, {{name}}!',
123
+ * 'Hi {{name}}, your code is {{code}}.',
124
+ * '<h1>Welcome, {{name}}</h1><p>Your code is <b>{{code}}</b>.</p>',
125
+ * );
126
+ * const vars = new Map<string,string>();
127
+ * vars.set('name', 'Alice'); vars.set('code', '123456');
128
+ * welcome.send('alice@example.com', vars, 'welcome');
129
+ *
130
+ * `{{ key }}` (with surrounding spaces) is accepted; an unknown placeholder
131
+ * renders to the empty string. `html` is optional — omit it for a plain-text
132
+ * template.
133
+ */
134
+ export class EmailTemplate {
135
+ private subjectTpl: string;
136
+ private bodyTpl: string;
137
+ private htmlTpl: string;
138
+
139
+ constructor(subject: string, body: string, html: string = '') {
140
+ this.subjectTpl = subject;
141
+ this.bodyTpl = body;
142
+ this.htmlTpl = html;
143
+ }
144
+
145
+ /** Render the template against `vars` without sending (preview / testing). */
146
+ render(vars: Map<string, string>): RenderedEmail {
147
+ return new RenderedEmail(
148
+ interpolate(this.subjectTpl, vars),
149
+ interpolate(this.bodyTpl, vars),
150
+ this.htmlTpl.length > 0 ? interpolate(this.htmlTpl, vars) : '',
151
+ );
152
+ }
153
+
154
+ /** Render and send to `to`. Returns the send's {@link EmailStatus}. */
155
+ send(to: string, vars: Map<string, string>, purpose: string = 'tx'): EmailStatus {
156
+ const r = this.render(vars);
157
+ return EmailService.send(to, r.subject, r.body, purpose, r.html);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Substitute every `{{key}}` in `pattern` with `vars.get(key)`. Surrounding
163
+ * whitespace in the placeholder is ignored (`{{ name }}` == `{{name}}`); an
164
+ * unknown key renders to the empty string; an unterminated `{{` is emitted
165
+ * literally. Module-private (not part of the `--lib` global surface).
166
+ */
167
+ function interpolate(pattern: string, vars: Map<string, string>): string {
168
+ let out = '';
169
+ let i = 0;
170
+ const n = pattern.length;
171
+ while (i < n) {
172
+ const open = pattern.indexOf('{{', i);
173
+ if (open < 0) {
174
+ out += pattern.substring(i);
175
+ break;
176
+ }
177
+ out += pattern.substring(i, open);
178
+ const close = pattern.indexOf('}}', open + 2);
179
+ if (close < 0) {
180
+ out += pattern.substring(open); // unterminated -> literal
181
+ break;
182
+ }
183
+ const key = pattern.substring(open + 2, close).trim();
184
+ if (vars.has(key)) out += vars.get(key);
185
+ i = close + 2;
186
+ }
187
+ return out;
188
+ }
@@ -0,0 +1,89 @@
1
+ // RateLimitService + the `RateLimit` strategy enum: the server half of the
2
+ // `@ratelimit` route decorator, available as a no-import global (registered via
3
+ // the toilscript `--lib` mechanism, the same way `AuthService` and `crypto`
4
+ // are). The toilscript `@ratelimit(strategy, limit, window)` decorator lowers to
5
+ // a single `RateLimitService.guard(...)` call at the top of the route, before
6
+ // the `@auth` guard and the handler.
7
+ //
8
+ // The actual counting happens host-side in an EXACT limiter shared across all
9
+ // edge workers and keyed, by default, on the request's UNSPOOFABLE peer IP (the
10
+ // socket's remote address, never a forgeable header). Backed by the
11
+ // `ratelimit_check` host import (toil-backend `ratelimit_check_import.rs`, and
12
+ // the toiljs dev-server mock). A tenant that does not use `@ratelimit` never
13
+ // references this namespace, so AssemblyScript tree-shakes it and the module
14
+ // never imports `ratelimit_check` (only opt-in routes pay anything).
15
+
16
+ import { Response } from 'toiljs/server/runtime';
17
+
18
+ // Host import: account one event against the route's shared limiter. Args are
19
+ // `(route_id, strategy_tag, limit, window, key_ptr, key_len)`; the two param
20
+ // slots mean `(limit, window_secs)` for the window strategies and
21
+ // `(burst, refill_per_sec)` for the token bucket. Returns the remaining budget
22
+ // (`>= 0`, allowed) or a NEGATIVE `Retry-After` in seconds (denied). When
23
+ // `key_len` is 0 the host keys on the peer IP.
24
+ // @ts-ignore: decorator
25
+ @external('env', 'ratelimit_check')
26
+ declare function __toilRateLimitCheck(
27
+ routeId: i32,
28
+ strategy: i32,
29
+ limit: i32,
30
+ window: i32,
31
+ keyPtr: usize,
32
+ keyLen: i32,
33
+ ): i32;
34
+
35
+ /**
36
+ * Rate-limit strategy, the first argument to `@ratelimit(...)`. The numeric
37
+ * values are the host's strategy tags (kept in sync with `Strategy` in
38
+ * toil-backend `ratelimit.rs`); the toilscript decorator transform reads the
39
+ * member by name, so reordering would only affect a bare-integer decorator arg.
40
+ *
41
+ * - `FixedWindow`: at most `limit` events per `window` seconds. Cheapest; a
42
+ * caller hammering the boundary can briefly land up to ~2x `limit`.
43
+ * - `SlidingWindow`: weights the previous window to smooth that boundary burst.
44
+ * - `TokenBucket`: `limit` is the burst size, `window` the refill-per-second;
45
+ * allows an initial burst then a steady rate.
46
+ */
47
+ export enum RateLimit {
48
+ FixedWindow = 0,
49
+ SlidingWindow = 1,
50
+ TokenBucket = 2,
51
+ }
52
+
53
+ export namespace RateLimitService {
54
+ /**
55
+ * The `@ratelimit` decorator's guard. Accounts one event for this request
56
+ * against the route's shared limiter, keyed on the UNSPOOFABLE peer IP.
57
+ * Returns a `429 Too Many Requests` (with a `Retry-After` header) when over
58
+ * the limit, or `null` to let the request proceed.
59
+ */
60
+ export function guard(routeId: i32, strategy: i32, limit: i32, window: i32): Response | null {
61
+ const r = __toilRateLimitCheck(routeId, strategy, limit, window, 0, 0);
62
+ return r >= 0 ? null : tooMany(-r);
63
+ }
64
+
65
+ /**
66
+ * Like {@link guard} but keyed on a tenant-chosen identity `key` (e.g. an
67
+ * authenticated user id) instead of the peer IP, for per-user limits. An
68
+ * empty `key` falls back to the peer IP (host-side).
69
+ */
70
+ export function guardKeyed(
71
+ routeId: i32,
72
+ strategy: i32,
73
+ limit: i32,
74
+ window: i32,
75
+ key: string,
76
+ ): Response | null {
77
+ const kb = Uint8Array.wrap(String.UTF8.encode(key));
78
+ const r = __toilRateLimitCheck(routeId, strategy, limit, window, kb.dataStart, kb.length);
79
+ return r >= 0 ? null : tooMany(-r);
80
+ }
81
+
82
+ /** A `429` response carrying the host-computed `Retry-After` (whole seconds). */
83
+ function tooMany(retryAfterSecs: i32): Response {
84
+ return Response.text('Too Many Requests\n', 429).setHeader(
85
+ 'Retry-After',
86
+ retryAfterSecs.toString(),
87
+ );
88
+ }
89
+ }
@@ -0,0 +1,212 @@
1
+ // TwoFactor: a STATELESS email verification-code primitive (2FA / confirm /
2
+ // magic-code), available as a no-import global (the toilscript `--lib`
3
+ // mechanism, like `AuthService`, `EmailService`, `RateLimitService`). No
4
+ // database: the server stores nothing between mint and verify.
5
+ //
6
+ // How stateless verification works: `mint`/`issue` generates a random code,
7
+ // emails it to the recipient, and returns a signed TOKEN that commits to the
8
+ // code via HMAC over `(recipient, purpose, exp, code)` -- WITHOUT putting the
9
+ // code in the token (the code is only in the email). Hand the token to the
10
+ // client (a cookie or hidden field). On `verify(token, recipient, code)` the
11
+ // server recomputes the HMAC from the token's `(purpose, exp)` + the caller's
12
+ // `(recipient, code)` and constant-time compares. A valid `(token, code)` pair
13
+ // can only be produced by someone who BOTH received the email (knows the code)
14
+ // AND holds the token. The HMAC binds the recipient + purpose + expiry, so a
15
+ // token can't be replayed for another address, flow, or past its TTL.
16
+ //
17
+ // LIMITATION: this is integrity + expiry, NOT single-use. A valid code can be
18
+ // verified multiple times within its (short) TTL -- there is no server state to
19
+ // burn it. For true single-use, keep a per-recipient last-verified-at (a DB /
20
+ // the edge store) and reject codes at or before it, plus a short TTL.
21
+
22
+ import {
23
+ CryptoKey,
24
+ HmacImportParams,
25
+ HmacParams,
26
+ ALG_SHA_256,
27
+ USAGE_SIGN,
28
+ USAGE_VERIFY,
29
+ } from 'crypto';
30
+ import { DataWriter, DataReader } from 'data';
31
+
32
+ import { base64UrlEncode, base64UrlDecode, Time } from 'toiljs/server/runtime';
33
+
34
+ // HMAC key for the verification tokens. The SAME secret must be configured on
35
+ // every edge instance and kept out of any client bundle. The default is a loud
36
+ // DEV placeholder; a real deployment calls `TwoFactor.setSecret(...)` at startup
37
+ // (a build-time constant is consistent across instances).
38
+ // TODO(secret): move to a per-deployment host-config secret.
39
+ let __twofaSecret: Uint8Array = Uint8Array.wrap(
40
+ String.UTF8.encode('toil-dev-insecure-2fa-secret-CHANGE-ME'),
41
+ );
42
+
43
+ /** Token format version (first byte of both the token and the signed message). */
44
+ const TWOFA_VERSION: u8 = 1;
45
+
46
+ function importHmac(key: Uint8Array): CryptoKey {
47
+ return crypto.subtle.importKey(
48
+ 'raw',
49
+ key,
50
+ new HmacImportParams(ALG_SHA_256),
51
+ false,
52
+ USAGE_SIGN | USAGE_VERIFY,
53
+ );
54
+ }
55
+
56
+ /** HMAC-SHA256 over `msg` with the configured secret. */
57
+ function hmac(msg: Uint8Array): Uint8Array {
58
+ return crypto.subtle.sign(new HmacParams(), importHmac(__twofaSecret), msg);
59
+ }
60
+
61
+ /** Constant-time HMAC verify (the host's `crypto.verify` compares in constant time). */
62
+ function hmacVerify(mac: Uint8Array, msg: Uint8Array): bool {
63
+ return crypto.subtle.verify(new HmacParams(), importHmac(__twofaSecret), mac, msg);
64
+ }
65
+
66
+ /**
67
+ * The canonical signed message, a FIXED length-prefixed binary layout (no JSON,
68
+ * so fields can't be confused): `u8 version | str recipient | str purpose |
69
+ * u64 exp | str code`. The code is signed here but NEVER stored in the token.
70
+ */
71
+ function canonicalMessage(recipient: string, purpose: string, exp: u64, code: string): Uint8Array {
72
+ const w = new DataWriter();
73
+ w.writeU8(TWOFA_VERSION);
74
+ w.writeString(recipient);
75
+ w.writeString(purpose);
76
+ w.writeU64(exp);
77
+ w.writeString(code);
78
+ return w.toBytes();
79
+ }
80
+
81
+ /** The client token: `u8 version | str purpose | u64 exp | bytes mac` (base64url). */
82
+ function encodeToken(purpose: string, exp: u64, mac: Uint8Array): string {
83
+ const w = new DataWriter();
84
+ w.writeU8(TWOFA_VERSION);
85
+ w.writeString(purpose);
86
+ w.writeU64(exp);
87
+ w.writeBytes(mac);
88
+ return base64UrlEncode(w.toBytes());
89
+ }
90
+
91
+ /** A random numeric code of `digits` digits, from the CSPRNG. */
92
+ function randomCode(digits: i32): string {
93
+ const buf = new Uint8Array(digits);
94
+ crypto.getRandomValues(buf);
95
+ let s = '';
96
+ for (let i = 0; i < digits; i++) {
97
+ // byte % 10: the tiny modulo bias is irrelevant for a short-lived code.
98
+ s += (<i32>buf[i] % 10).toString();
99
+ }
100
+ return s;
101
+ }
102
+
103
+ function clampDigits(digits: i32): i32 {
104
+ if (digits < 4) return 4;
105
+ if (digits > 10) return 10;
106
+ return digits;
107
+ }
108
+
109
+ /** A generated code + its token, for the bring-your-own-email path ({@link TwoFactor.issue}). */
110
+ export class TwoFactorIssue {
111
+ code: string;
112
+ token: string;
113
+ constructor(code: string, token: string) {
114
+ this.code = code;
115
+ this.token = token;
116
+ }
117
+ }
118
+
119
+ /** The result of {@link TwoFactor.send}: the token to hand the client + the email status. */
120
+ export class TwoFactorChallenge {
121
+ token: string;
122
+ status: EmailStatus;
123
+ constructor(token: string, status: EmailStatus) {
124
+ this.token = token;
125
+ this.status = status;
126
+ }
127
+ }
128
+
129
+ export namespace TwoFactor {
130
+ /** Default code lifetime if none is given. Keep it short (stateless => not single-use). */
131
+ export const DEFAULT_TTL_SECS: u64 = 600; // 10 minutes
132
+ /** Default number of digits in a code. */
133
+ export const DEFAULT_DIGITS: i32 = 6;
134
+
135
+ /**
136
+ * Configure the HMAC secret used to sign verification tokens. Call once at
137
+ * startup from `main.ts`. Must be identical on every edge instance and kept
138
+ * out of any client bundle.
139
+ */
140
+ export function setSecret(secret: Uint8Array): void {
141
+ __twofaSecret = secret;
142
+ }
143
+
144
+ /**
145
+ * Generate a code + token WITHOUT sending an email (bring your own email,
146
+ * e.g. an `EmailTemplate`): mint the code yourself-friendly path. Email
147
+ * `result.code` to `recipient` and hand `result.token` to the client.
148
+ */
149
+ export function issue(
150
+ recipient: string,
151
+ purpose: string,
152
+ ttlSecs: u64 = DEFAULT_TTL_SECS,
153
+ digits: i32 = DEFAULT_DIGITS,
154
+ ): TwoFactorIssue {
155
+ const code = randomCode(clampDigits(digits));
156
+ const exp = Time.nowSeconds() + ttlSecs;
157
+ const mac = hmac(canonicalMessage(recipient, purpose, exp, code));
158
+ return new TwoFactorIssue(code, encodeToken(purpose, exp, mac));
159
+ }
160
+
161
+ /**
162
+ * Issue a code and send it in a built-in email; returns the token + the send
163
+ * {@link EmailStatus}. For a branded email, use {@link issue} + your own
164
+ * `EmailTemplate`/`EmailService.send` instead.
165
+ */
166
+ export function send(
167
+ recipient: string,
168
+ purpose: string,
169
+ ttlSecs: u64 = DEFAULT_TTL_SECS,
170
+ digits: i32 = DEFAULT_DIGITS,
171
+ ): TwoFactorChallenge {
172
+ const issued = issue(recipient, purpose, ttlSecs, digits);
173
+ const subject = 'Your verification code';
174
+ const text =
175
+ 'Your verification code is ' +
176
+ issued.code +
177
+ '. It expires shortly. If you did not request it, ignore this email.';
178
+ const html =
179
+ '<table width="100%" style="font-family:Arial,sans-serif"><tbody><tr><td style="padding:24px">' +
180
+ '<p style="color:#111827;font-size:15px;margin:0 0 8px">Your verification code is</p>' +
181
+ '<p style="font-size:30px;font-weight:bold;letter-spacing:6px;color:#111827;margin:0">' +
182
+ issued.code +
183
+ '</p>' +
184
+ '<p style="color:#9ca3af;font-size:12px;margin-top:20px">It expires shortly. If you did not request it, ignore this email.</p>' +
185
+ '</td></tr></tbody></table>';
186
+ const status = EmailService.send(recipient, subject, text, purpose, html);
187
+ return new TwoFactorChallenge(issued.token, status);
188
+ }
189
+
190
+ /**
191
+ * Verify a `(token, code)` pair for `recipient`. Stateless: recomputes the
192
+ * HMAC from the token's `(purpose, exp)` + the supplied `(recipient, code)`
193
+ * and constant-time compares, after checking the version and expiry. Returns
194
+ * `true` only for a code that was issued for this recipient and has not
195
+ * expired. NOT single-use within the TTL (see the file header).
196
+ */
197
+ export function verify(token: string, recipient: string, code: string): bool {
198
+ const raw = base64UrlDecode(token);
199
+ if (raw == null) return false;
200
+
201
+ const r = new DataReader(raw);
202
+ if (r.readU8() != TWOFA_VERSION) return false;
203
+ const purpose = r.readString();
204
+ const exp = r.readU64();
205
+ const mac = r.readBytes();
206
+ if (!r.ok) return false; // truncated / malformed token
207
+
208
+ if (Time.nowSeconds() >= exp) return false; // expired
209
+
210
+ return hmacVerify(mac, canonicalMessage(recipient, purpose, exp, code));
211
+ }
212
+ }
@@ -7,6 +7,14 @@
7
7
 
8
8
  import { Request } from '../request';
9
9
 
10
+ // Host import: write the request's UNSPOOFABLE peer IP (the socket's remote
11
+ // address, NOT a client-supplied header) into a guest buffer. Returns the byte
12
+ // length written, 0 when there is no peer IP, or -1 if the buffer is too small.
13
+ // Backed by `client_ip_import.rs` on the edge and the dev-server host.
14
+ // @ts-ignore: decorator
15
+ @external('env', 'client_ip')
16
+ declare function __toilClientIp(ptr: usize, cap: i32): i32;
17
+
10
18
  export class RouteContext {
11
19
  /** The raw incoming request (method, path, headers, body). */
12
20
  request: Request;
@@ -55,6 +63,20 @@ export class RouteContext {
55
63
  return String.UTF8.decodeUnsafe(body.dataStart, body.byteLength);
56
64
  }
57
65
 
66
+ /**
67
+ * The connecting client's IP as a string ("203.0.113.7" or an IPv6 form),
68
+ * or "" if unavailable. This is the TRUSTED socket peer the edge observed,
69
+ * not a forgeable header like X-Forwarded-For, so it is safe to key rate
70
+ * limits, geo, or audit logs on it.
71
+ */
72
+ clientIp(): string {
73
+ const cap = 64; // max IPv6 text is 45 bytes; 64 leaves headroom
74
+ const buf = new Uint8Array(cap);
75
+ const n = __toilClientIp(buf.dataStart, cap);
76
+ if (n <= 0) return '';
77
+ return String.UTF8.decodeUnsafe(buf.dataStart, n);
78
+ }
79
+
58
80
  private ensureQuery(): void {
59
81
  if (this.queryKeys != null) return;
60
82
  const keys = new Array<string>();
package/src/cli/create.ts CHANGED
@@ -295,6 +295,17 @@ declare const Cookies: typeof import('toiljs/server/runtime/http/cookies').Cooki
295
295
  type Cookies = import('toiljs/server/runtime/http/cookies').Cookies;
296
296
  declare const SecureCookies: typeof import('toiljs/server/runtime/http/securecookies').SecureCookies;
297
297
  type SecureCookies = import('toiljs/server/runtime/http/securecookies').SecureCookies;
298
+ // Email, rate-limit, 2FA, and auth globals (server/globals/*), hand-declared
299
+ // because their AssemblyScript source can't be type-aliased from tsc.
300
+ declare enum EmailStatus { Sent, Disabled, Budget, RecipientCapped, Deduped, TryLater, BadRecipient, ProviderError }
301
+ declare namespace EmailService { function send(to: string, subject: string, body: string, purpose?: string, html?: string): EmailStatus; }
302
+ declare class RenderedEmail { subject: string; body: string; html: string; constructor(subject: string, body: string, html: string); }
303
+ declare class EmailTemplate { constructor(subject: string, body: string, html?: string); render(vars: Map<string, string>): RenderedEmail; send(to: string, vars: Map<string, string>, purpose?: string): EmailStatus; }
304
+ declare enum RateLimit { FixedWindow, SlidingWindow, TokenBucket }
305
+ declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }
306
+ declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }
307
+ declare namespace TwoFactor { function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }
308
+ declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }
298
309
  `;
299
310
 
300
311
  /**