toiljs 0.0.85 → 0.0.86
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 +5 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/generate.js +3 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +44 -5
- package/build/compiler/toil-docs.generated.js +6 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +7 -3
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +170 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/index.md +102 -0
- package/docs/auth/usage.md +188 -0
- package/docs/auth.md +6 -0
- package/examples/basic/client/routes/analytics.tsx +22 -12
- package/examples/basic/server/models/SiteAnalytics.ts +130 -17
- package/examples/basic/server/routes/Analytics.ts +68 -17
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +5 -1
- package/scripts/gen-toil-docs.mjs +15 -6
- package/server/auth/AuthController.ts +335 -0
- package/server/auth/AuthUser.ts +49 -0
- package/server/auth/index.ts +16 -0
- package/server/globals/auth.ts +31 -0
- package/server/globals/userid.ts +107 -0
- package/src/compiler/config.ts +13 -0
- package/src/compiler/generate.ts +3 -2
- package/src/compiler/index.ts +74 -5
- package/src/compiler/toil-docs.generated.ts +6 -1
- package/src/devserver/analytics/index.ts +10 -4
- package/test/analytics-dev.test.ts +2 -1
|
@@ -19,12 +19,17 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
19
19
|
"data.md": "# Data codec (`@data`)\n\n`@data` turns a plain class into a typed, versionable value with a deterministic\nbinary codec and a JSON codec. It is the backbone of request/response bodies,\nRPC arguments, sessions, and anything you persist. The same class becomes a\nfully typed client type in the generated `shared/server.ts` (see\n[RPC](./rpc.md)).\n\n```ts\n@data\nclass Player {\n username: string = '';\n admin: bool = false;\n score: u64 = 0;\n}\n```\n\nFrom that the compiler synthesizes, on the class:\n\n- `encode(): Uint8Array` / `static decode(buf): T`, the binary codec (with a\n 4-byte type id prefix).\n- `encodeInto(w: DataWriter)` / `static decodeFrom(r: DataReader)`, the codec\n without the type-id frame, for nesting.\n- `toJSON()` / `static fromJSON(v)`, the JSON codec (64-bit-and-larger integers\n as decimal strings, so they survive `JSON.parse` exactly).\n- `static dataId(): u32`, a stable FNV-1a hash of the class name, written as the\n type-id prefix by `encode()`.\n\nFields may be scalars (`u8`..`u256`, `i8`..`i256`, `f32`, `f64`, `bool`),\n`string`, a nested `@data` class, or an array `T[]` of any of these. Give every\nfield a default; the generated decoder and the client constructor use them.\n\n## Using `@data` in routes\n\nIn a **JSON** route, a `@data` parameter is revived from the parsed body and a\n`@data` return value is serialized with `toJSON()`. In a **Binary** route, the\nparameter is `decode`d from the raw body and the return value is `encode`d. The\nroute's stream mode (see [Routing](./routing.md#data-streams)) picks which.\n\n```ts\n@post('/') // JSON route\npublic create(input: NewPlayer): Player { /* input from JSON, Player to JSON */ }\n\n@route({ method: Methods.POST, path: '/blob', stream: DataStream.Binary })\npublic blob(input: FileData): FileResult { /* input.decode, result.encode */ }\n```\n\n## The binary codec: `DataWriter` / `DataReader`\n\nWhen you need to lay out bytes yourself, custom bodies, session payloads,\nchallenge messages, use the codec directly. It lives in the `data` module:\n\n```ts\nimport { DataWriter, DataReader } from 'data';\n```\n\nThe codec has a byte-for-byte identical TypeScript implementation in\n`toiljs/io` (`src/io/codec.ts`), so the client can read and write the exact same\nwire format the wasm guest does.\n\n### `DataWriter`\n\nEvery writer method returns the writer for chaining.\n\n| Method | Signature | Wire format |\n| --- | --- | --- |\n| `writeU8` / `writeI8` | `(v): DataWriter` | 1 byte |\n| `writeU16` / `writeI16` | `(v): DataWriter` | 2 bytes, little-endian |\n| `writeU32` / `writeI32` | `(v): DataWriter` | 4 bytes, LE |\n| `writeU64` / `writeI64` | `(v): DataWriter` | 8 bytes, LE |\n| `writeF32` / `writeF64` | `(v): DataWriter` | 4 / 8 bytes, IEEE-754 LE |\n| `writeBool` | `(v): DataWriter` | 1 byte (`1`/`0`) |\n| `writeBytes` | `(b: Uint8Array): DataWriter` | `u32` length (LE) + raw bytes |\n| `writeString` | `(s: string): DataWriter` | `u32` length (LE) + UTF-8 bytes |\n| `writeU128` / `writeI128` | `(v): DataWriter` | two `u64` limbs (lo, hi) |\n| `writeU256` / `writeI256` | `(v): DataWriter` | four `u64` limbs (lo1, lo2, hi1, hi2) |\n| `length` | `(): i32` | bytes written so far |\n| `toBytes` | `(): Uint8Array` | an exact-length copy of the buffer |\n\n### `DataReader`\n\nReads are bounds-safe: an over-read never traps. It returns a zero/empty default\nand sets the public `ok` flag to `false`. Check `ok` after a sequence of reads\nto detect a truncated or malformed buffer.\n\n| Method | Signature | On over-read |\n| --- | --- | --- |\n| `readU8` / `readI8` | `(): u8 / i8` | `0` |\n| `readU16`..`readU64`, `readI16`..`readI64` | `(): integer` | `0` |\n| `readF32` / `readF64` | `(): f32 / f64` | `0` |\n| `readBool` | `(): bool` | `false` |\n| `readBytes` | `(): Uint8Array` | empty array |\n| `readString` | `(): string` | `\"\"` |\n| `readU128`/`readI128`/`readU256`/`readI256` | `(): bignum` | `0` |\n| `remaining` | `(): i32` | bytes left unread |\n| `ok` | `bool` (field) | `false` once any read over-ran |\n\n### Example\n\n```ts\nimport { DataWriter, DataReader } from 'data';\n\n// Write: u8 version, str name, u64 score, bytes blob\nconst out = new DataWriter()\n .writeU8(1)\n .writeString('alice')\n .writeU64(1234)\n .writeBytes(payload)\n .toBytes();\n\n// Read it back\nconst r = new DataReader(out);\nconst version = r.readU8();\nconst name = r.readString();\nconst score = r.readU64();\nconst blob = r.readBytes();\nif (!r.ok) return Response.badRequest('truncated');\n```\n\n## Notes\n\n- **Endianness.** The AS guest codec is little-endian. The TypeScript `toiljs/io`\n codec defaults to little-endian and also accepts a per-call `be` flag for\n big-endian network formats; keep both ends on the same setting.\n- **Field order is the format.** The binary layout is exactly the field\n declaration order. Reordering fields, or changing a type, is a breaking format\n change. Add new fields at the end and bump a leading version byte if you need\n to evolve a hand-rolled payload.\n- **`encode()` carries a type id.** The 4-byte `dataId()` prefix lets a decoder\n confirm it is reading the type it expects. `encodeInto`/`decodeFrom` skip the\n frame for nesting one `@data` value inside another.\n",
|
|
20
20
|
"caching.md": "# Caching\n\ntoiljs can cache a response at the edge (shared, across users) and instruct the\nbrowser to cache it too. You opt in per route, either declaratively with the\n`@cache` decorator or imperatively with `Response.cache(...)`. The edge keys a\ncached entry by host, method, path, and body hash, and honors a per-entry TTL.\n\n## `@cache` decorator\n\nAnnotate a route method; the compiler appends the cache directive to whatever\n`Response` the route returns, so it composes with every return shape (a\n`Response`, a `void` 204, or an auto-encoded `@data` value).\n\n```ts\n@cache(60) // 60 minutes at the edge\n@cache(60, 300) // + 5 minutes (300s) in the browser\n@cache(60, 300, true) // + private scope (per-user caches only)\n@cache(60, 300, true, true) // + cache even for authenticated requests\n@get('/leaderboard')\npublic top(): Standings { /* … */ }\n```\n\nArguments must be integer or boolean literals; a non-literal argument makes the\ndecorator degrade safely to \"not cached\" rather than miscompile.\n\n## `Response.cache(...)`\n\nThe same controls are available imperatively, which is what `@cache` lowers to:\n\n```ts\npublic cache(\n edgeTtlMinutes: u16,\n browserTtlSeconds: u32 = 0,\n privateScope: bool = false,\n allowAuth: bool = false,\n): Response\n```\n\n```ts\nreturn Response.json(body).cache(60, 300);\n```\n\n`cacheFor(minutes)` is the common shorthand for \"edge only, no browser caching\":\n\n```ts\nreturn Response.bytes(blob).cacheFor(5);\n```\n\n## Parameters\n\n| Parameter | Meaning |\n| --- | --- |\n| `edgeTtlMinutes` | How long the edge may serve the cached response. Clamped to a 24-hour maximum. |\n| `browserTtlSeconds` | `max-age` for the browser. `0` (default) means the browser does not cache. |\n| `privateScope` | Marks the response `private`: only per-user caches (the browser), never a shared edge/CDN cache. |\n| `allowAuth` | Permit caching a response to an authenticated request. Off by default (see safety rails). |\n\n## Safety rails\n\nThe cache layer refuses to store anything unsafe, regardless of the directive:\n\n- **5xx** responses are never cached, a server error is transient, and `@cache`\n wraps the whole route, so a `@cache`d route that hits a blip returns its 500\n carrying the directive; caching it would serve the failure for the full TTL.\n **2xx, 3xx, and 4xx are cacheable** (a redirect or a `404`/`410` is a\n deterministic function of the request key);\n- a response that sets a **`Set-Cookie`** is never cached;\n- a response to an **authenticated** request is not cached unless you pass\n `allowAuth = true`, this prevents one user's personalized response from being\n served to another;\n- the edge TTL is **clamped to 24 hours**.\n\nBecause `@auth` guards and body-decode run before the cache directive is applied,\nan unauthorized request is rejected with 401 before anything is cached, and a\ncached entry is only ever produced from a handler that actually ran.\n\nCaching is **always opt-in.** A response with no `Dacely-Cache-Control` directive\n(i.e. no `@cache` / `Response.cache(...)`) is never stored, there is no blind\n\"cache every GET\" mode, because an automatic window cannot tell a personalized\nresponse from a public one and would key it without a per-user component.\n\n## Memory bounds and disk spill\n\nThe edge cache is per-core and hard-capped so it can never exhaust node memory.\nIt has two tiers:\n\n- **RAM tier**, small, short-TTL responses. Bounded by a per-core byte budget\n (each core holds at most ~128 MB) plus an entry-count cap; an insert that would\n exceed the budget drops expired entries first, then evicts the soonest-to-expire\n ones. A response over ~256 KB does not go in the RAM tier.\n- **Disk tier (spill)**, when the operator enables `--spill-dir`, a **big**\n (over the ~256 KB RAM cap) or **long-TTL** (≥ 10 min) cacheable response is\n written to disk instead and served back zero-RAM via a memory map, the same way\n static files are served. This keeps the RAM tier for the hot working set while\n still caching large bodies and long-lived entries. Writes (and unlinks) are\n offloaded to a sibling thread so they never stall the request path; a separate\n per-core disk budget caps total spilled bytes, with the same expiry + eviction.\n If spill is not enabled, a big response is simply not cached (reported as not\n stored by the `Dacely-Cache` tag).\n\nFrom a tenant's point of view nothing changes: you still just set a\n`Dacely-Cache-Control` directive (via `@cache` / `Response.cache(...)`). The edge\ndecides RAM vs disk; both honor the same TTL and the same safety rails above.\nExpiry is enforced on read (a past-TTL entry is a miss) and reclaimed on the next\ninsert that needs room. Nothing persists across a process restart.\n\n## Choosing TTLs\n\n- Public, slow-changing data (a leaderboard, a catalog): a few minutes of edge\n TTL plus a short browser TTL removes most of the load.\n- Per-user data: set `privateScope` so it never lands in a shared cache, and\n prefer a small or zero edge TTL.\n- Anything with a `Set-Cookie` or behind `@auth`: leave it uncached unless you\n have thought through `allowAuth` and are certain the body is identical for\n every authorized caller.\n",
|
|
21
21
|
"ratelimit.md": "# Rate limiting\n\nThe `@ratelimit` decorator throttles **any** `@rest` route, a login, a signup, a\npublic API, an email trigger, anything. It is enforced at the edge, before your\nhandler runs, and keyed by default on the connecting client's **unspoofable** IP,\nso it works as an abuse / brute-force control out of the box.\n\nIt composes with the other route decorators and is independent of email or auth.\n\n## Using `@ratelimit`\n\nAdd it to a route alongside the verb decorator:\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('auth')\nclass Auth {\n // At most 5 login attempts per 60 seconds per client IP.\n @ratelimit(RateLimit.SlidingWindow, 5, 60)\n @post('/login')\n login(ctx: RouteContext): Response {\n // ... only runs if under the limit ...\n return Response.text('ok\\n');\n }\n}\n```\n\n`@ratelimit(strategy, limit, window)`:\n\n- **`strategy`**, a `RateLimit` value (ambient global, no import):\n `RateLimit.FixedWindow`, `RateLimit.SlidingWindow`, or `RateLimit.TokenBucket`.\n- **`limit`** and **`window`**, integer literals whose meaning depends on the\n strategy (see below).\n\nWhen a request is over the limit the edge returns **`429 Too Many Requests`**\nwith a **`Retry-After`** header (whole seconds), and your handler never runs. The\nguard runs **before `@auth`**, so unauthenticated floods are limited too.\n\n> Both arguments must be **integer literals** and the strategy a `RateLimit`\n> member (or a bare integer tag). A malformed decorator emits no guard rather\n> than miscompiling, the same fail-safe rule as `@cache`.\n\n## Strategies\n\n| Strategy | `limit`, `window` mean | Behavior |\n| --- | --- | --- |\n| `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. |\n| `SlidingWindow` | `limit` events per `window` seconds | Smooths the fixed-window boundary by weighting the previous window. Best general choice for \"N per period\". |\n| `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. |\n\nExamples:\n\n```ts\n// 100 requests / minute, smoothed:\n@ratelimit(RateLimit.SlidingWindow, 100, 60)\n\n// Burst of 20, then 5 per second sustained:\n@ratelimit(RateLimit.TokenBucket, 20, 5)\n\n// Exactly 3 per hour, cheapest:\n@ratelimit(RateLimit.FixedWindow, 3, 3600)\n```\n\n## How requests are keyed\n\nBy default the limiter keys on the **client IP**, specifically the TCP peer\naddress the edge observed (`ctx.clientIp()`), **not** a header like\n`X-Forwarded-For`, which a client can forge. That makes it a real abuse control:\na caller can't reset their bucket by spoofing a header.\n\nThe count is **exact across all 14 edge workers** (a given IP always maps to one\nauthoritative shard), so the limit is global per route, not per worker. Only\nroutes that opt in with `@ratelimit` ever pay anything, the lock-free fast path\nfor everything else is untouched.\n\n> Each rate-limited route has its own independent limiter, a limit on `/login`\n> does not consume the budget of `/signup`.\n\n## Notes and limits\n\n- **Route-level only.** Put `@ratelimit` on each route you want limited; there is\n no controller-wide form yet (unlike `@auth`).\n- **Keyed on IP.** The decorator keys on the peer IP today. (A per-user / custom\n key, limiting by account instead of IP, exists in the runtime but is not yet\n exposed through the decorator.)\n- **In dev.** `toiljs dev` runs a single-process mirror of the same three\n strategies, so a limited route behaves the same locally as on the edge.\n\n## See also\n\n- [Email](./email.md), `@ratelimit` pairs well with email triggers (verification\n codes, password resets) to blunt abuse.\n- [Auth, sessions, and `@user`](./auth.md), `@ratelimit` runs before the `@auth`\n guard, so it protects the login itself.\n",
|
|
22
|
-
"auth.md": "# Auth, sessions, and `@user`\n\ntoiljs ships **Toil PQ-Auth**: a post-quantum password login where the password\nnever leaves the browser and the server stores only public verifier material.\nOn top of it sit HMAC-signed session cookies, a `@auth` route guard, and a\n`@user` type that makes the signed-in user available - fully typed, no type\nargument - on both the server (`AuthService.getUser()`) and the generated client\n(`getUser()`).\n\n> **Status.** PQ-Auth is a hybrid construction (see [What it is](#what-it-is)).\n> It is opt-in and **not hardened to production yet** - the example storage is a\n> dev stand-in, the secrets are dev placeholders, and the composition has not had\n> an external cryptographic review. See [`docs/auth-todo.md`](./auth-todo.md) for\n> the remaining work before it backs real credentials.\n\n`AuthService` is an ambient global (no import). The pieces:\n\n- **`@user`** - declares the authenticated user's shape and registers it as *the* user type.\n- **`@auth`** - guards a route (or a whole `@rest` class): a valid session is required or `401`.\n- **`AuthService`** - the server runtime: the PQ-Auth crypto, plus mint/read/clear a session and `getUser()`.\n- **client `Auth` + generated `getUser()`** - run the login from the browser and read the user for display.\n\n---\n\n## What it is\n\nA password is a weak, low-entropy secret. PQ-Auth turns it into a strong,\n**post-quantum** credential and proves possession to the server without the\nserver (or anyone on the wire, or a future quantum adversary) ever seeing\nanything they can replay. It is built from three independent ideas, each\ndefending a specific attack:\n\n| Layer | Primitive | Defends against |\n| --- | --- | --- |\n| **Keyed salt** | OPRF (RFC 9497, ristretto255-SHA512) | A breached server (or a passive observer) **precomputing** a password dictionary. |\n| **Credential** | Argon2id → ML-DSA-44 (FIPS 204) keypair | The password ever crossing the wire; a stolen verifier being usable without an expensive per-guess attack. |\n| **Mutual auth + key** | ML-KEM-768 (FIPS 203) | A phishing/MITM server impersonating the real one; a session with no key to bind to. |\n\nThe password is stretched into a signing keypair entirely client-side; only the\n**public** key is registered. Login is a challenge-response signature *plus* a\nkey encapsulation, so both parties authenticate each other. Authentication\n(ML-DSA) and key agreement (ML-KEM) are post-quantum; the keyed-salt OPRF is\nclassical ristretto255 (the one non-PQ layer - a quantum break of it degrades to\na post-breach offline attack, no worse than a plain salt, while defeating\nprecomputation for everyone else).\n\n### Why a keyed salt (the OPRF)\n\nA normal salted hash (`Argon2id(password, salt)`) lets anyone who learns the\nsalt - including a future attacker who simply asks the login endpoint for it -\n**precompute** a dictionary offline and crack the stored verifier the instant\nthey breach it. PQ-Auth replaces the salt with the output of a **server-keyed**\nOPRF:\n\n```\noprfOutput = OPRF_finalize(password, OPRF_evaluate(k_user, blind(password)))\nseed = Argon2id(oprfOutput, salt)\n```\n\nThe client **blinds** the password (so the server learns nothing about it),\nthe server **evaluates** the blinded element under a per-user key `k_user`\nderived from a server-secret master seed, and the client **unblinds** to recover\na deterministic, high-entropy `oprfOutput`. Because `k_user` is a server secret,\n**no offline work is possible until that secret leaks** - precomputation is\nimpossible, and even a passive observer who captures a login learns nothing.\nThe per-user key (`k_user = DeriveKeyPair(masterSeed, username)`) means two\naccounts with the same password get different outputs - no cross-account\npassword-equality leak.\n\n### Why a password-derived signing key\n\n`seed = Argon2id(oprfOutput, salt)` deterministically expands into an\n**ML-DSA-44 keypair**. The client registers only the 1312-byte **public** key;\nthe secret key and seed are zeroized the instant signing is done. The server\nstores the public key as a verifier and can only ever *verify* - it never holds\na secret (`crypto.mldsa_verify` is verify-only on the edge). A full server breach\nyields public keys, not passwords; recovering a password still requires an\noffline Argon2id dictionary attack **and** the leaked OPRF master seed.\n\n### Why ML-KEM (mutual auth + session key)\n\nA signature proves the *client* to the server, but nothing proves the *server*\nto the client. PQ-Auth pins the server's static **ML-KEM-768 public key** in the\nclient. At login the client **encapsulates** a shared secret to that key; only\nthe genuine server (holding the matching secret key) can **decapsulate** it. Both\nsides derive the same session key and the server returns a confirmation tag the\nclient checks - so a phishing/MITM server that lacks the secret key cannot\ncomplete the handshake.\n\n---\n\n## Flow at a glance\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant C as Browser\n participant S as Edge wasm\n participant DB as Your store\n\n rect rgb(14, 21, 32)\n Note over U,DB: Register, password never leaves the browser\n U->>C: Auth.register(username, password)\n C->>S: POST /auth/register/start (username, blinded)\n S->>S: OPRF-evaluate under k_user, issue salt and KDF params\n S-->>C: salt, params, evaluated\n C->>C: finalize OPRF, Argon2id, ML-DSA-44 keypair, sign PoP\n C->>S: POST /auth/register/finish (username, publicKey, regProof)\n S->>S: verifyRegister(publicKey, PoP)\n S->>DB: store Account (username, salt, params, publicKey)\n S-->>C: ok\n end\n\n rect rgb(22, 15, 31)\n Note over U,DB: Login, mutual authentication\n U->>C: Auth.login(username, password)\n C->>S: POST /auth/login/start (username, blinded)\n S->>DB: store challenge (cid, nonce, iat, exp)\n S-->>C: cid, aud, salt, params, nonce, iat, exp, evaluated\n C->>C: finalize OPRF, derive seed, ML-DSA keypair, ML-KEM encapsulate, sign M\n C->>S: POST /auth/login/finish (cid, ct, signature)\n S->>DB: atomic consume challenge(cid)\n S->>S: rebuild M, verifyLogin, decapsulate, derive K, build confirm\n alt signature valid\n S-->>C: ok, sessionToken, serverConfirm, Set-Cookie\n C->>C: re-derive K, check serverConfirm, server authenticated\n else invalid or unknown user\n S-->>C: 401 generic, anti-enumeration\n end\n end\n\n rect rgb(13, 25, 18)\n Note over U,DB: Guarded request, the @auth guard\n U->>C: open or call an @auth route\n C->>S: request, cookies sent automatically\n S->>S: @auth verifies HMAC and expiry on __Host-toil_sess\n alt valid session\n S->>S: handler runs, AuthService.getUser() returns the @user\n S-->>C: 200\n else missing or invalid\n S-->>C: 401 before handler and body-decode\n end\n end\n```\n\n### The signed transcript\n\nThe login message `M` the client signs (and the server rebuilds from its own\nstored values) is a single fixed binary layout - no JSON, no version negotiation:\n\n```\nu8 tag = 1\nstr sub (username)\nstr aud (service audience; server constant)\nbytes cid (challenge id)\nbytes nonce (32 random bytes, server-issued)\nu64 iat, u64 exp (challenge validity window)\nbytes ct (ML-KEM ciphertext)\nu32 memKiB, iterations, parallelism (Argon2id params)\nbytes serverKemKeyId (SHA-256 of the server KEM public key)\n```\n\nSigning over all of this binds the login to: the exact challenge (so it can't be\nreplayed - and `cid` is consumed atomically), the **ciphertext** (so a MITM can't\nswap the key encapsulation), the **KDF params** (so a downgrade can't be slipped\npast the signature), and the **server key identity** (so it commits to which\nserver key was used). The mutual-auth tag is then:\n\n```\nK = HMAC-SHA256(sharedSecret, \"toil-session-key-v1\" || SHA-256(M))\nconfirm = HMAC-SHA256(K, \"toil-server-confirm-v1\" || SHA-256(M))\n```\n\n`K` is the authenticated session key, derived from the KEM shared secret and\nbound to the transcript. Only a server that decapsulated correctly derives the\nsame `K`, so the client checking `confirm` proves the server's identity. (`K` is\nthe handle for future channel binding; binding the session *cookie* to the\ntransport needs the TLS exporter, which the wasm guest can't see - a follow-up.)\n\n### Anti-enumeration\n\n`login/start` returns a fully-formed response for **every** username: it always\nOPRF-evaluates (a real `k_user` for known users, a deterministic decoy key for\nunknown ones) and returns a **deterministic per-user salt** and constant params.\nKnown and unknown users are byte-indistinguishable, and the eventual signature\nsimply fails for a non-account. Failures return one generic `401`.\n\n---\n\n## `@user`\n\nMark one class per program as the user type. It becomes a `@data` codec (so it\nserializes into the session) and the return type of `getUser()` everywhere.\n\n```ts\n@user\nclass Account {\n username: string = '';\n admin: bool = false;\n score: u64 = 0;\n}\n```\n\nThere is exactly one `@user` per program; a second is a compile error.\n\n## `@auth`\n\nPut `@auth` on a route, or on the `@rest` class to guard every route in it. The\ngenerated dispatcher checks for a valid, unexpired session **before** the handler\nruns (and before any body-decode or cache write); without one it returns `401`.\n\n```ts\n@rest('session')\nclass Session {\n @auth\n @get('/me')\n public me(): Response {\n const u = AuthService.getUser(); // Account | null, auto-typed\n if (u == null) return Response.text('no session\\n', 401);\n return Response.bytes(new DataWriter()\n .writeString(u.username).writeBool(u.admin).writeU64(u.score).toBytes());\n }\n}\n```\n\n`@auth` on the class form guards all routes in it.\n\n## `AuthService` (server)\n\nA global namespace. Session methods read the ambient request\n(`Server.currentRequest`), so `getUser()`/`hasSession()` take no argument and are\nonly meaningful during a dispatch.\n\n### PQ-Auth crypto\n\nStartup config (call once in `main.ts`; identical on every edge instance; never\nin a client bundle):\n\n| Member | Notes |\n| --- | --- |\n| `setSecret(secret)` | HMAC secret for session cookies. |\n| `setOprfSeed(seed)` | 32-byte OPRF master seed; per-user keys derive from this + the username. |\n| `setServerKemSecretKey(sk)` | Server static ML-KEM-768 secret key (2400 B) used to decapsulate. |\n| `setServerKemPublicKey(pk)` | The matching public key (1184 B) for `serverKemKeyId`; it is embedded in `sk` at bytes `[1152, 2336)`, so you can pass `sk.slice(1152, 2336)`. |\n\nPer-request building blocks:\n\n| Member | Notes |\n| --- | --- |\n| `oprfEvaluate(username, blinded)` | OPRF server step: blind-evaluate under `k_user` derived from the seed + username. Returns the 32-byte evaluated element. |\n| `mlkemDecapsulate(ct)` | Recover the 32-byte shared secret from the client ciphertext with the server secret key. |\n| `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)` | The canonical login message `M`. Call it with the server's **own** stored values, never client-echoed fields. |\n| `verifyLogin(publicKey, message, signature)` | Verify the ML-DSA login signature under `LOGIN_CONTEXT`. |\n| `serverKemKeyId()` | `SHA-256(serverKemPublicKey)` - the key id bound into `M`. |\n| `sha256(data)` | SHA-256, for the transcript hash. |\n| `deriveSessionKey(sharedSecret, transcriptHash)` | `K = HMAC(sharedSecret, SESSION_KEY_LABEL || transcriptHash)`. |\n| `serverConfirmTag(sessionKey, transcriptHash)` | The mutual-auth tag `HMAC(K, SERVER_CONFIRM_LABEL || transcriptHash)`. |\n| `buildRegisterMessage(username, publicKey)` / `verifyRegister(...)` | Registration proof-of-possession (under `REGISTER_CONTEXT`). |\n| `LOGIN_CONTEXT` / `REGISTER_CONTEXT` | `qauth:login:v1` / `qauth:register:v1` - FIPS 204 signing contexts. |\n| `PUBLIC_KEY_LEN` `SIGNATURE_LEN` `KEM_*` `SHARED_SECRET_LEN` `OPRF_*` | Fixed sizes. |\n\nThe full register/login orchestration (the four binary endpoints, the\nanti-enumeration decoy, the atomic challenge-consume) is in\n`examples/basic/server/routes/Auth.ts`. **Storage is the app's** - a tenant's\nwasm memory is wiped per request, so accounts and challenges live in an external\nstore, and challenge-consume **must** be an atomic fetch-and-delete (a\nread-then-delete race makes a captured login replayable). The example uses a\n**dev-only** KV for this; production wires toildb (see `docs/auth-todo.md`).\n\n### Sessions\n\n| Member | Signature | Notes |\n| --- | --- | --- |\n| `getUser()` | `(): AuthUser \\| null` | The signed-in user, decoded from the verified session, auto-typed to your `@user`. |\n| `hasSession()` | `(): bool` | Whether the request carries a valid, unexpired session. What `@auth` calls. |\n| `mintSession(userData, ttlSecs?)` | `(Uint8Array, u64=86400): Cookie` | Signed `__Host-toil_sess` cookie carrying `user.encode()`. HttpOnly, Secure, SameSite=Lax. |\n| `clearSession()` / `userCookie(...)` / `clearUserCookie()` | | Logout; the readable `__Secure-toil_user` companion (display-only); clear it. |\n\nThe session payload is `u8 version || u64 iat || u64 exp || bytes userData`, sealed\nwith HMAC-SHA256. The HttpOnly `__Host-toil_sess` is the **only** cookie the\nserver trusts; the readable `__Secure-toil_user` exists solely so the client\n`getUser()` can show a name without a round-trip and must never gate anything.\n\n## The client half\n\n```ts\nimport { Auth } from 'toiljs/client';\n\nawait Auth.register(username, password); // OPRF + Argon2id + ML-DSA keypair, send only the public key + PoP\nawait Auth.login(username, password); // + ML-KEM encapsulate; resolves only if the server's confirm tag verifies\n```\n\n`login` resolves **only after** the client verifies the server's confirmation tag\n- so a resolved `login` means mutual authentication succeeded. The secret key,\nseed, and shared secret are zeroized as soon as they are used. There is no\nrecovery: the password *is* the key (see `docs/auth-todo.md` for the recovery\nwork).\n\nThe generated `shared/server.ts` also exports a typed, no-argument client\n`getUser()` that reads the readable companion cookie. It is **display-only and\nuntrusted** - a client can forge it, fooling only its own UI. The server\nre-verifies the signed session on every `@auth` request, so authorization never\ndepends on the readable cookie.\n\n## Security checklist\n\n- Set real secrets in `main.ts`: `setSecret`, `setOprfSeed`, and the server KEM\n keypair - per-deployment, identical on every instance, never in a client\n bundle. The defaults are insecure DEV placeholders.\n- Pin **your** server KEM public key in the client and rotate it; the example\n ships a throwaway dev keypair.\n- Use a production Argon2id cost (≥ 256 MiB, ≥ 3 iterations); the demo is tuned\n for browser responsiveness.\n- Back accounts/challenges with a shared store and make challenge-consume atomic.\n- Rate-limit `register` and `login` (online guessing is not stopped by the\n offline-attack resistance).\n- Always verify server-side. The server `getUser()` decodes a verified,\n expiry-checked session; the client `getUser()` does not and must not gate\n anything.\n- This is an unreviewed hybrid composition - get a cryptographic review before it\n backs real credentials. Tracked in [`docs/auth-todo.md`](./auth-todo.md).\n",
|
|
22
|
+
"auth.md": "# Auth, sessions, and `@user`\n\n> **Just want login working?** Enable **built-in auth** with one line — `server: { auth: true }` — and\n> you get the whole `/auth/*` API + sessions with no code. Start at **[the auth guide](./auth/index.md)**\n> ([how it works](./auth/how-it-works.md) · [usage](./auth/usage.md) · [configuration](./auth/configuration.md) ·\n> [extending](./auth/extending.md)). This page is the deeper reference on the underlying primitives, used\n> both by built-in auth and when you hand-write your own.\n\ntoiljs ships **Toil PQ-Auth**: a post-quantum password login where the password\nnever leaves the browser and the server stores only public verifier material.\nOn top of it sit HMAC-signed session cookies, a `@auth` route guard, and a\n`@user` type that makes the signed-in user available - fully typed, no type\nargument - on both the server (`AuthService.getUser()`) and the generated client\n(`getUser()`).\n\n> **Status.** PQ-Auth is a hybrid construction (see [What it is](#what-it-is)).\n> It is opt-in and **not hardened to production yet** - the example storage is a\n> dev stand-in, the secrets are dev placeholders, and the composition has not had\n> an external cryptographic review. See [`docs/auth-todo.md`](./auth-todo.md) for\n> the remaining work before it backs real credentials.\n\n`AuthService` is an ambient global (no import). The pieces:\n\n- **`@user`** - declares the authenticated user's shape and registers it as *the* user type.\n- **`@auth`** - guards a route (or a whole `@rest` class): a valid session is required or `401`.\n- **`AuthService`** - the server runtime: the PQ-Auth crypto, plus mint/read/clear a session and `getUser()`.\n- **client `Auth` + generated `getUser()`** - run the login from the browser and read the user for display.\n\n---\n\n## What it is\n\nA password is a weak, low-entropy secret. PQ-Auth turns it into a strong,\n**post-quantum** credential and proves possession to the server without the\nserver (or anyone on the wire, or a future quantum adversary) ever seeing\nanything they can replay. It is built from three independent ideas, each\ndefending a specific attack:\n\n| Layer | Primitive | Defends against |\n| --- | --- | --- |\n| **Keyed salt** | OPRF (RFC 9497, ristretto255-SHA512) | A breached server (or a passive observer) **precomputing** a password dictionary. |\n| **Credential** | Argon2id → ML-DSA-44 (FIPS 204) keypair | The password ever crossing the wire; a stolen verifier being usable without an expensive per-guess attack. |\n| **Mutual auth + key** | ML-KEM-768 (FIPS 203) | A phishing/MITM server impersonating the real one; a session with no key to bind to. |\n\nThe password is stretched into a signing keypair entirely client-side; only the\n**public** key is registered. Login is a challenge-response signature *plus* a\nkey encapsulation, so both parties authenticate each other. Authentication\n(ML-DSA) and key agreement (ML-KEM) are post-quantum; the keyed-salt OPRF is\nclassical ristretto255 (the one non-PQ layer - a quantum break of it degrades to\na post-breach offline attack, no worse than a plain salt, while defeating\nprecomputation for everyone else).\n\n### Why a keyed salt (the OPRF)\n\nA normal salted hash (`Argon2id(password, salt)`) lets anyone who learns the\nsalt - including a future attacker who simply asks the login endpoint for it -\n**precompute** a dictionary offline and crack the stored verifier the instant\nthey breach it. PQ-Auth replaces the salt with the output of a **server-keyed**\nOPRF:\n\n```\noprfOutput = OPRF_finalize(password, OPRF_evaluate(k_user, blind(password)))\nseed = Argon2id(oprfOutput, salt)\n```\n\nThe client **blinds** the password (so the server learns nothing about it),\nthe server **evaluates** the blinded element under a per-user key `k_user`\nderived from a server-secret master seed, and the client **unblinds** to recover\na deterministic, high-entropy `oprfOutput`. Because `k_user` is a server secret,\n**no offline work is possible until that secret leaks** - precomputation is\nimpossible, and even a passive observer who captures a login learns nothing.\nThe per-user key (`k_user = DeriveKeyPair(masterSeed, username)`) means two\naccounts with the same password get different outputs - no cross-account\npassword-equality leak.\n\n### Why a password-derived signing key\n\n`seed = Argon2id(oprfOutput, salt)` deterministically expands into an\n**ML-DSA-44 keypair**. The client registers only the 1312-byte **public** key;\nthe secret key and seed are zeroized the instant signing is done. The server\nstores the public key as a verifier and can only ever *verify* - it never holds\na secret (`crypto.mldsa_verify` is verify-only on the edge). A full server breach\nyields public keys, not passwords; recovering a password still requires an\noffline Argon2id dictionary attack **and** the leaked OPRF master seed.\n\n### Why ML-KEM (mutual auth + session key)\n\nA signature proves the *client* to the server, but nothing proves the *server*\nto the client. PQ-Auth pins the server's static **ML-KEM-768 public key** in the\nclient. At login the client **encapsulates** a shared secret to that key; only\nthe genuine server (holding the matching secret key) can **decapsulate** it. Both\nsides derive the same session key and the server returns a confirmation tag the\nclient checks - so a phishing/MITM server that lacks the secret key cannot\ncomplete the handshake.\n\n---\n\n## Flow at a glance\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant C as Browser\n participant S as Edge wasm\n participant DB as Your store\n\n rect rgb(14, 21, 32)\n Note over U,DB: Register, password never leaves the browser\n U->>C: Auth.register(username, password)\n C->>S: POST /auth/register/start (username, blinded)\n S->>S: OPRF-evaluate under k_user, issue salt and KDF params\n S-->>C: salt, params, evaluated\n C->>C: finalize OPRF, Argon2id, ML-DSA-44 keypair, sign PoP\n C->>S: POST /auth/register/finish (username, publicKey, regProof)\n S->>S: verifyRegister(publicKey, PoP)\n S->>DB: store Account (username, salt, params, publicKey)\n S-->>C: ok\n end\n\n rect rgb(22, 15, 31)\n Note over U,DB: Login, mutual authentication\n U->>C: Auth.login(username, password)\n C->>S: POST /auth/login/start (username, blinded)\n S->>DB: store challenge (cid, nonce, iat, exp)\n S-->>C: cid, aud, salt, params, nonce, iat, exp, evaluated\n C->>C: finalize OPRF, derive seed, ML-DSA keypair, ML-KEM encapsulate, sign M\n C->>S: POST /auth/login/finish (cid, ct, signature)\n S->>DB: atomic consume challenge(cid)\n S->>S: rebuild M, verifyLogin, decapsulate, derive K, build confirm\n alt signature valid\n S-->>C: ok, sessionToken, serverConfirm, Set-Cookie\n C->>C: re-derive K, check serverConfirm, server authenticated\n else invalid or unknown user\n S-->>C: 401 generic, anti-enumeration\n end\n end\n\n rect rgb(13, 25, 18)\n Note over U,DB: Guarded request, the @auth guard\n U->>C: open or call an @auth route\n C->>S: request, cookies sent automatically\n S->>S: @auth verifies HMAC and expiry on __Host-toil_sess\n alt valid session\n S->>S: handler runs, AuthService.getUser() returns the @user\n S-->>C: 200\n else missing or invalid\n S-->>C: 401 before handler and body-decode\n end\n end\n```\n\n### The signed transcript\n\nThe login message `M` the client signs (and the server rebuilds from its own\nstored values) is a single fixed binary layout - no JSON, no version negotiation:\n\n```\nu8 tag = 1\nstr sub (username)\nstr aud (service audience; server constant)\nbytes cid (challenge id)\nbytes nonce (32 random bytes, server-issued)\nu64 iat, u64 exp (challenge validity window)\nbytes ct (ML-KEM ciphertext)\nu32 memKiB, iterations, parallelism (Argon2id params)\nbytes serverKemKeyId (SHA-256 of the server KEM public key)\n```\n\nSigning over all of this binds the login to: the exact challenge (so it can't be\nreplayed - and `cid` is consumed atomically), the **ciphertext** (so a MITM can't\nswap the key encapsulation), the **KDF params** (so a downgrade can't be slipped\npast the signature), and the **server key identity** (so it commits to which\nserver key was used). The mutual-auth tag is then:\n\n```\nK = HMAC-SHA256(sharedSecret, \"toil-session-key-v1\" || SHA-256(M))\nconfirm = HMAC-SHA256(K, \"toil-server-confirm-v1\" || SHA-256(M))\n```\n\n`K` is the authenticated session key, derived from the KEM shared secret and\nbound to the transcript. Only a server that decapsulated correctly derives the\nsame `K`, so the client checking `confirm` proves the server's identity. (`K` is\nthe handle for future channel binding; binding the session *cookie* to the\ntransport needs the TLS exporter, which the wasm guest can't see - a follow-up.)\n\n### Anti-enumeration\n\n`login/start` returns a fully-formed response for **every** username: it always\nOPRF-evaluates (a real `k_user` for known users, a deterministic decoy key for\nunknown ones) and returns a **deterministic per-user salt** and constant params.\nKnown and unknown users are byte-indistinguishable, and the eventual signature\nsimply fails for a non-account. Failures return one generic `401`.\n\n---\n\n## `@user`\n\nMark one class per program as the user type. It becomes a `@data` codec (so it\nserializes into the session) and the return type of `getUser()` everywhere.\n\n```ts\n@user\nclass Account {\n username: string = '';\n admin: bool = false;\n score: u64 = 0;\n}\n```\n\nThere is exactly one `@user` per program; a second is a compile error.\n\n## `@auth`\n\nPut `@auth` on a route, or on the `@rest` class to guard every route in it. The\ngenerated dispatcher checks for a valid, unexpired session **before** the handler\nruns (and before any body-decode or cache write); without one it returns `401`.\n\n```ts\n@rest('session')\nclass Session {\n @auth\n @get('/me')\n public me(): Response {\n const u = AuthService.getUser(); // Account | null, auto-typed\n if (u == null) return Response.text('no session\\n', 401);\n return Response.bytes(new DataWriter()\n .writeString(u.username).writeBool(u.admin).writeU64(u.score).toBytes());\n }\n}\n```\n\n`@auth` on the class form guards all routes in it.\n\n## `AuthService` (server)\n\nA global namespace. Session methods read the ambient request\n(`Server.currentRequest`), so `getUser()`/`hasSession()` take no argument and are\nonly meaningful during a dispatch.\n\n### PQ-Auth crypto\n\nStartup config (call once in `main.ts`; identical on every edge instance; never\nin a client bundle):\n\n| Member | Notes |\n| --- | --- |\n| `setSecret(secret)` | HMAC secret for session cookies. |\n| `setOprfSeed(seed)` | 32-byte OPRF master seed; per-user keys derive from this + the username. |\n| `setServerKemSecretKey(sk)` | Server static ML-KEM-768 secret key (2400 B) used to decapsulate. |\n| `setServerKemPublicKey(pk)` | The matching public key (1184 B) for `serverKemKeyId`; it is embedded in `sk` at bytes `[1152, 2336)`, so you can pass `sk.slice(1152, 2336)`. |\n\nPer-request building blocks:\n\n| Member | Notes |\n| --- | --- |\n| `oprfEvaluate(username, blinded)` | OPRF server step: blind-evaluate under `k_user` derived from the seed + username. Returns the 32-byte evaluated element. |\n| `mlkemDecapsulate(ct)` | Recover the 32-byte shared secret from the client ciphertext with the server secret key. |\n| `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)` | The canonical login message `M`. Call it with the server's **own** stored values, never client-echoed fields. |\n| `verifyLogin(publicKey, message, signature)` | Verify the ML-DSA login signature under `LOGIN_CONTEXT`. |\n| `serverKemKeyId()` | `SHA-256(serverKemPublicKey)` - the key id bound into `M`. |\n| `sha256(data)` | SHA-256, for the transcript hash. |\n| `deriveSessionKey(sharedSecret, transcriptHash)` | `K = HMAC(sharedSecret, SESSION_KEY_LABEL || transcriptHash)`. |\n| `serverConfirmTag(sessionKey, transcriptHash)` | The mutual-auth tag `HMAC(K, SERVER_CONFIRM_LABEL || transcriptHash)`. |\n| `buildRegisterMessage(username, publicKey)` / `verifyRegister(...)` | Registration proof-of-possession (under `REGISTER_CONTEXT`). |\n| `LOGIN_CONTEXT` / `REGISTER_CONTEXT` | `qauth:login:v1` / `qauth:register:v1` - FIPS 204 signing contexts. |\n| `PUBLIC_KEY_LEN` `SIGNATURE_LEN` `KEM_*` `SHARED_SECRET_LEN` `OPRF_*` | Fixed sizes. |\n\nThe full register/login orchestration (the four binary endpoints, the\nanti-enumeration decoy, the atomic challenge-consume) is in\n`examples/basic/server/routes/Auth.ts`. **Storage is the app's** - a tenant's\nwasm memory is wiped per request, so accounts and challenges live in an external\nstore, and challenge-consume **must** be an atomic fetch-and-delete (a\nread-then-delete race makes a captured login replayable). The example uses a\n**dev-only** KV for this; production wires toildb (see `docs/auth-todo.md`).\n\n### Sessions\n\n| Member | Signature | Notes |\n| --- | --- | --- |\n| `getUser()` | `(): AuthUser \\| null` | The signed-in user, decoded from the verified session, auto-typed to your `@user`. |\n| `hasSession()` | `(): bool` | Whether the request carries a valid, unexpired session. What `@auth` calls. |\n| `mintSession(userData, ttlSecs?)` | `(Uint8Array, u64=86400): Cookie` | Signed `__Host-toil_sess` cookie carrying `user.encode()`. HttpOnly, Secure, SameSite=Lax. |\n| `clearSession()` / `userCookie(...)` / `clearUserCookie()` | | Logout; the readable `__Secure-toil_user` companion (display-only); clear it. |\n\nThe session payload is `u8 version || u64 iat || u64 exp || bytes userData`, sealed\nwith HMAC-SHA256. The HttpOnly `__Host-toil_sess` is the **only** cookie the\nserver trusts; the readable `__Secure-toil_user` exists solely so the client\n`getUser()` can show a name without a round-trip and must never gate anything.\n\n## The client half\n\n```ts\nimport { Auth } from 'toiljs/client';\n\nawait Auth.register(username, password); // OPRF + Argon2id + ML-DSA keypair, send only the public key + PoP\nawait Auth.login(username, password); // + ML-KEM encapsulate; resolves only if the server's confirm tag verifies\n```\n\n`login` resolves **only after** the client verifies the server's confirmation tag\n- so a resolved `login` means mutual authentication succeeded. The secret key,\nseed, and shared secret are zeroized as soon as they are used. There is no\nrecovery: the password *is* the key (see `docs/auth-todo.md` for the recovery\nwork).\n\nThe generated `shared/server.ts` also exports a typed, no-argument client\n`getUser()` that reads the readable companion cookie. It is **display-only and\nuntrusted** - a client can forge it, fooling only its own UI. The server\nre-verifies the signed session on every `@auth` request, so authorization never\ndepends on the readable cookie.\n\n## Security checklist\n\n- Set real secrets in `main.ts`: `setSecret`, `setOprfSeed`, and the server KEM\n keypair - per-deployment, identical on every instance, never in a client\n bundle. The defaults are insecure DEV placeholders.\n- Pin **your** server KEM public key in the client and rotate it; the example\n ships a throwaway dev keypair.\n- Use a production Argon2id cost (≥ 256 MiB, ≥ 3 iterations); the demo is tuned\n for browser responsiveness.\n- Back accounts/challenges with a shared store and make challenge-consume atomic.\n- Rate-limit `register` and `login` (online guessing is not stopped by the\n offline-attack resistance).\n- Always verify server-side. The server `getUser()` decodes a verified,\n expiry-checked session; the client `getUser()` does not and must not gate\n anything.\n- This is an unreviewed hybrid composition - get a cryptographic review before it\n backs real credentials. Tracked in [`docs/auth-todo.md`](./auth-todo.md).\n",
|
|
23
23
|
"environment.md": "# Environment variables & secrets\n\n`Environment` gives a tenant **per-app environment variables and secrets**, set\nout of band (a dashboard, like GitHub Actions) so the deployed `.wasm` carries\n**no credentials**. It is read-only from app code, there is no `set`; values are\nconfigured on the deployment side, never from the module.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('cfg')\nclass Cfg {\n @get('/')\n show(ctx: RouteContext): Response {\n const base = Environment.get('PUBLIC_API_BASE'); // plain var, or null\n const key = Environment.getSecure('STRIPE_KEY'); // secret, or null\n // Use `key` to call a third party; never log it or return it to a client.\n return Response.text(base != null ? base : 'unset');\n }\n}\n```\n\n`Environment` is a global, no import needed (like `EmailService` / `AuthService`).\n\n## Two disjoint buckets\n\nJust like GitHub Actions' `vars` vs `secrets`:\n\n- **`Environment.get(key)`** reads **plain vars**, non-sensitive config (a public\n API base URL, a feature flag, a region). Returns the string, or `null`.\n- **`Environment.getSecure(key)`** reads **secrets**, sensitive values (a\n third-party API key). Returns the string, or `null`.\n\nThe buckets are **disjoint**: a secret is **never** returned by `get()`, and a\nplain var is never returned by `getSecure()`. That keeps a secret from leaking\nthrough a code path that logs the result of a `get()`. Keys are case-sensitive,\nexact-match.\n\n> Secrets you read with `getSecure` are plaintext in your module at runtime\n> (that's the point, you need them to call out). Don't log them, don't put them\n> in a response, and don't copy them into a client bundle.\n\n## What is NOT here\n\nFramework-reserved namespaces (today: **email** provider config) are **host-only**,\nresolved and used in Rust where the framework needs them, and **never exposed to\nthe `.wasm`**. There is no `Environment.email`; you configure email in the\n`[email]` block of the same env file and the platform uses it for you (see\n[Email](./email.md)). The env imports only ever see your own `vars` / `secrets`.\n\n## Where values live\n\nVars and secrets live in **two separate dotenv (`.env`) files**, so the disjoint\nsplit is structural and the secrets file can be locked down on its own. On the\nedge they are per host, **out of `hosts/`** (so the config watcher never sees a\ncredential), the dashboard / edge database replaces them later:\n\n```bash\n# $TOIL_ENV_DIR/<host>.env (default dir /run/toil/env)\nPUBLIC_API_BASE=https://api.example.com # -> Environment.get(\"PUBLIC_API_BASE\")\nREGION=eu\n\n# $TOIL_ENV_DIR/<host>.env.secrets (mode 0600)\nSTRIPE_KEY=sk_live_xxx # -> Environment.getSecure(\"STRIPE_KEY\")\n\n# host-only email config, reserved TOIL_EMAIL_* keys, NEVER exposed to the .wasm\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_PROVIDER=resend\nTOIL_EMAIL_FROM=noreply@example.com\nTOIL_EMAIL_API_KEY=re_xxx\n```\n\nEach file is plain dotenv: `KEY=value` per line, `#` comments, optional `export`,\noptional quotes. Keys with the reserved **`TOIL_`** prefix are framework/host-only\nand are stripped from BOTH guest buckets, a tenant can never read them via\n`get`/`getSecure` (see [Email](./email.md) for `TOIL_EMAIL_*`).\n\nOn the edge, env is loaded **lazily** (the first time your code reads it) into a\n**bounded, shared, read-only cache** with idle eviction: the data lives in one\nplace and is never copied per request, a host that never reads env costs nothing,\nsecrets are wiped when a host goes cold, and a flood of requests to many distinct\nhosts can never grow memory without bound.\n\n## In dev\n\n`toiljs dev` reads `.env` (vars) and `.env.secrets` (secrets) at your project\nroot, and overlays `process.env` as plain vars for convenience. Both are\ngitignored by the scaffold. The ABI is identical to the edge, so code that runs\nin dev runs on the edge.\n\n```bash\n# .env (vars)\nPUBLIC_API_BASE=http://localhost:4000\n\n# .env.secrets (secrets; 0600; gitignored)\nSTRIPE_KEY=sk_test_xxx\n```\n",
|
|
24
24
|
"email.md": "# Email\n\ntoiljs can send transactional email from a route handler. A handler calls\n`EmailService.send(...)` (or a typed `EmailTemplate` / `Emails.*` from the\n`emails/` folder, or the stateless `TwoFactor` helper); the edge hands the\nmessage to a single off-core mailer thread that talks to the provider over the\nkernel network (never the worker cores), and **suspends** the wasm call until the\nprovider responds, so a slow send never blocks the worker.\n\nEverything here is an ambient **global** (no import), like `crypto` and\n`AuthService`. A tenant that never sends email pulls none of it into its build.\n\n- **`EmailService`**, send one email.\n- **`EmailTemplate`**, a reusable template with `{{placeholder}}` substitution\n (plain text and/or HTML).\n- **`emails/*.tsx`**, author emails as React components; the build renders them\n to static HTML and generates a typed `Emails.<Name>.send(...)`.\n- **`TwoFactor`**, stateless email verification codes (2FA / confirm), no DB.\n\n> **The one rule of HTML email:** email clients run **no JavaScript** and strip\n> `<style>`/external CSS. So HTML email is a static, inline-styled string, and\n> any \"rendering\" (React, CSS files) happens at **build time**, not at send time.\n> See [React email templates](#react-email-templates).\n\n## Configure email\n\nEmail is a **framework-reserved namespace of the tenant's environment**, the\nsame out-of-band [Environment](./environment.md) store that backs\n`Environment.get` / `getSecure`, but the `[email]` block is **host-only**: it is\nread and used in Rust (the off-core mailer) and is **never exposed to the\n`.wasm`**. The provider key never lives in the deployed module or in the\ninotify-watched `hosts/<host>.toml`.\n\nOn the edge today it lives in the tenant's env secrets file,\n`$TOIL_ENV_DIR/<host>.env.secrets` (default dir `/run/toil/env`), kept `0600` and\n**out of `hosts/`** so the config watcher never sees a credential (the dashboard /\nedge DB replaces this file later). Email config is a set of **reserved\n`TOIL_EMAIL_*` keys**, host-only, stripped from the guest buckets, so a tenant\ncan never read them via `Environment.getSecure`:\n\n```bash\n# $TOIL_ENV_DIR/<host>.env.secrets (mode 0600; NOT under hosts/, NOT in the .wasm)\n\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_FROM=you@example.com # validated; single address, no CRLF\nTOIL_EMAIL_PROVIDER=resend # resend | gmail | smtp\nTOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx # the provider credential\nTOIL_EMAIL_MAX_PER_MIN=60 # per-host send budget: rolling 1-minute cap\nTOIL_EMAIL_MAX_PER_DAY=0 # per-host send budget: rolling 24-hour cap (0 = unlimited)\nTOIL_EMAIL_MAX_PER_RECIPIENT_PER_HOUR=5 # anti-abuse cap per recipient\n```\n\nThe same file also carries the tenant's own secrets (and `<host>.env` their plain\nvars; see [Environment](./environment.md)); the `TOIL_EMAIL_*` keys are just the\nreserved namespace the framework consumes.\n\nWhen `enabled` is `false` (the default) the host has no email capability and\n`EmailService.send` returns `Disabled`. The env is loaded **lazily** (on the\nfirst send) and the `api_key` is materialized into a zeroizing secret in host\nmemory, never written to logs or `/_admin`. A malformed `[email]` block is\ntreated as \"no email\" (the host returns `Disabled`); validate config on the\ndashboard before deploying.\n\n### Providers\n\n**Resend** (`provider = \"resend\"`), a JSON API; `api_key` holds the API key.\n\n**Gmail** (`TOIL_EMAIL_PROVIDER=gmail`), SMTP with Gmail defaults:\n`smtp.gmail.com`, port `587` (STARTTLS), username = `from`. `TOIL_EMAIL_API_KEY`\nholds a Gmail **App Password** (create one at\n<https://myaccount.google.com/apppasswords>; the account needs 2-Step\nVerification). No extra keys needed:\n\n```bash\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_FROM=you@gmail.com\nTOIL_EMAIL_PROVIDER=gmail\nTOIL_EMAIL_API_KEY=abcd efgh ijkl mnop\n```\n\n**Generic SMTP** (`TOIL_EMAIL_PROVIDER=smtp`), any submission server (Outlook,\nSendGrid/Mailgun SMTP, your own). Requires `TOIL_EMAIL_SMTP_HOST`; port defaults\nto `587` (STARTTLS), or set `465` for implicit TLS. `TOIL_EMAIL_SMTP_USER`\ndefaults to `from`.\n\n```bash\nTOIL_EMAIL_ENABLED=true\nTOIL_EMAIL_FROM=noreply@example.com\nTOIL_EMAIL_PROVIDER=smtp\nTOIL_EMAIL_API_KEY=the-smtp-password\nTOIL_EMAIL_SMTP_HOST=smtp.example.com\nTOIL_EMAIL_SMTP_PORT=587\nTOIL_EMAIL_SMTP_USER=noreply@example.com\n```\n\n### In dev\n\n`toiljs dev` runs the **full email pipeline** in Node, recipient validation,\ndedup, and the per-minute / per-day / per-recipient caps all behave exactly like\nthe edge, and **really sends** once you configure a provider. Configure it in\n`toil.config.ts` (non-secret) with the API key in `.env.secrets`:\n\n```ts\n// toil.config.ts\nimport { defineConfig } from 'toiljs/compiler';\nexport default defineConfig({\n server: {\n email: { provider: 'resend', from: 'you@example.com', maxPerMin: 60 },\n },\n});\n```\n\n```bash\n# .env.secrets (gitignored)\nTOIL_EMAIL_API_KEY=re_xxxxxxxxxxxx\n```\n\n`TOIL_EMAIL_*` env vars override the config file (so the same `.env.secrets` the\nedge uses works in dev too). Supports `resend` and `gmail`/`smtp` (SMTP via\nnodemailer). **Not configured?** `EmailService.send` stays a log-only mock and\nreturns `Sent`, so a flow that sends email still works without setup.\n\n> Because the dev server runs the guest **synchronously**, the actual network\n> send is fire-and-forget: validation + caps return their exact status\n> immediately, but a `Sent` is optimistic and the real delivery outcome (or\n> `ProviderError`) is logged, not returned. The ABI is identical to the edge, so\n> code that runs in dev runs on the edge.\n\n## Sending email\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('notify')\nclass Notify {\n @post('/welcome')\n welcome(ctx: RouteContext): Response {\n const status = EmailService.send(\n 'alice@example.com',\n 'Welcome!',\n 'Thanks for signing up.', // plain-text body\n 'welcome', // purpose tag (dedup / abuse keying)\n '<h1>Thanks for signing up.</h1>', // optional HTML body\n );\n return status == EmailStatus.Sent\n ? Response.text('sent\\n')\n : Response.text('could not send\\n', 503);\n }\n}\n```\n\n`send(to, subject, body, purpose = 'tx', html = '')` returns an **`EmailStatus`**:\n\n| Status | Meaning | Retry? |\n| ----------------- | ----------------------------------------------------------- | ---------------- |\n| `Sent` | Accepted by the provider |, |\n| `Deduped` | An identical recent `(recipient, purpose)` was collapsed | treat as sent |\n| `Budget` | The host's per-minute budget is exhausted | yes, later |\n| `TryLater` | The mailer was saturated / a queue was full | yes, back off |\n| `RecipientCapped` | The per-recipient hourly cap was hit | no (this window) |\n| `BadRecipient` | The address failed validation (CRLF, multiple addresses) | no |\n| `Disabled` | This host has no `[email]` capability | no |\n| `ProviderError` | The provider rejected it, or transport failed after retries | no |\n\n`purpose` is a short, non-PII tag (`\"welcome\"`, `\"reset\"`, …). The mailer folds\nit into the **dedup** key (identical `(host, recipient, purpose)` within ~30s is\ncollapsed to one send) and the abuse counters. It is never logged in the clear.\n\nThe recipient is validated host-side (exactly one address, no CR/LF/`<>`), so a\nguest can never smuggle a second envelope recipient or a header into the send.\n\n## Templates\n\n`EmailTemplate` is a reusable message with `{{placeholder}}` substitution, for\nwhen the same email is sent with different values:\n\n```ts\nconst welcome = new EmailTemplate(\n 'Welcome, {{name}}!', // subject\n 'Hi {{name}}, your code is {{code}}.', // plain-text body\n '<h1>Welcome, {{name}}</h1><p>Code: <b>{{code}}</b></p>', // html (optional)\n);\n\nconst vars = new Map<string, string>();\nvars.set('name', 'Alice');\nvars.set('code', '123456');\nconst status = welcome.send('alice@example.com', vars, 'welcome');\n```\n\n- `{{ name }}` (with surrounding spaces) is accepted; an unknown placeholder\n renders to the empty string.\n- Omit the `html` argument for a plain-text template.\n- `template.render(vars)` returns the rendered `{ subject, body, html }` without\n sending (useful for preview/testing).\n\nFor anything richer than `{{token}}` substitution, real layout, CSS, brand,\nauthor the email as a React component instead.\n\n## React email templates\n\nWrite emails as React components in an **`emails/`** folder. At `toiljs build`\neach one is rendered **once, at build time**, to static inline-CSS HTML (because\nthe inbox runs no JS), with the component's props turned into `{{token}}` holes;\nthe build then generates a typed `Emails.<Name>.send(...)` your server calls.\n\n```tsx\n// emails/Welcome.tsx\nexport const subject = 'Welcome, {{name}}!';\n\nexport default function Welcome({ name, code }: { name: string; code: string }) {\n return (\n <table\n width=\"100%\"\n style={{ fontFamily: 'Arial, sans-serif' }}>\n <tbody>\n <tr>\n <td style={{ padding: '24px' }}>\n <h1 style={{ color: '#111' }}>Welcome, {name}!</h1>\n <p>\n Your code is <b>{code}</b>.\n </p>\n </td>\n </tr>\n </tbody>\n </table>\n );\n}\n```\n\nThe generated `Emails.Welcome.send(...)` takes the recipient, then one argument\nper `{{token}}` **in alphabetical order**, then an optional `purpose`:\n\n```ts\n// emails/Welcome.tsx uses {{code}} and {{name}} -> params are (code, name)\nconst status = Emails.Welcome.send('alice@example.com', '123456', 'Alice');\n```\n\nAuthoring notes:\n\n- **Styles must end up inline.** Email clients strip `<style>`/external CSS, so\n write inline `style={{ ... }}`, or import a stylesheet and its rules are\n inlined into element `style=\"…\"` for you at build (a bare CSS import has no\n effect on its own under SSR). Keep email-only styles next to the email, e.g.\n `import './styles/email.css'`, or **reuse existing project CSS** with `import\n'client/styles/…'` (the `client/*` alias points at your client source).\n- **Optional exports:** `export const subject` (a token template; defaults to the\n email name), `export const text` (a plain-text alternative; otherwise derived\n from the HTML), `export const purpose`.\n- **Build-time, field substitution only.** Because the component renders once at\n build, per-send data is `{{token}}` substitution, a runtime `{items.map(...)}`\n or conditional bakes in at build, it does not re-run per recipient. That covers\n transactional / 2FA / confirmation email; dynamic lists need a different\n approach.\n- The generated `server/_emails.ts` is regenerated on `build`/`dev` and should be\n gitignored.\n\n### Preview while you author\n\nWhile `toiljs dev` runs, open **`/__toil/emails`** (the dev banner prints the\nlink). It lists every `emails/*.tsx`, renders the selected one exactly as the\nbuild does (imported `client/*` CSS inlined), lets you fill each `{{token}}` to\nsee the result, toggle the HTML and plain-text parts, and open the file in your\neditor. It refreshes live as you edit the template or its CSS.\n\n## Email verification codes (`TwoFactor`)\n\n`TwoFactor` is a **stateless** email-code primitive (2FA, email confirmation,\nmagic codes), no database. It emails a random code and returns a signed\n**token** that commits to the code via HMAC, without putting the code in the\ntoken (the code is only in the email). Verification recomputes the HMAC from the\ntoken plus the user-entered code, so a valid `(token, code)` pair can only come\nfrom someone who both received the email and holds the token.\n\n```ts\n// 1. Issue + email a code; hand `token` to the client (a cookie or hidden field).\nconst challenge = TwoFactor.send('alice@example.com', 'login'); // emails the code\n// challenge.token -> give to the client\n// challenge.status -> the EmailStatus of the send\n\n// 2. Later, verify what the user typed.\nconst ok: bool = TwoFactor.verify(challenge.token, 'alice@example.com', userEntered);\n```\n\n- **`send(recipient, purpose, ttlSecs = 600, digits = 6)`**, issues a code,\n emails it with a built-in template, returns `{ token, status }`.\n- **`issue(recipient, purpose, ttlSecs, digits)`**, returns `{ code, token }`\n **without** sending, so you can email `code` with your own `EmailTemplate` /\n `Emails.*` for a branded message.\n- **`verify(token, recipient, code)`**, `true` only for a code issued for that\n recipient that hasn't expired. Constant-time compare.\n- **`TwoFactor.setSecret(secret)`**, the HMAC secret for the tokens. Call once\n at startup in `main.ts`; it must be identical on every edge instance and out of\n any client bundle. (This is separate from the provider `api_key`.)\n\n**Limitation:** this gives integrity + expiry but **not single-use**, a valid\ncode verifies repeatedly within its TTL, because there is no server state to burn\nit. Keep the TTL short; for true single-use, store a per-recipient\nlast-verified-at and reject at or before it.\n\n## Limits and abuse controls\n\nAll enforced authoritatively in the single mailer (so the counts are exact across\nall workers):\n\n- **Per-host budget**, two rolling windows, both enforced: a 1-minute cap\n (`max_per_min`) and a 24-hour cap (`max_per_day`, `0` = unlimited). Over either\n one → `Budget`. Each host's caps, in-window sends, and reject counts are visible\n per host at `GET /_admin/email`.\n- **Per-recipient cap**, `max_per_recipient_per_hour`. Over it →\n `RecipientCapped`.\n- **Dedup**, identical `(host, recipient, purpose)` within ~30s → `Deduped`.\n\nEditing these in the host config takes effect on the next send (no restart).\n\n## Observability\n\n`GET /_admin/email` returns process-wide counters by reason (JSON), e.g.\n`submitted`, `sent`, `deduped`, `budget`, `recipient_capped`, `try_later`,\n`bad_recipient`, `provider_error`. It exposes **counts only**, never a\nrecipient, code, subject, body, or secret.\n\n## See also\n\n- [Rate limiting](./ratelimit.md), protect your routes (including any email\n trigger) with `@ratelimit`.\n- [Web Crypto](./crypto.md), the `crypto` global `TwoFactor` builds on.\n",
|
|
25
25
|
"cookies.md": "# Cookies\n\nA complete HTTP cookie layer for the toiljs server runtime, covering the full\nRFC 6265bis surface (including `SameSite`, the `Partitioned`/CHIPS attribute, and\nthe `__Host-` / `__Secure-` prefixes) plus cryptographic signing and encryption.\n\n`Cookie`, `Cookies`, `CookieMap`, `SecureCookies`, and the `SameSite` /\n`CookieEncoding` / `CookiePrefix` enums are **ambient globals**: a handler uses\nthem with **no import**, exactly like `crypto`. They are also exported from\n`toiljs/server/runtime` for anyone who prefers an explicit import.\n\n- [How \"global, no import\" works](#how-global-no-import-works)\n- [Quick start](#quick-start)\n- [The `Cookie` builder](#the-cookie-builder)\n- [The `Cookies` parser and codec](#the-cookies-parser-and-codec)\n- [`CookieMap`](#cookiemap)\n- [`SecureCookies` signing and encryption](#securecookies-signing-and-encryption)\n- [`Request` and `Response` integration](#request-and-response-integration)\n- [`base64url` helpers](#base64url-helpers)\n- [Encoding vs encryption](#encoding-vs-encryption)\n- [Security notes](#security-notes)\n- [Spec compliance](#spec-compliance)\n- [Testing](#testing)\n- [API reference](#api-reference)\n\n---\n\n## How \"global, no import\" works\n\nThe cookie types are declared with ToilScript's `@global` decorator and pulled\ninto every server build (re-exported from `toiljs/server/runtime` and\nside-effect-imported by `toiljs/server/runtime/exports`, which every `main.ts`\nre-exports). At compile time the symbols register in the global scope, so a\nhandler can write `Cookie.create(...)` or `req.cookie(...)` without importing\nanything.\n\nFor the editor, `toiljs create` scaffolds `server/toil-server-env.d.ts` with\nambient `declare`s for these globals (the toilscript compiler ignores `.d.ts`;\nit only feeds the language service). If you would rather import them:\n\n```ts\nimport { Cookie, Cookies, SecureCookies, SameSite } from 'toiljs/server/runtime';\n```\n\n---\n\n## Quick start\n\n```ts\nimport { ToilHandler, Request, Response } from 'toiljs/server/runtime';\n\nexport class AppHandler extends ToilHandler {\n public handle(req: Request): Response {\n // Read (no import needed for Cookie / Cookies / SameSite, they are global).\n const sid = req.cookie('sid'); // string | null\n\n // Write a hardened session cookie.\n return Response.json('{\"ok\":true}').setCookie(\n Cookie.create('sid', 'abc123')\n .httpOnly()\n .secure()\n .sameSite(SameSite.Lax)\n .maxAge(3600)\n .asHostPrefixed(), // forces Secure + Path=/ + no Domain\n );\n }\n}\n```\n\n---\n\n## The `Cookie` builder\n\nA fluent builder that serializes to one `Set-Cookie` field value. Every setter\nreturns the cookie, so calls chain.\n\n```ts\nconst c = Cookie.create('id', 'abc123')\n .domain('example.com')\n .path('/app')\n .maxAge(3600)\n .secure()\n .httpOnly()\n .sameSite(SameSite.Lax);\n\nc.serialize();\n// \"id=abc123; Domain=example.com; Path=/app; Max-Age=3600; SameSite=Lax; Secure; HttpOnly\"\n```\n\n### Fields\n\n| Field | Type | Notes |\n| --- | --- | --- |\n| `name` | `string` | The cookie name (a token; never encoded). |\n| `value` | `string` | The logical value (encoded per `encoding` on serialize). |\n| `encoding` | `CookieEncoding` | Wire encoding for the value. Default `Percent`. |\n\n### Construction\n\n- `new Cookie(name, value)`\n- `Cookie.create(name, value): Cookie`, a builder-style alias.\n\n### Attribute setters\n\n| Method | Attribute |\n| --- | --- |\n| `domain(v: string)` | `Domain` |\n| `path(v: string)` | `Path` (must begin with `/`) |\n| `maxAge(seconds: i64)` | `Max-Age` (`0` / negative expire immediately) |\n| `expires(epochSeconds: i64)` | `Expires`, formatted as an IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`) |\n| `expiresRaw(date: string)` | `Expires` verbatim (escape hatch) |\n| `secure(on: bool = true)` | `Secure` |\n| `httpOnly(on: bool = true)` | `HttpOnly` |\n| `sameSite(s: SameSite)` | `SameSite` |\n| `partitioned(on: bool = true)` | `Partitioned` (CHIPS) |\n| `priority(p: string)` | `Priority` (`Low` / `Medium` / `High`) |\n| `extension(av: string)` | An arbitrary extension attribute, appended verbatim |\n| `withEncoding(e: CookieEncoding)` | Choose the value wire encoding |\n\n### Prefixes\n\n- `asSecurePrefixed(): Cookie`, prepends `__Secure-` and forces `Secure`.\n- `asHostPrefixed(): Cookie`, prepends `__Host-` and forces `Secure`, `Path=/`, and no `Domain`.\n- `detectedPrefix(): CookiePrefix`, the prefix detected on the current name (case-insensitive).\n\n### Output\n\n- `serialize(strict: bool = false): string`, returns the `Set-Cookie` value. Lenient by\n default (always returns a best-effort cookie); pass `strict = true` to throw on\n a hard validation failure. `Secure` is added automatically when `SameSite=None`\n or `Partitioned` is set; `Max-Age` is clamped to the 400-day cap; control\n characters are stripped from the name, value, and attributes.\n- `toString(): string`, alias for `serialize()`.\n- `encodedValue(): string`, the value transformed per `encoding`.\n\n### Validation\n\n`validate(): CookieValidation` checks the cookie against RFC 6265bis and returns\na structured result:\n\n```ts\nclass CookieValidation {\n valid: bool;\n errors: Array<string>;\n}\n```\n\nIt flags: a non-token name, name+value over 4096 bytes, a `Domain`/`Path` over\n1024 bytes, a `Path` not starting with `/`, a `Raw` value outside `cookie-octet`,\nthe `__Host-` / `__Secure-` prefix requirements, `SameSite=None` or `Partitioned`\nwithout `Secure`, and a `Max-Age` beyond the 400-day cap.\n\n### Attribute serialization order\n\n`name=value` then, when set: `Domain`, `Path`, `Expires`, `Max-Age`, `SameSite`,\n`Secure`, `HttpOnly`, `Partitioned`, `Priority`, then any `extension(...)` values.\n(Attribute order is not significant to user agents; the order is stable so output\nis predictable.)\n\n### Enums\n\n```ts\nenum SameSite { Default, None, Lax, Strict } // Default omits the attribute\nenum CookieEncoding { Percent, Raw, Base64Url } // value wire encoding\nenum CookiePrefix { None, Secure, Host }\n```\n\n---\n\n## The `Cookies` parser and codec\n\nStatic helpers for the read side and a one-shot serializer.\n\n| Method | Description |\n| --- | --- |\n| `Cookies.parse(cookieHeader: string): CookieMap` | Parse a request `Cookie` header (`a=1; b=2`). Values are percent-decoded; one layer of surrounding quotes is stripped; malformed pairs and empty names are skipped. On a duplicate name the first wins. |\n| `Cookies.get(cookieHeader: string, name: string): string \\| null` | Parse and return one value. |\n| `Cookies.serialize(name: string, value: string): string` | One-shot `name=value` with no attributes (percent-encoded). For attributes, build a `Cookie`. |\n| `Cookies.parseSetCookie(setCookie: string): Cookie` | Parse a `Set-Cookie` line back into a `Cookie` (for clients, tests, proxies). Kept verbatim (`CookieEncoding.Raw`) so re-serializing reproduces the wire form. |\n| `Cookies.encodeValue(raw: string): string` | Percent-encode a value (the default `Cookie` encoding). |\n| `Cookies.decodeValue(enc: string): string` | Percent-decode a value (the inverse). |\n\n```ts\nconst jar = Cookies.parse('sid=abc123; theme=dark');\njar.get('sid'); // \"abc123\"\n\nCookies.serialize('sid', 'a b'); // \"sid=a%20b\"\n```\n\n---\n\n## `CookieMap`\n\nThe ordered name to value view returned by `Cookies.parse` and `Request.cookies()`.\nBacked by parallel arrays (a request carries a handful of cookies, so a linear\nscan beats hashing and keeps the runtime small).\n\n| Member | Description |\n| --- | --- |\n| `get(name: string): string \\| null` | The value, or `null`. |\n| `has(name: string): bool` | Whether the cookie is present. |\n| `names(): Array<string>` | A copy of the names, in encounter order. |\n| `size: i32` | The number of cookies. |\n| `set(name: string, value: string): void` | Insert unless present (keep-first). Used by `parse`; rarely called directly. |\n\n---\n\n## `SecureCookies` signing and encryption\n\nTamper-proof and confidential cookie values, built on the `crypto` global (no new\nhost functions).\n\n- **`SecureCookies.signed(key)`**: HMAC-SHA256. The value stays readable but is\n bound to the cookie name, so it cannot be tampered with or moved to another\n cookie. Sealed form: `base64url(value) \".\" base64url(mac)`.\n- **`SecureCookies.encrypted(key)`**: AES-256-GCM (or AES-128-GCM) with a random\n 96-bit IV and the cookie name as additional authenticated data. The value is\n confidential and authenticated. Sealed form: `base64url(iv ‖ ciphertext ‖ tag)`.\n\nKeys are caller-supplied raw bytes:\n\n- HMAC: any length (32+ bytes recommended).\n- AES: exactly 16 or 32 bytes (enforced up front; a wrong length is rejected by\n the factory).\n\n```ts\n// A real app loads a long random secret from config; never hard-code one.\nconst key = Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));\n\n// Signed (readable, tamper-proof)\nconst signer = SecureCookies.signed(key);\nconst sealed = signer.sign('session', 'user-42');\nconst user = signer.unsign('session', sealed); // \"user-42\", or null if tampered\n\n// Encrypted (confidential + authenticated)\nconst box = SecureCookies.encrypted(key);\nresp.setCookie(box.seal(Cookie.create('secret', 'top-secret').httpOnly()));\nconst secret = box.open(req.cookies(), 'secret'); // \"top-secret\", or null\n```\n\n| Method | Description |\n| --- | --- |\n| `SecureCookies.signed(key: Uint8Array)` | HMAC-SHA256 signer/verifier. |\n| `SecureCookies.encrypted(key: Uint8Array)` | AES-GCM (16- or 32-byte key). |\n| `addKey(key: Uint8Array): SecureCookies` | Add a fallback key for rotation: seal with the first, open with any. |\n| `sign(name, value): string` | Sealed signed value. |\n| `unsign(name, sealed): string \\| null` | Verify and recover, or `null`. |\n| `encrypt(name, value): string` | Sealed encrypted value. |\n| `decrypt(name, sealed): string \\| null` | Decrypt, or `null`. |\n| `seal(cookie: Cookie): Cookie` | Seal a cookie's value in place (sign or encrypt per the instance mode) and mark it `Raw`. Returns the same cookie. |\n| `open(jar: CookieMap, name): string \\| null` | Read and open cookie `name` from a parsed jar. |\n\n**Key rotation:** seal with `keys[0]`; `unsign` / `decrypt` try every key in turn,\nso you can add a new key as the first and keep an old one as a fallback while\nexisting cookies age out.\n\n```ts\nconst signer = SecureCookies.signed(newKey).addKey(oldKey);\n```\n\n---\n\n## `Request` and `Response` integration\n\nBecause every handler already has a `Request` and returns a `Response`, the most\ncommon operations live there directly.\n\n**Read (`Request`):**\n\n| Method | Description |\n| --- | --- |\n| `req.cookies(): CookieMap` | All cookies, parsed from the `Cookie` header (cached for the request). |\n| `req.cookie(name: string): string \\| null` | One cookie value. |\n\n**Write (`Response`, builder-style):**\n\n| Method | Description |\n| --- | --- |\n| `resp.setCookie(cookie: Cookie): Response` | Append a `Set-Cookie`. Each call adds its own header (cookies are never folded). |\n| `resp.setCookieKV(name, value): Response` | Shorthand for `setCookie(new Cookie(name, value))`. |\n| `resp.clearCookie(name, path = '/', domain = ''): Response` | Append a deletion cookie (empty value, `Max-Age=0`, epoch `Expires`). `path` / `domain` must match the original. |\n\n---\n\n## `base64url` helpers\n\nUnpadded base64url (RFC 4648 §5), used internally by `SecureCookies` and exported\nfor convenience. Its alphabet (`A-Z a-z 0-9 - _`) is within the `cookie-octet`\ngrammar and invariant under percent-encoding, so encoded values round-trip\ncleanly through the default cookie codec.\n\n| Function | Description |\n| --- | --- |\n| `base64UrlEncode(data: Uint8Array): string` | Encode bytes as unpadded base64url. |\n| `base64UrlDecode(s: string): Uint8Array \\| null` | Decode base64url/base64 (padding and whitespace tolerated); `null` on an invalid character or length. |\n\n---\n\n## Encoding vs encryption\n\nTwo independent layers, easy to mix up:\n\n- **Encoding** (`CookieEncoding`) is transport-only and reversible by anyone. It\n keeps an arbitrary value inside the `cookie-octet` grammar.\n - `Percent` (default): `encodeURIComponent`-style; arbitrary UTF-8 is safe.\n - `Base64Url`: UTF-8 then base64url.\n - `Raw`: no transformation (the value must already be valid `cookie-octet`).\n- **Signing / encryption** (`SecureCookies`) is cryptographic. Signing keeps the\n value readable but tamper-proof; encryption makes it unreadable and\n authenticated. Both require a secret key.\n\n`SecureCookies.seal` sets the value to its sealed (base64url) form and marks the\ncookie `Raw`, so it passes through the default parse path untouched.\n\n---\n\n## Security notes\n\n- **Panic-free verification.** `unsign` and `decrypt` return `null` on a tampered,\n truncated, or wrong-key value, never a trap. (`decrypt` reads the host return\n code directly instead of letting the underlying crypto throw, because the\n server runs with exceptions disabled.) This makes them safe to call on\n attacker-controlled input.\n- **Name-binding.** Signing MACs `name + \"=\" + value`; encryption uses the name as\n AAD. A sealed value made for one cookie name will not verify or decrypt under\n another.\n- **Control characters are stripped** from the name, value, and attribute values\n on serialize, as a defense-in-depth guard against header injection (CR/LF).\n Control characters are invalid in all of these per the grammar, so nothing\n legitimate is lost. The default value encoding already neutralizes CR/LF.\n- **Prefixes.** `asHostPrefixed()` / `asSecurePrefixed()` apply and enforce the\n browser-recognized guarantees; `validate()` reports a name that carries a prefix\n without satisfying its requirements.\n- **`SameSite=None` and `Partitioned` imply `Secure`** and are emitted with it\n automatically.\n- **Lifetime is clamped** to the RFC 400-day cap on serialize; sizes are checked by\n `validate()`.\n- **Local development.** Browsers treat `http://localhost` as a secure context, so\n `Secure` and `__Host-` cookies work under `toiljs dev` over plain HTTP.\n\nWhen putting untrusted input into a cookie **name** or **attribute** (rather than\nthe value, which is encoded by default), check `validate()` or use\n`serialize(true)`.\n\n---\n\n## Spec compliance\n\nImplements RFC 6265bis (HTTP State Management) and the `Partitioned` (CHIPS)\ncompanion: the `cookie-name` token and `cookie-value` `cookie-octet` grammars,\nthe `Expires` / `Max-Age` / `Domain` / `Path` / `Secure` / `HttpOnly` /\n`SameSite` / `Partitioned` attributes plus `Priority` and arbitrary extensions,\nthe `__Host-` / `__Secure-` prefixes (matched case-insensitively), the 4096-byte\nname+value and 1024-byte attribute limits, the 400-day lifetime cap, the\n`SameSite=None` ⇒ `Secure` rule, and the requirement that each cookie occupy its\nown `Set-Cookie` header (never folded).\n\n---\n\n## Testing\n\n- Pure cookie logic (builder, parser, codec, validation, `Request` / `Response`\n integration) is unit-tested with as-pect in `test/assembly/cookie.spec.ts`\n (`npm run test:server`).\n- `SecureCookies` is exercised end-to-end against the real toilscript-compiled\n wasm with the Node-backed crypto host in `test/devserver.test.ts`\n (`npm test`). It is tested there rather than under as-pect because the as-pect\n compiler does not ship the toilscript crypto standard library.\n\nA live demo (every attribute's serialized output, set/inspect/clear, and an\ninteractive sign/encrypt) is in the example app: run `toiljs dev` in\n`examples/basic` and open `/cookies`. The backend lives in\n`examples/basic/server/core/AppHandler.ts`.\n\n---\n\n## API reference\n\n```ts\n// Globals (also exported from 'toiljs/server/runtime')\n\nenum SameSite { Default, None, Lax, Strict }\nenum CookieEncoding { Percent, Raw, Base64Url }\nenum CookiePrefix { None, Secure, Host }\n\nclass CookieValidation {\n valid: bool;\n errors: Array<string>;\n}\n\nclass Cookie {\n name: string;\n value: string;\n encoding: CookieEncoding;\n static create(name: string, value: string): Cookie;\n domain(v: string): Cookie;\n path(v: string): Cookie;\n maxAge(seconds: i64): Cookie;\n expires(epochSeconds: i64): Cookie;\n expiresRaw(date: string): Cookie;\n secure(on?: bool): Cookie;\n httpOnly(on?: bool): Cookie;\n sameSite(s: SameSite): Cookie;\n partitioned(on?: bool): Cookie;\n priority(p: string): Cookie;\n extension(av: string): Cookie;\n withEncoding(e: CookieEncoding): Cookie;\n asSecurePrefixed(): Cookie;\n asHostPrefixed(): Cookie;\n detectedPrefix(): CookiePrefix;\n encodedValue(): string;\n validate(): CookieValidation;\n serialize(strict?: bool): string;\n toString(): string;\n}\n\nclass CookieMap {\n get(name: string): string | null;\n has(name: string): bool;\n names(): Array<string>;\n size: i32;\n set(name: string, value: string): void;\n}\n\nclass Cookies {\n static parse(cookieHeader: string): CookieMap;\n static get(cookieHeader: string, name: string): string | null;\n static serialize(name: string, value: string): string;\n static parseSetCookie(setCookie: string): Cookie;\n static encodeValue(raw: string): string;\n static decodeValue(enc: string): string;\n}\n\nclass SecureCookies {\n static signed(key: Uint8Array): SecureCookies;\n static encrypted(key: Uint8Array): SecureCookies;\n addKey(key: Uint8Array): SecureCookies;\n sign(name: string, value: string): string;\n unsign(name: string, sealed: string): string | null;\n encrypt(name: string, value: string): string;\n decrypt(name: string, sealed: string): string | null;\n seal(cookie: Cookie): Cookie;\n open(jar: CookieMap, name: string): string | null;\n}\n\nfunction base64UrlEncode(data: Uint8Array): string;\nfunction base64UrlDecode(s: string): Uint8Array | null;\n\n// On Request\nreq.cookies(): CookieMap;\nreq.cookie(name: string): string | null;\n\n// On Response (builder-style)\nresp.setCookie(cookie: Cookie): Response;\nresp.setCookieKV(name: string, value: string): Response;\nresp.clearCookie(name: string, path?: string, domain?: string): Response;\n```\n",
|
|
26
26
|
"crypto.md": "# Web Crypto\n\nThe guest gets a synchronous Web Crypto surface through the ambient `crypto`\nglobal, backed by host functions. It mirrors the browser `crypto` /\n`crypto.subtle` API but **without Promises**, ToilScript has no `async`, so\nevery call returns its result directly. Keys are opaque per-request handles in a\nhost keystore; a `CryptoKey` is valid only for the request that created it.\n\n```ts\nconst mac = crypto.hmacSha256(key, message); // Uint8Array\nconst id = crypto.randomUUID(); // string\n```\n\nThis is also what [`SecureCookies`](./cookies.md) and\n[`AuthService`](./auth.md) are built on, so most apps use crypto indirectly.\n\n## `crypto` namespace\n\nConvenience helpers (all synchronous):\n\n| Function | Signature | Notes |\n| --- | --- | --- |\n| `getRandomValues` | `(array: Uint8Array): void` | Fill with CSPRNG bytes. |\n| `randomUUID` | `(): string` | RFC 4122 v4 UUID. |\n| `sha1` / `sha256` / `sha384` / `sha512` | `(data: Uint8Array): Uint8Array` | One-shot digests. |\n| `sha1Text` … `sha512Text` | `(s: string): Uint8Array` | UTF-8 encode then digest. |\n| `hmacSha256` | `(key: Uint8Array, msg: Uint8Array): Uint8Array` | One-shot HMAC-SHA256. |\n| `hmacSha256Text` | `(key: Uint8Array, msg: string): Uint8Array` | HMAC-SHA256 over a UTF-8 string. |\n| `toHex` | `(bytes: Uint8Array): string` | Lowercase hex. |\n| `subtle` | `SubtleCrypto` | The full primitive surface (below). |\n\n## `crypto.subtle`\n\n| Method | Signature |\n| --- | --- |\n| `digest` | `digest(algorithm: string, data: Uint8Array): Uint8Array` |\n| `importKey` | `importKey(format: string, keyData: Uint8Array, algorithm: AlgorithmParams, extractable: bool, usages: i32): CryptoKey` |\n| `exportKey` | `exportKey(format: string, key: CryptoKey): Uint8Array` |\n| `encrypt` | `encrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `decrypt` | `decrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `sign` | `sign(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `verify` | `verify(algorithm: AlgorithmParams, key: CryptoKey, signature: Uint8Array, data: Uint8Array): bool` |\n| `deriveBits` | `deriveBits(algorithm: AlgorithmParams, baseKey: CryptoKey, length: i32): Uint8Array` |\n| `deriveKey` | `deriveKey(algorithm, baseKey, lengthBits, derivedKeyAlgorithm, extractable, usages): CryptoKey` |\n\n`digest` takes a named algorithm string (`\"SHA-1\"`, `\"SHA-256\"`, `\"SHA-384\"`,\n`\"SHA-512\"`, `\"SHA3-256\"`, `\"SHA3-384\"`, `\"SHA3-512\"`). `verify` returns a bool\n(it does not throw on a mismatch). Formats are `raw`, `pkcs8`, `spki`; **`jwk`\nis not supported**.\n\n### Algorithm parameter classes\n\n`crypto` and `crypto.subtle` are ambient globals (no import). The params classes\nand the `ALG_*` / `USAGE_*` / `FMT_*` / `CURVE_*` constants and the `CryptoKey`\ntype are imported from the `'crypto'` module:\n\n```ts\nimport { AesGcmParams, HmacImportParams, ALG_SHA_256, USAGE_SIGN } from 'crypto';\n```\n\nEach algorithm has a small params class you pass to `importKey`/`sign`/etc.:\n\n| Class | Constructor |\n| --- | --- |\n| `AesGcmParams` | `(iv, additionalData?, tagLength = 128)` |\n| `AesCbcParams` | `(iv)` |\n| `AesCtrParams` | `(counter, length = 128)` |\n| `HmacImportParams` | `(hash)` |\n| `HmacParams` | `()` |\n| `Pbkdf2Params` | `(hash, salt, iterations)` |\n| `HkdfParams` | `(hash, salt, info?)` |\n| `EcdsaParams` | `(hash)` |\n| `EcKeyImportParams` | `(alg, namedCurve)` |\n| `Ed25519Params` | `()` |\n| `X25519ImportParams` | `()` |\n| `EcdhParams` | `(alg, publicKeyHandle)` |\n\n### Constants\n\n- **Hashes / algorithms:** `ALG_SHA_1`, `ALG_SHA_256`, `ALG_SHA_384`,\n `ALG_SHA_512`, `ALG_SHA3_256/384/512`, `ALG_AES_GCM`, `ALG_AES_CBC`,\n `ALG_AES_CTR`, `ALG_HMAC`, `ALG_ECDSA`, `ALG_ED25519`, `ALG_ECDH`, `ALG_HKDF`,\n `ALG_PBKDF2`.\n- **Key formats:** `FMT_RAW`, `FMT_PKCS8`, `FMT_SPKI` (`FMT_JWK` is rejected).\n- **Usages (bitmask):** `USAGE_ENCRYPT`, `USAGE_DECRYPT`, `USAGE_SIGN`,\n `USAGE_VERIFY`, `USAGE_DERIVE_KEY`, `USAGE_DERIVE_BITS`, `USAGE_WRAP_KEY`,\n `USAGE_UNWRAP_KEY`, OR them together.\n- **Named curves:** `CURVE_P256`, `CURVE_P384` (`CURVE_P521` is not supported).\n\n### `CryptoKey`\n\nAn opaque handle plus metadata: `handle: i32`, `type: string`\n(`secret`/`public`/`private`), `extractable: bool`, `algorithm: i32`,\n`usages: i32`, with `algorithmName()` and `hasUsage(u)`. A key is valid only for\nthe request that imported it.\n\n## Examples\n\nHMAC-SHA256 (one-shot):\n\n```ts\nconst mac = crypto.hmacSha256(key, message);\nconst hex = crypto.toHex(mac);\n```\n\nAES-256-GCM via `subtle`:\n\n```ts\nconst key = new Uint8Array(32); crypto.getRandomValues(key);\nconst iv = new Uint8Array(12); crypto.getRandomValues(iv);\n\nconst k = crypto.subtle.importKey('raw', key, new AesGcmParams(iv, aad, 128), false, USAGE_ENCRYPT);\nconst ct = crypto.subtle.encrypt(new AesGcmParams(iv, aad, 128), k, plaintext);\n```\n\n## Post-quantum verify\n\nThe host also exposes ML-DSA-44 (FIPS 204) signature verification as\n`crypto.mldsa_verify`. It is verify-only, the host never holds a secret key, and\nunderpins the [auth primitive](./auth.md). Most code reaches it through\n`AuthService.verifyLogin(publicKey, message, signature)` rather than calling the\nimport directly. Public key is 1312 bytes, signature 2420 bytes, with a FIPS 204\ndomain-separation context.\n\n## Limitations\n\n- **No Promises**, every call is synchronous.\n- **No RSA** and **no JWK** key format.\n- **P-521** is not supported (P-256 and P-384 are).\n- Signature *generation* for ML-DSA is client-side only; the server verifies.\n",
|
|
27
27
|
"time.md": "# Time\n\n`Time` is the guest's wall-clock. It is the toiljs-blessed way to read the\ncurrent time, backed by the host's `Date.now()` binding (`env.Date.now`). Both\nthe edge and the dev server provide that binding, so time behaves identically in\n`toiljs dev` and in production.\n\nIt is available as an ambient global (`@global`, no import) and is also exported\nfrom `toiljs/server/runtime`.\n\n```ts\nimport { Time } from 'toiljs/server/runtime'; // optional; Time is also a global\n\nconst ms = Time.nowMillis(); // u64 milliseconds since the Unix epoch\nconst s = Time.nowSeconds(); // u64 whole seconds since the Unix epoch\n```\n\n## API\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `Time.nowMillis()` | `static nowMillis(): u64` | Milliseconds since the Unix epoch (the raw host `Date.now()` value). |\n| `Time.nowSeconds()` | `static nowSeconds(): u64` | Whole seconds since the epoch (`nowMillis() / 1000`). The unit used by sessions and login challenges. |\n\n## Semantics\n\n`Time` is **wall-clock, not monotonic**, exactly like browser `Date.now()`. It\ntracks the system clock and can step backward across an NTP correction.\n\n- Use it to stamp and compare absolute instants: session `iat`/`exp`, login\n challenge expiry, cache ages.\n- Do **not** use it to measure elapsed time or as a high-resolution timer; a\n backward step would produce a negative or zero interval.\n\n## Relationship to `Date.now()`\n\nToilScript's `Date.now()` lowers to the same `env.Date.now` host import, so you\n*can* call it directly. Prefer `Time`: it makes the host boundary (and the\nsingle millisecond unit) explicit and easy to find, and it gives you\n`nowSeconds()` without an open-coded `/ 1000` cast at every call site.\n\n`AuthService` uses `Time.nowSeconds()` internally for session `iat`/`exp`, so\nsession timing and any timing you do in a handler share one clock.\n",
|
|
28
28
|
"cli.md": "# CLI\n\n- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,\n `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.\n- `toiljs dev`, dev server with HMR (`--port`, `--root`). With a `toilconfig.json` it builds\n the server first, then rebuilds it whenever a `server/` file changes (regenerating\n `shared/server.ts`, which Vite HMRs into the client); client-only edits just HMR the client.\n- `toiljs build`, production build. With a `toilconfig.json` it builds the server (toilscript,\n regenerating `shared/server.ts`) first, then the client → `build/client`. `--server` builds\n only the server. Every `server/` file declaring a surface (`@data`/`@rest`/...) is compiled.\n- `toiljs start`, self-host the built app with production hyper-express/uWS static workers,\n SSR/wasm dispatch, daemon support, and a `/_toil` WebSocket channel. Use `--threads <n>`\n (or `server.threads`) to set the worker count; `1` disables the pool.\n- `toiljs configure`, toggle styling features on an existing project (see [styling.md](./styling.md)).\n- `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC\n setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the\n toilscript prettier plugin) so an existing project upgrades in one command.\n",
|
|
29
|
+
"auth/configuration.md": "# Configuring auth for production\n\nBuilt-in auth runs locally with **zero configuration** — it falls back to published, insecure DEV secrets\nso `toiljs dev` Just Works. **A deployment MUST replace all three secrets and pin its KEM key.** This page\nis the checklist.\n\n## The secrets\n\nAuth reads these from the tenant environment store (locally, `.env.secrets`; on the edge, the per-host\nsecure env). They resolve **lazily** the first time auth runs, so no startup wiring is needed.\n\n| Key | What it is | Dev fallback |\n| --- | --- | --- |\n| `AUTH_SESSION_SECRET` | HMAC-SHA256 key that signs the session cookie. Must be identical on every edge instance (a cookie minted anywhere must verify everywhere). | a public constant — **anyone can forge a session** |\n| `AUTH_OPRF_SEED` | Master seed for the per-user OPRF salt key. Rotating it invalidates every password (users must re-register). | a hashed public constant |\n| `AUTH_KEM_SK` | The server's ML-KEM-768 **secret** key (hex). Its public half is what the client encapsulates to. | a pinned dev key pair |\n\n```bash\n# .env.secrets (gitignored; mode 0600 on the edge, NEVER under hosts/, NEVER in the .wasm)\nAUTH_SESSION_SECRET=…64 hex chars (32 bytes)…\nAUTH_OPRF_SEED=…64 hex chars (32 bytes)…\nAUTH_KEM_SK=…hex of an ML-KEM-768 secret key…\n```\n\n### Generating them\n\n`AUTH_SESSION_SECRET` and `AUTH_OPRF_SEED` are just 32 random bytes each:\n\n```bash\nnode -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"\n```\n\n`AUTH_KEM_SK` is an ML-KEM-768 key pair. Generate it and keep BOTH halves — the secret goes in the env, the\npublic half is pinned in the client (next section):\n\n```ts\nimport { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem';\nconst { secretKey, publicKey } = ml_kem768.keygen();\nconsole.log('AUTH_KEM_SK =', Buffer.from(secretKey).toString('hex'));\nconsole.log('client serverKemPublicKey =', Buffer.from(publicKey).toString('hex'));\n```\n\n## Pin the client's KEM public key\n\nThe browser must know the server's genuine KEM public key to run the mutual-auth handshake — this is the\nanti-phishing anchor. The `toiljs/client` `Auth` helper ships with the **dev** key pinned, so a deployment\nMUST pass its own:\n\n```ts\nimport { Auth } from 'toiljs/client';\n\nconst SERVER_KEM_PUBLIC_KEY = /* the publicKey bytes from AUTH_KEM_SK */;\n\nawait Auth.login(username, password, { serverKemPublicKey: SERVER_KEM_PUBLIC_KEY });\nawait Auth.register(username, password, { serverKemPublicKey: SERVER_KEM_PUBLIC_KEY });\n```\n\nShip the public key with your client bundle (it's public — safe to embed). If it doesn't match the server's\n`AUTH_KEM_SK`, login's `serverConfirm` check fails and the client aborts.\n\n## Optional: audience & domain\n\nBoth are optional and have sensible defaults; set them for stability across host aliases:\n\n| Key | Meaning | Default |\n| --- | --- | --- |\n| `TOIL_AUTH_AUDIENCE` | The service audience bound into the signed login message. | `\"toil\"` |\n| `TOIL_AUTH_DOMAIN` | The `domain` input of the stable `ToilUserId` (`sha256(pubkey ‖ username ‖ domain)`). | the request `Host` header, else `localhost` |\n\nSet `TOIL_AUTH_DOMAIN` explicitly if your site answers on multiple hostnames — otherwise the same user could\nget different `ToilUserId`s from different aliases. Once users exist, changing it changes everyone's id, so\npick it before launch.\n\n## Argon2id strength (known limitation)\n\nThe built-in controller currently uses **demo-light** Argon2id params (32 MiB, 2 iterations, 1 lane) so it\nstays responsive in a browser tab. These are baked into the shipped controller today; **config-driven\ntuning is a planned follow-up.** For a high-value production deployment you should either wait for the\nconfig knob or hand-write your own controller with `≥ 256 MiB / ≥ 3 iterations`. The OPRF still provides the\nprimary offline-attack resistance regardless, but raise these before protecting anything sensitive.\n\nThe client always derives against whatever params the server returns in `/login/start`, so when the config\nknob lands you can raise them server-side with **no client change**.\n\n## Deploy checklist\n\n- [ ] `AUTH_SESSION_SECRET` set (32 random bytes), identical on every edge instance.\n- [ ] `AUTH_OPRF_SEED` set (32 random bytes).\n- [ ] `AUTH_KEM_SK` set (an ML-KEM-768 secret key), and its **public** half pinned in the client via\n `serverKemPublicKey`.\n- [ ] `TOIL_AUTH_DOMAIN` set if you serve multiple hostnames (stable `ToilUserId`).\n- [ ] (Recommended) Argon2id params reviewed for your threat model.\n\nThe CLI doctor warns when `server.auth` is on and the secrets are missing — run it before you ship.\n",
|
|
30
|
+
"auth/extending.md": "# Extending & integrating auth\n\nBuilt-in auth is deliberately opinionated so the common case is one line. This page covers the identity\nyou build ON — `ToilUserId` — and how to go beyond the defaults: keying your own data on a user, a custom\nuser shape, and hand-writing auth from the same primitives.\n\n## `ToilUserId`\n\nThe stable, tenant-scoped user identity: `sha256(mldsaPublicKey ‖ identifier ‖ domain)`, a 256-bit value.\nIt's a **global** (no import), like `crypto`.\n\n```ts\n// Read the current user's id in any handler (gate on hasSession() — see the null note below).\nconst id: ToilUserId = AuthService.userId()!;\n\n// Or derive one yourself.\nconst id2 = ToilUserId.derive(mldsaPublicKey, 'alice@example.com', 'acme.dacely.com');\n```\n\n| Member | Description |\n| --- | --- |\n| `ToilUserId.derive(pk, identifier, domain)` | Derive from an ML-DSA public key + email/username + tenant domain. Deterministic. |\n| `ToilUserId.fromBytes(b)` | Rebuild from a 32-byte digest (from `toBytes()` or storage). |\n| `toBytes(): Uint8Array` | The 32 identity bytes. |\n| `toHex(): string` | Lowercase 64-char hex — a convenient string key. |\n| `isZero(): bool` | True for the unset / anonymous id. |\n| `equals(other): bool` | Value equality. |\n| `a == b` / `a != b` | Overloaded value comparison — **O(1)** (four `u64` word compares, no byte loop, no allocation). |\n\n```ts\nconst a = ToilUserId.derive(pk, 'alice', 'acme.com');\nconst b = ToilUserId.derive(pk, 'alice', 'acme.com');\nconst c = ToilUserId.derive(pk, 'bob', 'acme.com');\na == b; // true — same inputs, same id\na != c; // true — different user\n```\n\n> **Null-check gotcha:** because `ToilUserId` overloads `==`, `AuthService.userId() == null` does NOT\n> type-check (`==` expects a `ToilUserId`). Gate with `AuthService.hasSession()` and then `userId()!`, or\n> compare with `getUser()` (a plain nullable). `===` is reference identity in AssemblyScript and is not\n> overloadable — use `==` for value equality.\n\n## Keying your own data on the user\n\n`toilUserId` is the right key for per-user data — it's stable across sessions/devices and opaque. Use the\nhex as a string key, or the bytes in a `@data` key class:\n\n```ts\n@data\nclass UserKey {\n id: Uint8Array = new Uint8Array(0); // toilUserId bytes\n constructor(id: Uint8Array = new Uint8Array(0)) { this.id = id; }\n}\n\n@data class Profile { displayName: string = ''; bio: string = ''; }\n\n@database\nclass AppDb {\n @collection static profiles: Documents<UserKey, Profile>;\n}\n\n@rest('profile')\nclass ProfileApi {\n @auth\n @post('/')\n public save(ctx: RouteContext): Response {\n const key = new UserKey(AuthService.userId()!.toBytes());\n const p = Profile.decode(ctx.request.body);\n AppDb.profiles.enqueue(key, p); // upsert this user's profile\n return Response.text('saved\\n');\n }\n}\n```\n\n## A custom user shape — opt out and hand-write\n\nBuilt-in auth ships the single `@user` (`{ toilUserId, username }`), and there is exactly **one `@user` per\nprogram**. If you need a richer authenticated user (roles, a display name, a tenant), do **not** enable\n`server.auth`; hand-write a controller and your own `@user` using the same primitives. The\n`examples/basic` app does exactly this — copy `server/routes/Auth.ts` + `server/routes/Session.ts` as your\nstarting point. The shape:\n\n```ts\n@user\nclass Account {\n username: string = '';\n admin: bool = false;\n score: u64 = 0;\n}\n\n// After verifyLogin succeeds, mint your own session payload:\nresp.setCookie(AuthService.mintSession(account.encode(), 3600));\nresp.setCookie(AuthService.userCookie(account.encode(), 3600));\n```\n\nYou still get `@auth`, `AuthService.getUser()` (typed to YOUR `@user`), and every crypto primitive — you're\njust choosing your own routes and user fields. You can still derive a `ToilUserId` yourself and put it in\nyour `@user` if you want the stable id.\n\n## Adding email verification / 2FA\n\nLayer a second factor on top of the session with `TwoFactor` (stateless email codes — no DB; see\n[email](../email.md)). Typical flow: after login, require a verified email before granting access to\nsensitive routes.\n\n```ts\n@rest('2fa')\nclass TwoFactorApi {\n // Step 1: email a code to the logged-in user, hand back the signed token.\n @auth @post('/send')\n public send(): Response {\n const email = /* the user's email — e.g. their username, or a stored profile field */;\n const ch = TwoFactor.send(email, 'login'); // emails the code, returns { token, status }\n return Response.bytes(new DataWriter().writeString(ch.token).toBytes());\n }\n\n // Step 2: verify the code the user typed against the token.\n @auth @post('/verify')\n public verify(ctx: RouteContext): Response {\n const r = new DataReader(ctx.request.body);\n const token = r.readString(); const email = r.readString(); const code = r.readString();\n if (!TwoFactor.verify(token, email, code)) return Response.text('bad code\\n', 401);\n // Mark this session 2FA-verified: re-mint the session with a flag in your own @user, or store a\n // per-user \"verified\" record keyed on AuthService.userId().\n return Response.text('verified\\n');\n }\n}\n```\n\n`TwoFactor` gives integrity + expiry but not single-use (a code re-verifies within its TTL); keep the TTL\nshort. For a branded email, use `TwoFactor.issue(...)` (returns the code without sending) + your own\n`Emails.*` template. Call `TwoFactor.setSecret(...)` once at startup in production.\n\n## The `AuthService` primitive reference\n\nEverything the built-in controller is built from, available for hand-written auth. All are ambient globals\n(no import).\n\n**Sessions & cookies**\n- `mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie` — the signed `__Host-toil_sess` cookie.\n- `userCookie(userData, ttlSecs?): Cookie` — the readable `__Secure-toil_user` companion.\n- `clearSession(): Cookie` / `clearUserCookie(): Cookie`.\n- `hasSession(): bool` — the `@auth` predicate.\n- `getSessionBytes(): Uint8Array | null` — the verified `@user` payload bytes.\n- `getUser(): <your @user> | null` — decoded, typed to your `@user`.\n- `userId(): ToilUserId | null` — the stable id (built-in `@user` layout).\n- `setSecret(secret: Uint8Array)` — override the session HMAC key programmatically.\n\n**Post-quantum login crypto**\n- `oprfEvaluate(username, blinded): Uint8Array` — server-keyed OPRF eval.\n- `buildRegisterMessage(username, pk)` / `verifyRegister(pk, msg, sig): bool` — proof-of-possession.\n- `buildLoginMessage(sub, aud, cid, nonce, iat, exp, ct, memKiB, iterations, parallelism, serverKemKeyId)`\n / `verifyLogin(pk, msg, sig): bool`.\n- `mlkemDecapsulate(ct): Uint8Array` · `serverKemKeyId(): Uint8Array`.\n- `deriveSessionKey(sharedSecret, transcriptHash)` · `serverConfirmTag(sessionKey, transcriptHash)` — the\n mutual-auth confirmation.\n- `sha256(data): Uint8Array`.\n- `setOprfSeed(seed)` · `setServerKemSecretKey(sk)` · `setServerKemPublicKey(pk)` — override the seeds/keys.\n- Sizes: `PUBLIC_KEY_LEN`, `SIGNATURE_LEN`, `OPRF_ELEMENT_LEN`, `KEM_CIPHERTEXT_LEN`, `SHARED_SECRET_LEN`, …\n\n**Related globals** — `TwoFactor` (email codes; see [email](../email.md)), `RateLimitService` (used by\n`@ratelimit`), `Environment` (the secret store).\n\n## Two ways in, one behavior\n\nThe config flag and the import are identical at build time — both make the build append the shipped\n`@user` + `@rest('auth')` controller to the toilscript **entry** set, where their decorators weave and the\n`@rest` class self-mounts. (A framework decorator source only weaves as an entry; that's why a plain\n`import` of the controller isn't enough on its own — the marker import is detected by the build, which does\nthe entry injection.) This is why built-in auth needs no runtime registration call.\n",
|
|
31
|
+
"auth/how-it-works.md": "# How Toil auth works\n\nToil auth is a **post-quantum, password-based, mutually-authenticated** login. The design goal: the user\ntypes a password, but the server never sees it, never stores it, and can't be phished into leaking a\nverifier — and every primitive is quantum-resistant.\n\nThis page explains the protocol. You do not need to understand it to use auth (see [Usage](./usage.md)),\nbut you should understand the guarantees before you ship.\n\n## The building blocks\n\n| Primitive | Role |\n| --- | --- |\n| **OPRF** (RFC 9497, ristretto255-SHA512) | A *server-keyed* salt. The client blinds its password, the server evaluates it under a per-user key, the client unblinds. The result can't be computed without the server, so a stolen database can't be brute-forced offline. |\n| **Argon2id** | Stretches the OPRF output into key material (memory-hard, GPU/ASIC-resistant). Runs in the browser. |\n| **ML-DSA-44** (FIPS 204) | The user's identity key pair is *derived* from the Argon2id output. The server stores only the **public** key. Login is proved by an ML-DSA signature. |\n| **ML-KEM-768** (FIPS 203) | Key encapsulation on login: the server proves it holds its KEM secret by returning a confirmation tag only derivable from the decapsulated shared secret → **mutual** auth (anti-phishing). |\n| **HMAC-SHA256** | Signs the session cookie (`AUTH_SESSION_SECRET`). |\n\n> The whole thing is sometimes called an *aPAKE* (asymmetric password-authenticated key exchange). Do not\n> call it OPAQUE — this is Toil's own OPRF + KEM + signature construction.\n\n## What the server stores\n\nPer account, in ToilDB (`@database AuthDb`):\n\n- `username`, a deterministic `salt`, the **ML-DSA public key**, and the Argon2id params.\n\nThat's it. **No password, no password hash, no verifier that can be brute-forced without the OPRF key.**\nLogin challenges are a second collection, consumed exactly once (atomic `getDelete`).\n\n## Registration\n\n```\n Browser (client) Toil edge (server)\n ───────────────── ──────────────────\n blind(password) OPRF key = f(seed, username)\n │ username, blinded │\n │ ───────── POST /auth/register/start ───────────► │\n │ │ evaluated = OPRF(blinded)\n │ ◄──── {mem,iters,par, salt, evaluated} ───────── │ (salt is deterministic per user)\n │ │\n unblind → oprfOut │\n seed = Argon2id(oprfOut, salt, params) │\n (pk, sk) = ML-DSA-44.keygen(seed) │\n proof = ML-DSA.sign(sk, \"register|username|pk\") │\n │ username, pk, proof │\n │ ───────── POST /auth/register/finish ──────────► │\n │ │ verifyRegister(pk, msg, proof) ✓ proof-of-possession\n │ ◄──────────── {status: 0 ok | 1 taken} ──────── │ store AuthAccount{username, salt, pk, params}\n```\n\nThe server never learns the password or the secret key `sk` — only the public key `pk` and a proof the\nclient holds the matching `sk`. A duplicate username returns a **distinguishable** `status = 1` (so the UI\ncan say \"taken, log in instead\"); everything else fails generically.\n\n## Login (with mutual auth)\n\n```\n Browser (client) Toil edge (server)\n ───────────────── ──────────────────\n blind(password) │\n │ username, blinded │\n │ ───────── POST /auth/login/start ──────────────► │ evaluated = OPRF(blinded) (ALWAYS, even unknown user)\n │ │ if known: store Challenge{cid, nonce, iat, exp}\n │ ◄─ {cid, aud, params, salt, nonce, iat, exp, ── │ (identical response whether the user exists or not)\n │ evaluated} │\n unblind → Argon2id → (pk, sk) = ML-DSA.keygen │\n (ct, ssС) = ML-KEM-768.encapsulate(serverKemPublicKey) │\n msg = \"login|username|aud|cid|nonce|iat|exp|ct|params|kid\" │\n sig = ML-DSA.sign(sk, msg) │\n │ cid, ct, sig │\n │ ───────── POST /auth/login/finish ─────────────► │ ch = challenges.getDelete(cid) (consume once; check exp)\n │ │ rebuild msg from OUR stored values + ct\n │ │ verifyLogin(acct.pk, msg, sig) ✓ it's really this user\n │ │ ssS = ML-KEM.decapsulate(ct) ✓ we hold the KEM key\n │ │ K = deriveSessionKey(ssS, H(msg))\n │ ◄──── {0, sessionToken, serverConfirm} ──────── │ serverConfirm = tag(K, H(msg))\n │ + Set-Cookie: __Host-toil_sess=… │ mint session cookie\n verify serverConfirm using ssC ✓ the server is genuine │\n```\n\nTwo verifications, both required:\n1. **The server verifies the client** — the ML-DSA signature over a message bound to the challenge, the\n Argon2id params, and the server's KEM key id. Replays fail (the challenge is consumed).\n2. **The client verifies the server** — the `serverConfirm` tag is derivable only from the ML-KEM shared\n secret, which only the holder of the KEM secret key can decapsulate. A phishing site can't forge it.\n\n## Anti-enumeration\n\n`/login/start` behaves identically for a known and an unknown user: it always runs the OPRF (a decoy key\nfor unknown users), returns a **deterministic** per-user salt and constant params, and a fresh challenge.\nThe challenge is persisted only for a real account, and `/login/finish` fails generically at consume for an\nunknown user. So an attacker can't probe which usernames exist. Every failure path returns the same\n`401 auth: request failed`.\n\n## Sessions & cookies\n\nOn successful login the server mints **two** cookies:\n\n- **`__Host-toil_sess`** — the authoritative session. HMAC-SHA256 signed with `AUTH_SESSION_SECRET`,\n `HttpOnly`, `Secure`, `SameSite=Lax`. It carries the `@user` codec payload. `@auth` and\n `AuthService.getUser()` open + verify this cookie server-side; a forged or tampered cookie fails.\n- **`__Secure-toil_user`** — a **readable** companion carrying the same payload, so the browser can show\n \"logged in as …\" via the client's `getUser()`. The server **never trusts it** — it is display-only.\n\nEach request runs in a fresh wasm instance, but the signed cookie is self-contained, so no server-side\nsession store is needed. `AUTH_SESSION_SECRET` must be identical across every edge instance (it is, via the\nenv store) so a cookie minted anywhere verifies everywhere.\n\n## The stable user identity — `ToilUserId`\n\nAt login the server derives a stable, tenant-scoped id and stores it in the session (the first field of the\nbuilt-in `@user`):\n\n```\ntoilUserId = SHA-256( mldsaPublicKey ‖ username ‖ domain ) // 256 bits\n```\n\n- **Stable:** same login key + username on the same tenant `domain` → same id, forever, across sessions\n and devices. Key your own data on it.\n- **Opaque + one-way:** it's a hash — safe to store, log, or expose without leaking the key or the address.\n- Read it anywhere with `AuthService.userId()`. See [Extending](./extending.md#toiluserid) for the\n `ToilUserId` API (O(1) `==` / `!=`, `toHex()`, …).\n\n## Threat-model summary\n\n- **Server database stolen** → attacker gets public keys + salts, not passwords. Brute-forcing needs the\n OPRF key (server-side) *and* Argon2id work per guess.\n- **Server compromised / malicious** → still can't recover passwords (only public keys) and can't forge a\n past session without `AUTH_SESSION_SECRET`.\n- **Phishing site** → can't produce the `serverConfirm` tag (no KEM secret), so a correct client aborts.\n- **Quantum adversary** → ML-DSA + ML-KEM are post-quantum; the OPRF/Argon2id/HMAC pieces are classical but\n not the long-term identity or key-exchange.\n- **Replay** → challenges are single-use (`getDelete`) with a short TTL.\n\nResidual responsibilities are yours: set the [secrets](./configuration.md), pin your deployment's KEM\npublic key in the client, and raise the Argon2id params for production.\n",
|
|
32
|
+
"auth/index.md": "# Authentication\n\nToil ships a complete, **post-quantum password login** you turn on with one line. No passwords on your\nserver, no third-party identity provider, no hand-written crypto: enable it and you get a `/auth/*` API,\nsigned sessions, `@auth`-guarded routes, and a stable per-user identity.\n\n```ts\n// toil.config.ts\nimport { defineConfig } from 'toiljs/compiler';\n\nexport default defineConfig({\n server: { auth: true }, // ← mounts the full /auth/* API + sessions\n});\n```\n\nThat is the whole setup. The build appends the framework's auth controller to your server, so `/auth/*`\nis live and `@auth` works everywhere.\n\n## What you get\n\n| Endpoint | Purpose |\n| --- | --- |\n| `POST /auth/register/start` · `/register/finish` | Create an account (password never leaves the browser) |\n| `POST /auth/login/start` · `/login/finish` | Log in; sets a signed session cookie |\n| `GET /auth/me` *(`@auth`)* | The current user (`toilUserId` + `username`) |\n| `POST /auth/logout` *(`@auth`)* | Clear the session |\n\nPlus, in your own code:\n\n- **`@auth`** on any route (or a whole `@rest` class) → 401 unless there's a valid session.\n- **`AuthService.getUser()`** → the typed logged-in user.\n- **`AuthService.userId()`** → the stable [`ToilUserId`](./extending.md#toiluserid) (a 256-bit id you can\n key your data on).\n- **The client** — `import { Auth } from 'toiljs/client'`, then `Auth.register(username, password)` /\n `Auth.login(username, password)`. It does all the browser-side crypto and talks to `/auth/*` for you.\n\n## A login page in full\n\n```tsx\n// client/routes/login.tsx\nimport { useState } from 'react';\nimport { Auth } from 'toiljs/client';\n\nexport default function Login() {\n const [u, setU] = useState('');\n const [p, setP] = useState('');\n const [msg, setMsg] = useState('');\n\n const register = async () => {\n try { await Auth.register(u, p); setMsg('registered — now log in'); }\n catch (e) { setMsg(parseError(e)); }\n };\n const login = async () => {\n try { await Auth.login(u, p); setMsg('logged in'); } // sets the session cookie\n catch (e) { setMsg(parseError(e)); }\n };\n\n return (\n <main>\n <input value={u} onChange={(e) => setU(e.currentTarget.value)} placeholder=\"username\" />\n <input value={p} type=\"password\" onChange={(e) => setP(e.currentTarget.value)} placeholder=\"password\" />\n <button onClick={register}>Register</button>\n <button onClick={login}>Log in</button>\n <p>{msg}</p>\n </main>\n );\n}\n```\n\n```ts\n// server/routes/Secret.ts — a route only a logged-in user can reach\nimport { Response } from 'toiljs/server/runtime';\n\n@rest('secret')\nclass Secret {\n @auth // 401 without a valid session\n @get('/')\n public secret(): Response {\n const user = AuthService.getUser()!; // typed: { toilUserId, username }\n return Response.text('hello ' + user.username + '\\n');\n }\n}\n```\n\nThat's a real, production-grade auth system — the password is stretched with Argon2id in the browser into\nan ML-DSA-44 key pair, your server only ever stores a public key, and login is a mutually-authenticated\nML-KEM-768 challenge. You didn't write any of it.\n\n## Where to go next\n\n- **[How it works](./how-it-works.md)** — the protocol (OPRF + Argon2id + ML-DSA + ML-KEM), sessions,\n cookies, and the `ToilUserId`, with sequence diagrams.\n- **[Usage](./usage.md)** — enabling it, the client API, guarding routes, reading the user, the full wire\n contract of each endpoint.\n- **[Configuration](./configuration.md)** — the secrets a deployment MUST set, the audience/domain, tuning\n Argon2id, and the deploy checklist.\n- **[Extending & integrating](./extending.md)** — `ToilUserId`, keying your own data on a user, a custom\n user shape / opting out, and the `AuthService` primitive reference.\n\n> **One rule before you ship:** built-in auth runs with **insecure DEV fallback secrets** so it Just Works\n> locally. A deployment MUST set `AUTH_SESSION_SECRET`, `AUTH_OPRF_SEED`, and `AUTH_KEM_SK`. See\n> [Configuration](./configuration.md).\n",
|
|
33
|
+
"auth/usage.md": "# Using auth\n\n## 1. Enable it\n\nEither the config flag (canonical) or a one-line import — both do the same thing (the build appends the\nframework's auth controller + user shape to your server as compiled entries, so `/auth/*` self-mounts):\n\n```ts\n// toil.config.ts — canonical\nexport default defineConfig({ server: { auth: true } });\n```\n\n```ts\n// server/main.ts — escape hatch (equivalent)\nimport 'toiljs/server/auth';\n```\n\nThere is also an `AuthService.enable()` no-op you can call for discoverability, but enabling is done by the\nflag/import above (it happens at build time). Turn it off by removing the flag/import — with neither, no\n`/auth` routes and no accounts collection are generated. It is strictly opt-in.\n\n## 2. The client\n\n`toiljs/client` ships the browser half — it runs the OPRF blinding, Argon2id, ML-DSA keygen, and ML-KEM\nencapsulation, and talks to `/auth/*`. You never touch the crypto.\n\n```ts\nimport { Auth } from 'toiljs/client';\n\n// Create an account. Throws on a taken username or a transport error.\nawait Auth.register(username, password);\n\n// Log in. On success the browser holds the signed session cookie; subsequent\n// requests to @auth routes are authorized automatically.\nawait Auth.login(username, password);\n```\n\nOptions (second argument, `AuthOptions`):\n\n```ts\nawait Auth.login(username, password, {\n baseUrl: '/auth', // default; change if you mount elsewhere\n serverKemPublicKey: MY_KEM_PUB, // REQUIRED in production — pin your deployment's KEM key\n});\n```\n\n> **Production:** the client ships with the DEV KEM public key pinned. A real deployment MUST pass its own\n> `serverKemPublicKey` (derived from `AUTH_KEM_SK`). See [Configuration](./configuration.md).\n\n## 2b. Client-side: who's logged in, and protecting pages\n\n`register`/`login` set the session cookies; to *render* login state on the client, use the generated\n`getUser()` (emitted from the built-in `@user`). It reads the **readable** `__Secure-toil_user` companion\ncookie and returns the typed user or `null` — instant, no network call. It is **display-only**; the server\nstill enforces access via `@auth`, so never trust it for authorization.\n\n```tsx\nimport { getUser } from 'shared/server'; // generated; typed to the built-in @user\n\nfunction Nav() {\n const user = getUser(); // { toilUserId, username } | null\n return user\n ? <span>Hi {user.username} <button onClick={logout}>Log out</button></span>\n : <a href=\"/login\">Log in</a>;\n}\n\nasync function logout() {\n await fetch('/auth/logout', { method: 'POST' });\n location.href = '/login'; // cookies are cleared; bounce to login\n}\n```\n\nGate a whole client route by redirecting when there's no session:\n\n```tsx\nexport default function Dashboard() {\n const user = getUser();\n if (user == null) { location.href = '/login'; return null; } // not logged in -> to /login\n return <main>Welcome, {user.username}</main>;\n}\n```\n\nThe authoritative check is still the server: any data this page fetches should come from an `@auth` route,\nso a user who forged/deleted the readable cookie sees the redirect OR a `401`, never real data.\n\n## 2c. Handling errors\n\n`Auth.register` / `Auth.login` reject with a message you can show. The important distinguishable cases:\n\n```ts\ntry {\n await Auth.register(username, password);\n} catch (e) {\n const m = String(e);\n if (m.includes('already registered')) setError('That username is taken — log in instead.');\n else setError('Could not register, try again.');\n}\n\ntry {\n await Auth.login(username, password);\n} catch {\n // Wrong password OR unknown user both fail generically (anti-enumeration) — one message.\n setError('Incorrect username or password.');\n}\n```\n\n- **Username taken** → `register` throws `auth: username already registered (log in instead)` (a\n distinguishable case so you can guide the user).\n- **Wrong password / unknown user** → `login` throws generically (`auth: request failed`) — by design,\n the two are indistinguishable, so use ONE \"incorrect username or password\" message.\n- **Rate limited** → after 5 attempts / 60s a `429` surfaces as the same generic throw; back off and tell\n the user to wait.\n\n## 3. Guard your routes — `@auth`\n\nPut `@auth` on a route method or a whole `@rest` class. The generated dispatcher checks for a valid signed\nsession **before** your handler runs and returns `401` otherwise. `@auth` is unchanged by built-in auth —\nit's the same decorator you'd use with hand-written auth.\n\n```ts\n@rest('account')\nclass AccountApi {\n @auth // this route needs a session\n @get('/settings')\n public settings(): Response { /* … */ }\n\n @get('/public') // this one is open\n public open(): Response { /* … */ }\n}\n\n@auth // …or guard the ENTIRE class\n@rest('admin')\nclass AdminApi { /* every route requires a session */ }\n```\n\n## 4. Read the current user\n\nInside any handler:\n\n```ts\n// The typed logged-in user (the built-in `@user`: toilUserId + username), or null.\nconst user = AuthService.getUser();\nif (user != null) {\n user.username; // string\n user.toilUserId; // Uint8Array(32) — the stable id bytes\n}\n\n// The stable identity as a ToilUserId (gate on hasSession() first — see note).\nif (AuthService.hasSession()) {\n const id = AuthService.userId()!; // ToilUserId\n // key your own data on id (see Extending)\n}\n```\n\n> `ToilUserId` overloads `==`, so `AuthService.userId() == null` does not type-check. Gate with\n> `AuthService.hasSession()` and then use `userId()!`, or use `getUser()` and null-check that.\n\n## 5. The endpoint wire contract\n\nYou normally use the `Auth` client, but the raw endpoints are binary (`DataWriter`/`DataReader`, never\nJSON):\n\n| Route | Request body | Response |\n| --- | --- | --- |\n| `POST /auth/register/start` | `str(username) bytes(blinded)` | `u8(0) u32(mem) u32(iters) u32(par) bytes(salt) bytes(evaluated)` |\n| `POST /auth/register/finish` | `str(username) bytes(pubkey) bytes(proof)` | `u8(status)` — `0` ok, `1` username taken |\n| `POST /auth/login/start` | `str(username) bytes(blinded)` | `bytes(cid) str(aud) u32(mem) u32(iters) u32(par) bytes(salt) bytes(nonce) u64(iat) u64(exp) bytes(evaluated)` |\n| `POST /auth/login/finish` | `bytes(cid) bytes(ct) bytes(sig)` | `u8(0) bytes(sessionToken) bytes(serverConfirm)` + `Set-Cookie`, or `u8(≠0)` on failure |\n| `GET /auth/me` *(`@auth`)* | — | `bytes(toilUserId) str(username)` |\n| `POST /auth/logout` *(`@auth`)* | — | `200` + cookie-clearing `Set-Cookie` |\n\nRate limiting: every register/login POST carries `@ratelimit(SlidingWindow, 5, 60)` (5 requests / 60s per\nclient) out of the box, so brute-force is throttled before it reaches the crypto.\n\n## 6. Under `toiljs dev`\n\nEverything runs locally with **zero setup**: the dev server emulates the ToilDB account/challenge storage\nand the ML-DSA/ML-KEM/OPRF host functions in process, and the auth secrets fall back to insecure DEV\nvalues. Register and login span requests (the accounts persist for the dev session). You'll see a warning\nthat `AUTH_SESSION_SECRET` is unset — that's expected in dev; set it before you deploy (see\n[Configuration](./configuration.md)).\n\n## 7. `Server.REST.auth.*`\n\nBecause the controller is a normal `@rest('auth')` class, a typed `Server.REST.auth.*` fetch client is\ngenerated for free (`me`, `logout`, and the register/login methods). Use it for `/me` and `/logout`; but\n**drive register/login through `toiljs/client` `Auth`**, not the generated client — only the `Auth` helper\nruns the required browser-side OPRF/Argon2id/ML-DSA/ML-KEM crypto.\n",
|
|
29
34
|
"derive.md": "# Derive (materialized views)\n\n`@derive` precomputes a read-optimized **view** from your data so reads stay\nfast and never scan. A request handler (`@get` runs as a *query*, `@post`/`@put`/\n`@delete` as an *action*) is not allowed to scan, reading \"the latest N events\"\nor \"every member of a set\" could fan out across unbounded rows, so those scans\nare barred on the request path. A `@derive` does the scan **off** the request\npath: it folds your event log / counters into a `View`, and your route serves\nthat view with a single keyed read.\n\n```ts\n@database\nclass GuestbookDb {\n @collection static entries: Events<GuestKey, GuestEntry>;\n @collection static totals: Counter<GuestKey>;\n @collection static book: View<GuestKey, GuestbookView>;\n\n // Recompute the view from the sources. Runs after a signature is written\n // (and when a box first loads). A derive MAY scan + publish; a route may not.\n @derive\n recompute(): void {\n const key = new GuestKey('main');\n const view = new GuestbookView();\n view.total = GuestbookDb.totals.get(key); // counter read\n view.entries = GuestbookDb.entries.latest(key, 10); // scan, allowed here\n GuestbookDb.book.publish(key, view); // publish the materialized view\n }\n}\n```\n\n## Why a derive\n\nToilDB gates every data op by the *function kind* it runs under:\n\n- **query** (`@get`/`@head`) and **action** (`@post`/`@put`/`@patch`/`@delete`)\n may do keyed reads and (actions only) writes, but **not scans**\n (`events.latest`, `membership.list`).\n- **derive** may do everything a read can, **plus** scans, plus\n `view.publish`/`append`/`counter.add`.\n\nSo if a page needs \"the 10 newest entries\" or \"the leaderboard\", you cannot read\nthat directly in the `@get`. Instead a `@derive` builds it once into a `View`,\nand the `@get` reads the view by key, which is not a scan.\n\n## Declaring a derive\n\nA derive is a method on your `@database` class, alongside the collections it\nreads and the `View` it writes:\n\n```ts\n@database\nclass MyDb {\n @collection static events: Events<Key, Fact>; // a source\n @collection static home: View<Key, HomePage>; // the materialized view\n\n @derive\n rebuild(): void {\n // read sources, build the value, publish it\n }\n}\n```\n\nRules:\n\n- A `@derive` method takes **no arguments and returns `void`**.\n- A `@database` may declare **multiple** `@derive` methods; each is run\n independently.\n- The view value (`HomePage` above) and the key are ordinary `@data` types, so\n they round-trip through the codec like any other stored value.\n\n## `View<K, V>`\n\nA `View` is a published, read-optimized projection. Its API:\n\n```ts\nview.get(key) // V | null - the published view, or null if none yet\nview.require(key) // V - like get, but traps if nothing is published\nview.publish(key, value) // void - overwrite the view (derive/job only)\n```\n\n`publish` is only allowed from a `@derive` (or a `@job`); the host assigns the\nversion so a later publish always supersedes an earlier one. `get`/`require` are\nplain keyed reads, allowed from any handler, including a `@get` route.\n\n## When derives run\n\nYou never call a derive yourself. The runtime runs it for you:\n\n- **After a write to a source.** When a request writes one of a database's\n source collections (an `events.append`/`append_once`, a `counter.add`, or a\n record `create`/`patch`), that database's derives run right after the response\n is produced, so the view reflects the new data on the next read. Many writes to\n one database in a single request coalesce into one recompute.\n- **On box load.** When a server box starts or hot-reloads (or the underlying\n source data changed out of band), the views are rebuilt from their sources\n before the first read is served. This is also where a value type's `@migrate`\n runs against old stored events, as the derive re-reads and republishes them.\n\nA derive's own writes (its `view.publish`) never re-trigger it.\n\nThe same code runs under `toiljs dev` (the in-process emulator) and on the\nproduction edge, no flags or wiring to change.\n\n## Reading a view from a route\n\nThe route just reads the view by key, which is a non-scan read and so is legal in\na `@get`:\n\n```ts\n@rest('guestbook')\nclass Guestbook {\n @get('/')\n list(): GuestbookView {\n const key = new GuestKey('main');\n const view = GuestbookDb.book.get(key);\n return view == null ? new GuestbookView() : view; // empty until first publish\n }\n\n @post('/')\n sign(input: NewMessage): GuestbookView {\n const key = new GuestKey('main');\n GuestbookDb.entries.append(key, new GuestEntry(input.author, input.message, 0));\n GuestbookDb.totals.add(key, 1);\n // The @derive republishes `book` right after this action returns, so the\n // entries list is served by GET. The action just acks with the new total\n // (a counter read is allowed here; a scan is not).\n const view = new GuestbookView();\n view.total = GuestbookDb.totals.get(key);\n return view;\n }\n}\n```\n\n## How it fits together (the guestbook)\n\nThe `examples/basic` guestbook is the end-to-end demo:\n\n1. `POST /guestbook` (an action) appends the signature to an `Events` stream and\n bumps a `Counter`. It returns the running total, but it does **not** read the\n entry list (that would be a scan).\n2. The runtime then runs `@derive recompute()` under the derive kind: it scans\n `entries.latest(...)`, reads the `totals` counter, and `publish`es a fresh\n `GuestbookView`.\n3. `GET /guestbook` (a query) reads `book.get(...)`, a single keyed read, and\n returns the precomputed total + newest entries.\n\nSign twice and the total climbs across requests, because the data lives in\nToilDB (and its view), not in module memory.\n\n## Notes\n\n- A derive **recomputes** the view from whatever its method reads (here, the\n latest 10 events). It is a fresh recompute on each trigger, so it suits views\n built from a bounded read (latest N, a counter total, a small set). Folding an\n unbounded full event log incrementally is a separate, more advanced pattern.\n- Because publishes are last-writer-wins and a derive recomputes from the source\n of truth, a view always converges to a correct snapshot of its sources.\n- See also: [`data.md`](data.md) for `@data` value types, and the ToilDB host\n ABI for the exact `derive_run` / `toildb.derives` contract.\n",
|
|
30
35
|
};
|
|
@@ -16,8 +16,9 @@ import type { MemoryRef } from '../runtime/host.js';
|
|
|
16
16
|
|
|
17
17
|
/** Frame version + counter count, kept in lockstep with the edge (`analytics.rs` / `metric_id.rs`). */
|
|
18
18
|
const FRAME_VERSION = 2;
|
|
19
|
-
const METRIC_COUNTERS =
|
|
20
|
-
/** MetricId indices we seed with sample data (others default 0). Mirrors the wire contract
|
|
19
|
+
const METRIC_COUNTERS = 43;
|
|
20
|
+
/** MetricId indices we seed with sample data (others default 0). Mirrors the wire contract
|
|
21
|
+
* (counter ids 0..=42; gauge ids ConnectedStreamsAvg=43, CommittedMemoryAvg=45). */
|
|
21
22
|
const MID = {
|
|
22
23
|
Requests: 0,
|
|
23
24
|
BytesOutL1: 1,
|
|
@@ -33,6 +34,8 @@ const MID = {
|
|
|
33
34
|
StreamBytesIn: 25,
|
|
34
35
|
StreamBytesOut: 26,
|
|
35
36
|
MemGrownBytes: 39,
|
|
37
|
+
CacheHits: 41,
|
|
38
|
+
CacheMisses: 42,
|
|
36
39
|
} as const;
|
|
37
40
|
|
|
38
41
|
interface DevTenantStats {
|
|
@@ -63,6 +66,8 @@ function devStats(): DevTenantStats {
|
|
|
63
66
|
life[MID.StreamBytesIn] = 2048n;
|
|
64
67
|
life[MID.StreamBytesOut] = 8192n;
|
|
65
68
|
life[MID.MemGrownBytes] = 262144n;
|
|
69
|
+
life[MID.CacheHits] = 900n;
|
|
70
|
+
life[MID.CacheMisses] = 100n;
|
|
66
71
|
return {
|
|
67
72
|
life,
|
|
68
73
|
connectedStreams: 3,
|
|
@@ -103,7 +108,7 @@ function encodeStats(s: DevTenantStats): Buffer {
|
|
|
103
108
|
|
|
104
109
|
/** The metric ids carried in the per-minute ring (mirrors the edge `MINUTE_RING_METRICS`). Only these get
|
|
105
110
|
* minute resolution on the 1h/6h ranges; every other metric falls back to the hour ring there. */
|
|
106
|
-
const MINUTE_RING_METRICS = new Set<number>([0, 2, 1, 25, 26, 12, 13, 39,
|
|
111
|
+
const MINUTE_RING_METRICS = new Set<number>([0, 2, 1, 25, 26, 12, 13, 39, 43, 45]);
|
|
107
112
|
|
|
108
113
|
/**
|
|
109
114
|
* Range id -> (bucketCount, bucketSecs), matching the edge `Range` + minute-ring fallback: 1h/6h are
|
|
@@ -206,7 +211,8 @@ export function buildAnalyticsImports(
|
|
|
206
211
|
range: number,
|
|
207
212
|
): number => {
|
|
208
213
|
if (domainLen > MAX_DOMAIN_LEN) return ABSENT;
|
|
209
|
-
|
|
214
|
+
// Valid metric ids are 0..=46 (counters 0..=42 + the 4 gauge avg/peak series), ranges 0..=7.
|
|
215
|
+
if (metricId < 0 || metricId > 46 || range < 0 || range > 7) return ABSENT;
|
|
210
216
|
if (domainLen > 0) {
|
|
211
217
|
if (!ref.memory) throw new Error('analytics_series called before memory was bound');
|
|
212
218
|
const m = Buffer.from(ref.memory.buffer);
|
|
@@ -32,12 +32,13 @@ describe('dev analytics stub v2 frame parity', () => {
|
|
|
32
32
|
expect(u16()).toBe(2); // FRAME_VERSION
|
|
33
33
|
u64(); // now_ms
|
|
34
34
|
const count = u32();
|
|
35
|
-
expect(count).toBe(
|
|
35
|
+
expect(count).toBe(43); // METRIC_COUNTERS
|
|
36
36
|
const life: bigint[] = [];
|
|
37
37
|
for (let i = 0; i < count; i++) life.push(i64());
|
|
38
38
|
expect(life[0]).toBe(42n); // Requests
|
|
39
39
|
expect(life[1]).toBe(12345n); // BytesOutL1
|
|
40
40
|
expect(life[12]).toBe(1_000_000n); // GasUsed
|
|
41
|
+
expect(life[41]).toBe(900n); // CacheHits
|
|
41
42
|
expect(i64()).toBe(3n); // connectedStreams
|
|
42
43
|
expect(i64()).toBe(65536n); // committedMemory
|
|
43
44
|
expect(i64()).toBe(5n); // reqMinuteUsed
|