toiljs 0.0.85 → 0.0.87
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/README.md +2 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +303 -293
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/docs.js +8 -24
- package/build/compiler/generate.js +4 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +69 -6
- package/build/compiler/toil-docs.generated.js +64 -22
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +7 -3
- package/build/devserver/db/database.d.ts +4 -0
- package/build/devserver/db/database.js +43 -1
- package/build/devserver/db/index.d.ts +1 -1
- package/build/devserver/db/index.js +1 -1
- package/build/devserver/db/types.d.ts +3 -0
- package/build/devserver/db/types.js +2 -0
- package/build/devserver/runtime/module.js +4 -1
- package/docs/README.md +104 -65
- package/docs/auth/README.md +102 -0
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +202 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/usage.md +188 -0
- package/docs/backend/README.md +143 -0
- package/docs/backend/data.md +351 -0
- package/docs/backend/rest.md +402 -0
- package/docs/backend/rpc.md +226 -0
- package/docs/background/README.md +114 -0
- package/docs/background/daemons.md +230 -0
- package/docs/background/derive.md +179 -0
- package/docs/cli/README.md +377 -0
- package/docs/concepts/config.md +416 -0
- package/docs/concepts/decorators.md +127 -0
- package/docs/concepts/security.md +108 -0
- package/docs/concepts/tiers.md +166 -0
- package/docs/concepts/types.md +216 -0
- package/docs/database/README.md +143 -0
- package/docs/database/capacity.md +350 -0
- package/docs/database/counters.md +174 -0
- package/docs/database/documents.md +255 -0
- package/docs/database/events.md +307 -0
- package/docs/database/membership.md +246 -0
- package/docs/database/setup.md +155 -0
- package/docs/database/unique.md +216 -0
- package/docs/database/views.md +246 -0
- package/docs/frontend/README.md +101 -0
- package/docs/frontend/data-fetching.md +243 -0
- package/docs/frontend/images.md +148 -0
- package/docs/frontend/metadata.md +344 -0
- package/docs/frontend/rendering.md +236 -0
- package/docs/frontend/routing.md +344 -0
- package/docs/frontend/scripts.md +118 -0
- package/docs/frontend/search.md +191 -0
- package/docs/frontend/styling.md +147 -0
- package/docs/getting-started/README.md +81 -0
- package/docs/getting-started/create-project.md +131 -0
- package/docs/getting-started/deploy.md +101 -0
- package/docs/getting-started/first-app.md +215 -0
- package/docs/getting-started/installation.md +106 -0
- package/docs/getting-started/migrating.md +125 -0
- package/docs/getting-started/project-structure.md +163 -0
- package/docs/introduction/README.md +41 -0
- package/docs/introduction/design-principles.md +55 -0
- package/docs/introduction/distributed.md +74 -0
- package/docs/introduction/how-it-works.md +74 -0
- package/docs/introduction/hyperscale.md +51 -0
- package/docs/introduction/modern-stack.md +36 -0
- package/docs/introduction/vs-other-frameworks.md +48 -0
- package/docs/introduction/why-toil.md +93 -0
- package/docs/llms.txt +90 -0
- package/docs/realtime/README.md +102 -0
- package/docs/realtime/channels.md +211 -0
- package/docs/realtime/streams.md +369 -0
- package/docs/services/README.md +60 -0
- package/docs/services/analytics.md +268 -0
- package/docs/services/caching.md +175 -0
- package/docs/services/cookies.md +235 -0
- package/docs/services/crypto.md +209 -0
- package/docs/services/email.md +289 -0
- package/docs/services/environment.md +141 -0
- package/docs/services/ratelimit.md +174 -0
- package/docs/services/time.md +85 -0
- package/examples/basic/client/routes/analytics.tsx +13 -12
- package/examples/basic/server/models/SiteAnalytics.ts +29 -17
- package/examples/basic/server/routes/Analytics.ts +15 -17
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +15 -11
- package/scripts/gen-toil-docs.mjs +24 -35
- package/server/auth/AuthController.ts +336 -0
- package/server/auth/AuthUser.ts +23 -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/docs.ts +16 -33
- package/src/compiler/generate.ts +6 -2
- package/src/compiler/index.ts +114 -6
- package/src/compiler/toil-docs.generated.ts +64 -22
- package/src/devserver/analytics/index.ts +10 -4
- package/src/devserver/db/database.ts +67 -1
- package/src/devserver/db/index.ts +1 -0
- package/src/devserver/db/types.ts +13 -0
- package/src/devserver/runtime/module.ts +7 -0
- package/test/analytics-dev.test.ts +2 -1
- package/test/devserver-database.test.ts +113 -0
- package/docs/auth-todo.md +0 -149
- package/docs/auth.md +0 -322
- package/docs/caching.md +0 -115
- package/docs/cli.md +0 -17
- package/docs/client.md +0 -39
- package/docs/cookies.md +0 -457
- package/docs/crypto.md +0 -130
- package/docs/daemon.md +0 -123
- package/docs/data.md +0 -131
- package/docs/derive.md +0 -159
- package/docs/email.md +0 -326
- package/docs/environment.md +0 -97
- package/docs/getting-started.md +0 -128
- package/docs/index.md +0 -30
- package/docs/ratelimit.md +0 -95
- package/docs/routing.md +0 -259
- package/docs/rpc.md +0 -149
- package/docs/server.md +0 -61
- package/docs/ssr.md +0 -632
- package/docs/streams.md +0 -178
- package/docs/styling.md +0 -22
- package/docs/tiers.md +0 -133
- package/docs/time.md +0 -43
package/docs/auth.md
DELETED
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
# Auth, sessions, and `@user`
|
|
2
|
-
|
|
3
|
-
toiljs ships **Toil PQ-Auth**: a post-quantum password login where the password
|
|
4
|
-
never leaves the browser and the server stores only public verifier material.
|
|
5
|
-
On top of it sit HMAC-signed session cookies, a `@auth` route guard, and a
|
|
6
|
-
`@user` type that makes the signed-in user available - fully typed, no type
|
|
7
|
-
argument - on both the server (`AuthService.getUser()`) and the generated client
|
|
8
|
-
(`getUser()`).
|
|
9
|
-
|
|
10
|
-
> **Status.** PQ-Auth is a hybrid construction (see [What it is](#what-it-is)).
|
|
11
|
-
> It is opt-in and **not hardened to production yet** - the example storage is a
|
|
12
|
-
> dev stand-in, the secrets are dev placeholders, and the composition has not had
|
|
13
|
-
> an external cryptographic review. See [`docs/auth-todo.md`](./auth-todo.md) for
|
|
14
|
-
> the remaining work before it backs real credentials.
|
|
15
|
-
|
|
16
|
-
`AuthService` is an ambient global (no import). The pieces:
|
|
17
|
-
|
|
18
|
-
- **`@user`** - declares the authenticated user's shape and registers it as *the* user type.
|
|
19
|
-
- **`@auth`** - guards a route (or a whole `@rest` class): a valid session is required or `401`.
|
|
20
|
-
- **`AuthService`** - the server runtime: the PQ-Auth crypto, plus mint/read/clear a session and `getUser()`.
|
|
21
|
-
- **client `Auth` + generated `getUser()`** - run the login from the browser and read the user for display.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## What it is
|
|
26
|
-
|
|
27
|
-
A password is a weak, low-entropy secret. PQ-Auth turns it into a strong,
|
|
28
|
-
**post-quantum** credential and proves possession to the server without the
|
|
29
|
-
server (or anyone on the wire, or a future quantum adversary) ever seeing
|
|
30
|
-
anything they can replay. It is built from three independent ideas, each
|
|
31
|
-
defending a specific attack:
|
|
32
|
-
|
|
33
|
-
| Layer | Primitive | Defends against |
|
|
34
|
-
| --- | --- | --- |
|
|
35
|
-
| **Keyed salt** | OPRF (RFC 9497, ristretto255-SHA512) | A breached server (or a passive observer) **precomputing** a password dictionary. |
|
|
36
|
-
| **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. |
|
|
37
|
-
| **Mutual auth + key** | ML-KEM-768 (FIPS 203) | A phishing/MITM server impersonating the real one; a session with no key to bind to. |
|
|
38
|
-
|
|
39
|
-
The password is stretched into a signing keypair entirely client-side; only the
|
|
40
|
-
**public** key is registered. Login is a challenge-response signature *plus* a
|
|
41
|
-
key encapsulation, so both parties authenticate each other. Authentication
|
|
42
|
-
(ML-DSA) and key agreement (ML-KEM) are post-quantum; the keyed-salt OPRF is
|
|
43
|
-
classical ristretto255 (the one non-PQ layer - a quantum break of it degrades to
|
|
44
|
-
a post-breach offline attack, no worse than a plain salt, while defeating
|
|
45
|
-
precomputation for everyone else).
|
|
46
|
-
|
|
47
|
-
### Why a keyed salt (the OPRF)
|
|
48
|
-
|
|
49
|
-
A normal salted hash (`Argon2id(password, salt)`) lets anyone who learns the
|
|
50
|
-
salt - including a future attacker who simply asks the login endpoint for it -
|
|
51
|
-
**precompute** a dictionary offline and crack the stored verifier the instant
|
|
52
|
-
they breach it. PQ-Auth replaces the salt with the output of a **server-keyed**
|
|
53
|
-
OPRF:
|
|
54
|
-
|
|
55
|
-
```
|
|
56
|
-
oprfOutput = OPRF_finalize(password, OPRF_evaluate(k_user, blind(password)))
|
|
57
|
-
seed = Argon2id(oprfOutput, salt)
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
The client **blinds** the password (so the server learns nothing about it),
|
|
61
|
-
the server **evaluates** the blinded element under a per-user key `k_user`
|
|
62
|
-
derived from a server-secret master seed, and the client **unblinds** to recover
|
|
63
|
-
a deterministic, high-entropy `oprfOutput`. Because `k_user` is a server secret,
|
|
64
|
-
**no offline work is possible until that secret leaks** - precomputation is
|
|
65
|
-
impossible, and even a passive observer who captures a login learns nothing.
|
|
66
|
-
The per-user key (`k_user = DeriveKeyPair(masterSeed, username)`) means two
|
|
67
|
-
accounts with the same password get different outputs - no cross-account
|
|
68
|
-
password-equality leak.
|
|
69
|
-
|
|
70
|
-
### Why a password-derived signing key
|
|
71
|
-
|
|
72
|
-
`seed = Argon2id(oprfOutput, salt)` deterministically expands into an
|
|
73
|
-
**ML-DSA-44 keypair**. The client registers only the 1312-byte **public** key;
|
|
74
|
-
the secret key and seed are zeroized the instant signing is done. The server
|
|
75
|
-
stores the public key as a verifier and can only ever *verify* - it never holds
|
|
76
|
-
a secret (`crypto.mldsa_verify` is verify-only on the edge). A full server breach
|
|
77
|
-
yields public keys, not passwords; recovering a password still requires an
|
|
78
|
-
offline Argon2id dictionary attack **and** the leaked OPRF master seed.
|
|
79
|
-
|
|
80
|
-
### Why ML-KEM (mutual auth + session key)
|
|
81
|
-
|
|
82
|
-
A signature proves the *client* to the server, but nothing proves the *server*
|
|
83
|
-
to the client. PQ-Auth pins the server's static **ML-KEM-768 public key** in the
|
|
84
|
-
client. At login the client **encapsulates** a shared secret to that key; only
|
|
85
|
-
the genuine server (holding the matching secret key) can **decapsulate** it. Both
|
|
86
|
-
sides derive the same session key and the server returns a confirmation tag the
|
|
87
|
-
client checks - so a phishing/MITM server that lacks the secret key cannot
|
|
88
|
-
complete the handshake.
|
|
89
|
-
|
|
90
|
-
---
|
|
91
|
-
|
|
92
|
-
## Flow at a glance
|
|
93
|
-
|
|
94
|
-
```mermaid
|
|
95
|
-
sequenceDiagram
|
|
96
|
-
autonumber
|
|
97
|
-
actor U as User
|
|
98
|
-
participant C as Browser
|
|
99
|
-
participant S as Edge wasm
|
|
100
|
-
participant DB as Your store
|
|
101
|
-
|
|
102
|
-
rect rgb(14, 21, 32)
|
|
103
|
-
Note over U,DB: Register, password never leaves the browser
|
|
104
|
-
U->>C: Auth.register(username, password)
|
|
105
|
-
C->>S: POST /auth/register/start (username, blinded)
|
|
106
|
-
S->>S: OPRF-evaluate under k_user, issue salt and KDF params
|
|
107
|
-
S-->>C: salt, params, evaluated
|
|
108
|
-
C->>C: finalize OPRF, Argon2id, ML-DSA-44 keypair, sign PoP
|
|
109
|
-
C->>S: POST /auth/register/finish (username, publicKey, regProof)
|
|
110
|
-
S->>S: verifyRegister(publicKey, PoP)
|
|
111
|
-
S->>DB: store Account (username, salt, params, publicKey)
|
|
112
|
-
S-->>C: ok
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
rect rgb(22, 15, 31)
|
|
116
|
-
Note over U,DB: Login, mutual authentication
|
|
117
|
-
U->>C: Auth.login(username, password)
|
|
118
|
-
C->>S: POST /auth/login/start (username, blinded)
|
|
119
|
-
S->>DB: store challenge (cid, nonce, iat, exp)
|
|
120
|
-
S-->>C: cid, aud, salt, params, nonce, iat, exp, evaluated
|
|
121
|
-
C->>C: finalize OPRF, derive seed, ML-DSA keypair, ML-KEM encapsulate, sign M
|
|
122
|
-
C->>S: POST /auth/login/finish (cid, ct, signature)
|
|
123
|
-
S->>DB: atomic consume challenge(cid)
|
|
124
|
-
S->>S: rebuild M, verifyLogin, decapsulate, derive K, build confirm
|
|
125
|
-
alt signature valid
|
|
126
|
-
S-->>C: ok, sessionToken, serverConfirm, Set-Cookie
|
|
127
|
-
C->>C: re-derive K, check serverConfirm, server authenticated
|
|
128
|
-
else invalid or unknown user
|
|
129
|
-
S-->>C: 401 generic, anti-enumeration
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
rect rgb(13, 25, 18)
|
|
134
|
-
Note over U,DB: Guarded request, the @auth guard
|
|
135
|
-
U->>C: open or call an @auth route
|
|
136
|
-
C->>S: request, cookies sent automatically
|
|
137
|
-
S->>S: @auth verifies HMAC and expiry on __Host-toil_sess
|
|
138
|
-
alt valid session
|
|
139
|
-
S->>S: handler runs, AuthService.getUser() returns the @user
|
|
140
|
-
S-->>C: 200
|
|
141
|
-
else missing or invalid
|
|
142
|
-
S-->>C: 401 before handler and body-decode
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
### The signed transcript
|
|
148
|
-
|
|
149
|
-
The login message `M` the client signs (and the server rebuilds from its own
|
|
150
|
-
stored values) is a single fixed binary layout - no JSON, no version negotiation:
|
|
151
|
-
|
|
152
|
-
```
|
|
153
|
-
u8 tag = 1
|
|
154
|
-
str sub (username)
|
|
155
|
-
str aud (service audience; server constant)
|
|
156
|
-
bytes cid (challenge id)
|
|
157
|
-
bytes nonce (32 random bytes, server-issued)
|
|
158
|
-
u64 iat, u64 exp (challenge validity window)
|
|
159
|
-
bytes ct (ML-KEM ciphertext)
|
|
160
|
-
u32 memKiB, iterations, parallelism (Argon2id params)
|
|
161
|
-
bytes serverKemKeyId (SHA-256 of the server KEM public key)
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
Signing over all of this binds the login to: the exact challenge (so it can't be
|
|
165
|
-
replayed - and `cid` is consumed atomically), the **ciphertext** (so a MITM can't
|
|
166
|
-
swap the key encapsulation), the **KDF params** (so a downgrade can't be slipped
|
|
167
|
-
past the signature), and the **server key identity** (so it commits to which
|
|
168
|
-
server key was used). The mutual-auth tag is then:
|
|
169
|
-
|
|
170
|
-
```
|
|
171
|
-
K = HMAC-SHA256(sharedSecret, "toil-session-key-v1" || SHA-256(M))
|
|
172
|
-
confirm = HMAC-SHA256(K, "toil-server-confirm-v1" || SHA-256(M))
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
`K` is the authenticated session key, derived from the KEM shared secret and
|
|
176
|
-
bound to the transcript. Only a server that decapsulated correctly derives the
|
|
177
|
-
same `K`, so the client checking `confirm` proves the server's identity. (`K` is
|
|
178
|
-
the handle for future channel binding; binding the session *cookie* to the
|
|
179
|
-
transport needs the TLS exporter, which the wasm guest can't see - a follow-up.)
|
|
180
|
-
|
|
181
|
-
### Anti-enumeration
|
|
182
|
-
|
|
183
|
-
`login/start` returns a fully-formed response for **every** username: it always
|
|
184
|
-
OPRF-evaluates (a real `k_user` for known users, a deterministic decoy key for
|
|
185
|
-
unknown ones) and returns a **deterministic per-user salt** and constant params.
|
|
186
|
-
Known and unknown users are byte-indistinguishable, and the eventual signature
|
|
187
|
-
simply fails for a non-account. Failures return one generic `401`.
|
|
188
|
-
|
|
189
|
-
---
|
|
190
|
-
|
|
191
|
-
## `@user`
|
|
192
|
-
|
|
193
|
-
Mark one class per program as the user type. It becomes a `@data` codec (so it
|
|
194
|
-
serializes into the session) and the return type of `getUser()` everywhere.
|
|
195
|
-
|
|
196
|
-
```ts
|
|
197
|
-
@user
|
|
198
|
-
class Account {
|
|
199
|
-
username: string = '';
|
|
200
|
-
admin: bool = false;
|
|
201
|
-
score: u64 = 0;
|
|
202
|
-
}
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
There is exactly one `@user` per program; a second is a compile error.
|
|
206
|
-
|
|
207
|
-
## `@auth`
|
|
208
|
-
|
|
209
|
-
Put `@auth` on a route, or on the `@rest` class to guard every route in it. The
|
|
210
|
-
generated dispatcher checks for a valid, unexpired session **before** the handler
|
|
211
|
-
runs (and before any body-decode or cache write); without one it returns `401`.
|
|
212
|
-
|
|
213
|
-
```ts
|
|
214
|
-
@rest('session')
|
|
215
|
-
class Session {
|
|
216
|
-
@auth
|
|
217
|
-
@get('/me')
|
|
218
|
-
public me(): Response {
|
|
219
|
-
const u = AuthService.getUser(); // Account | null, auto-typed
|
|
220
|
-
if (u == null) return Response.text('no session\n', 401);
|
|
221
|
-
return Response.bytes(new DataWriter()
|
|
222
|
-
.writeString(u.username).writeBool(u.admin).writeU64(u.score).toBytes());
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
`@auth` on the class form guards all routes in it.
|
|
228
|
-
|
|
229
|
-
## `AuthService` (server)
|
|
230
|
-
|
|
231
|
-
A global namespace. Session methods read the ambient request
|
|
232
|
-
(`Server.currentRequest`), so `getUser()`/`hasSession()` take no argument and are
|
|
233
|
-
only meaningful during a dispatch.
|
|
234
|
-
|
|
235
|
-
### PQ-Auth crypto
|
|
236
|
-
|
|
237
|
-
Startup config (call once in `main.ts`; identical on every edge instance; never
|
|
238
|
-
in a client bundle):
|
|
239
|
-
|
|
240
|
-
| Member | Notes |
|
|
241
|
-
| --- | --- |
|
|
242
|
-
| `setSecret(secret)` | HMAC secret for session cookies. |
|
|
243
|
-
| `setOprfSeed(seed)` | 32-byte OPRF master seed; per-user keys derive from this + the username. |
|
|
244
|
-
| `setServerKemSecretKey(sk)` | Server static ML-KEM-768 secret key (2400 B) used to decapsulate. |
|
|
245
|
-
| `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)`. |
|
|
246
|
-
|
|
247
|
-
Per-request building blocks:
|
|
248
|
-
|
|
249
|
-
| Member | Notes |
|
|
250
|
-
| --- | --- |
|
|
251
|
-
| `oprfEvaluate(username, blinded)` | OPRF server step: blind-evaluate under `k_user` derived from the seed + username. Returns the 32-byte evaluated element. |
|
|
252
|
-
| `mlkemDecapsulate(ct)` | Recover the 32-byte shared secret from the client ciphertext with the server secret key. |
|
|
253
|
-
| `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. |
|
|
254
|
-
| `verifyLogin(publicKey, message, signature)` | Verify the ML-DSA login signature under `LOGIN_CONTEXT`. |
|
|
255
|
-
| `serverKemKeyId()` | `SHA-256(serverKemPublicKey)` - the key id bound into `M`. |
|
|
256
|
-
| `sha256(data)` | SHA-256, for the transcript hash. |
|
|
257
|
-
| `deriveSessionKey(sharedSecret, transcriptHash)` | `K = HMAC(sharedSecret, SESSION_KEY_LABEL || transcriptHash)`. |
|
|
258
|
-
| `serverConfirmTag(sessionKey, transcriptHash)` | The mutual-auth tag `HMAC(K, SERVER_CONFIRM_LABEL || transcriptHash)`. |
|
|
259
|
-
| `buildRegisterMessage(username, publicKey)` / `verifyRegister(...)` | Registration proof-of-possession (under `REGISTER_CONTEXT`). |
|
|
260
|
-
| `LOGIN_CONTEXT` / `REGISTER_CONTEXT` | `qauth:login:v1` / `qauth:register:v1` - FIPS 204 signing contexts. |
|
|
261
|
-
| `PUBLIC_KEY_LEN` `SIGNATURE_LEN` `KEM_*` `SHARED_SECRET_LEN` `OPRF_*` | Fixed sizes. |
|
|
262
|
-
|
|
263
|
-
The full register/login orchestration (the four binary endpoints, the
|
|
264
|
-
anti-enumeration decoy, the atomic challenge-consume) is in
|
|
265
|
-
`examples/basic/server/routes/Auth.ts`. **Storage is the app's** - a tenant's
|
|
266
|
-
wasm memory is wiped per request, so accounts and challenges live in an external
|
|
267
|
-
store, and challenge-consume **must** be an atomic fetch-and-delete (a
|
|
268
|
-
read-then-delete race makes a captured login replayable). The example uses a
|
|
269
|
-
**dev-only** KV for this; production wires toildb (see `docs/auth-todo.md`).
|
|
270
|
-
|
|
271
|
-
### Sessions
|
|
272
|
-
|
|
273
|
-
| Member | Signature | Notes |
|
|
274
|
-
| --- | --- | --- |
|
|
275
|
-
| `getUser()` | `(): AuthUser \| null` | The signed-in user, decoded from the verified session, auto-typed to your `@user`. |
|
|
276
|
-
| `hasSession()` | `(): bool` | Whether the request carries a valid, unexpired session. What `@auth` calls. |
|
|
277
|
-
| `mintSession(userData, ttlSecs?)` | `(Uint8Array, u64=86400): Cookie` | Signed `__Host-toil_sess` cookie carrying `user.encode()`. HttpOnly, Secure, SameSite=Lax. |
|
|
278
|
-
| `clearSession()` / `userCookie(...)` / `clearUserCookie()` | | Logout; the readable `__Secure-toil_user` companion (display-only); clear it. |
|
|
279
|
-
|
|
280
|
-
The session payload is `u8 version || u64 iat || u64 exp || bytes userData`, sealed
|
|
281
|
-
with HMAC-SHA256. The HttpOnly `__Host-toil_sess` is the **only** cookie the
|
|
282
|
-
server trusts; the readable `__Secure-toil_user` exists solely so the client
|
|
283
|
-
`getUser()` can show a name without a round-trip and must never gate anything.
|
|
284
|
-
|
|
285
|
-
## The client half
|
|
286
|
-
|
|
287
|
-
```ts
|
|
288
|
-
import { Auth } from 'toiljs/client';
|
|
289
|
-
|
|
290
|
-
await Auth.register(username, password); // OPRF + Argon2id + ML-DSA keypair, send only the public key + PoP
|
|
291
|
-
await Auth.login(username, password); // + ML-KEM encapsulate; resolves only if the server's confirm tag verifies
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
`login` resolves **only after** the client verifies the server's confirmation tag
|
|
295
|
-
- so a resolved `login` means mutual authentication succeeded. The secret key,
|
|
296
|
-
seed, and shared secret are zeroized as soon as they are used. There is no
|
|
297
|
-
recovery: the password *is* the key (see `docs/auth-todo.md` for the recovery
|
|
298
|
-
work).
|
|
299
|
-
|
|
300
|
-
The generated `shared/server.ts` also exports a typed, no-argument client
|
|
301
|
-
`getUser()` that reads the readable companion cookie. It is **display-only and
|
|
302
|
-
untrusted** - a client can forge it, fooling only its own UI. The server
|
|
303
|
-
re-verifies the signed session on every `@auth` request, so authorization never
|
|
304
|
-
depends on the readable cookie.
|
|
305
|
-
|
|
306
|
-
## Security checklist
|
|
307
|
-
|
|
308
|
-
- Set real secrets in `main.ts`: `setSecret`, `setOprfSeed`, and the server KEM
|
|
309
|
-
keypair - per-deployment, identical on every instance, never in a client
|
|
310
|
-
bundle. The defaults are insecure DEV placeholders.
|
|
311
|
-
- Pin **your** server KEM public key in the client and rotate it; the example
|
|
312
|
-
ships a throwaway dev keypair.
|
|
313
|
-
- Use a production Argon2id cost (≥ 256 MiB, ≥ 3 iterations); the demo is tuned
|
|
314
|
-
for browser responsiveness.
|
|
315
|
-
- Back accounts/challenges with a shared store and make challenge-consume atomic.
|
|
316
|
-
- Rate-limit `register` and `login` (online guessing is not stopped by the
|
|
317
|
-
offline-attack resistance).
|
|
318
|
-
- Always verify server-side. The server `getUser()` decodes a verified,
|
|
319
|
-
expiry-checked session; the client `getUser()` does not and must not gate
|
|
320
|
-
anything.
|
|
321
|
-
- This is an unreviewed hybrid composition - get a cryptographic review before it
|
|
322
|
-
backs real credentials. Tracked in [`docs/auth-todo.md`](./auth-todo.md).
|
package/docs/caching.md
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
# Caching
|
|
2
|
-
|
|
3
|
-
toiljs can cache a response at the edge (shared, across users) and instruct the
|
|
4
|
-
browser to cache it too. You opt in per route, either declaratively with the
|
|
5
|
-
`@cache` decorator or imperatively with `Response.cache(...)`. The edge keys a
|
|
6
|
-
cached entry by host, method, path, and body hash, and honors a per-entry TTL.
|
|
7
|
-
|
|
8
|
-
## `@cache` decorator
|
|
9
|
-
|
|
10
|
-
Annotate a route method; the compiler appends the cache directive to whatever
|
|
11
|
-
`Response` the route returns, so it composes with every return shape (a
|
|
12
|
-
`Response`, a `void` 204, or an auto-encoded `@data` value).
|
|
13
|
-
|
|
14
|
-
```ts
|
|
15
|
-
@cache(60) // 60 minutes at the edge
|
|
16
|
-
@cache(60, 300) // + 5 minutes (300s) in the browser
|
|
17
|
-
@cache(60, 300, true) // + private scope (per-user caches only)
|
|
18
|
-
@cache(60, 300, true, true) // + cache even for authenticated requests
|
|
19
|
-
@get('/leaderboard')
|
|
20
|
-
public top(): Standings { /* … */ }
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Arguments must be integer or boolean literals; a non-literal argument makes the
|
|
24
|
-
decorator degrade safely to "not cached" rather than miscompile.
|
|
25
|
-
|
|
26
|
-
## `Response.cache(...)`
|
|
27
|
-
|
|
28
|
-
The same controls are available imperatively, which is what `@cache` lowers to:
|
|
29
|
-
|
|
30
|
-
```ts
|
|
31
|
-
public cache(
|
|
32
|
-
edgeTtlMinutes: u16,
|
|
33
|
-
browserTtlSeconds: u32 = 0,
|
|
34
|
-
privateScope: bool = false,
|
|
35
|
-
allowAuth: bool = false,
|
|
36
|
-
): Response
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
return Response.json(body).cache(60, 300);
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
`cacheFor(minutes)` is the common shorthand for "edge only, no browser caching":
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
return Response.bytes(blob).cacheFor(5);
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
## Parameters
|
|
50
|
-
|
|
51
|
-
| Parameter | Meaning |
|
|
52
|
-
| --- | --- |
|
|
53
|
-
| `edgeTtlMinutes` | How long the edge may serve the cached response. Clamped to a 24-hour maximum. |
|
|
54
|
-
| `browserTtlSeconds` | `max-age` for the browser. `0` (default) means the browser does not cache. |
|
|
55
|
-
| `privateScope` | Marks the response `private`: only per-user caches (the browser), never a shared edge/CDN cache. |
|
|
56
|
-
| `allowAuth` | Permit caching a response to an authenticated request. Off by default (see safety rails). |
|
|
57
|
-
|
|
58
|
-
## Safety rails
|
|
59
|
-
|
|
60
|
-
The cache layer refuses to store anything unsafe, regardless of the directive:
|
|
61
|
-
|
|
62
|
-
- **5xx** responses are never cached, a server error is transient, and `@cache`
|
|
63
|
-
wraps the whole route, so a `@cache`d route that hits a blip returns its 500
|
|
64
|
-
carrying the directive; caching it would serve the failure for the full TTL.
|
|
65
|
-
**2xx, 3xx, and 4xx are cacheable** (a redirect or a `404`/`410` is a
|
|
66
|
-
deterministic function of the request key);
|
|
67
|
-
- a response that sets a **`Set-Cookie`** is never cached;
|
|
68
|
-
- a response to an **authenticated** request is not cached unless you pass
|
|
69
|
-
`allowAuth = true`, this prevents one user's personalized response from being
|
|
70
|
-
served to another;
|
|
71
|
-
- the edge TTL is **clamped to 24 hours**.
|
|
72
|
-
|
|
73
|
-
Because `@auth` guards and body-decode run before the cache directive is applied,
|
|
74
|
-
an unauthorized request is rejected with 401 before anything is cached, and a
|
|
75
|
-
cached entry is only ever produced from a handler that actually ran.
|
|
76
|
-
|
|
77
|
-
Caching is **always opt-in.** A response with no `Dacely-Cache-Control` directive
|
|
78
|
-
(i.e. no `@cache` / `Response.cache(...)`) is never stored, there is no blind
|
|
79
|
-
"cache every GET" mode, because an automatic window cannot tell a personalized
|
|
80
|
-
response from a public one and would key it without a per-user component.
|
|
81
|
-
|
|
82
|
-
## Memory bounds and disk spill
|
|
83
|
-
|
|
84
|
-
The edge cache is per-core and hard-capped so it can never exhaust node memory.
|
|
85
|
-
It has two tiers:
|
|
86
|
-
|
|
87
|
-
- **RAM tier**, small, short-TTL responses. Bounded by a per-core byte budget
|
|
88
|
-
(each core holds at most ~128 MB) plus an entry-count cap; an insert that would
|
|
89
|
-
exceed the budget drops expired entries first, then evicts the soonest-to-expire
|
|
90
|
-
ones. A response over ~256 KB does not go in the RAM tier.
|
|
91
|
-
- **Disk tier (spill)**, when the operator enables `--spill-dir`, a **big**
|
|
92
|
-
(over the ~256 KB RAM cap) or **long-TTL** (≥ 10 min) cacheable response is
|
|
93
|
-
written to disk instead and served back zero-RAM via a memory map, the same way
|
|
94
|
-
static files are served. This keeps the RAM tier for the hot working set while
|
|
95
|
-
still caching large bodies and long-lived entries. Writes (and unlinks) are
|
|
96
|
-
offloaded to a sibling thread so they never stall the request path; a separate
|
|
97
|
-
per-core disk budget caps total spilled bytes, with the same expiry + eviction.
|
|
98
|
-
If spill is not enabled, a big response is simply not cached (reported as not
|
|
99
|
-
stored by the `Dacely-Cache` tag).
|
|
100
|
-
|
|
101
|
-
From a tenant's point of view nothing changes: you still just set a
|
|
102
|
-
`Dacely-Cache-Control` directive (via `@cache` / `Response.cache(...)`). The edge
|
|
103
|
-
decides RAM vs disk; both honor the same TTL and the same safety rails above.
|
|
104
|
-
Expiry is enforced on read (a past-TTL entry is a miss) and reclaimed on the next
|
|
105
|
-
insert that needs room. Nothing persists across a process restart.
|
|
106
|
-
|
|
107
|
-
## Choosing TTLs
|
|
108
|
-
|
|
109
|
-
- Public, slow-changing data (a leaderboard, a catalog): a few minutes of edge
|
|
110
|
-
TTL plus a short browser TTL removes most of the load.
|
|
111
|
-
- Per-user data: set `privateScope` so it never lands in a shared cache, and
|
|
112
|
-
prefer a small or zero edge TTL.
|
|
113
|
-
- Anything with a `Set-Cookie` or behind `@auth`: leave it uncached unless you
|
|
114
|
-
have thought through `allowAuth` and are certain the body is identical for
|
|
115
|
-
every authorized caller.
|
package/docs/cli.md
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# CLI
|
|
2
|
-
|
|
3
|
-
- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,
|
|
4
|
-
`--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.
|
|
5
|
-
- `toiljs dev`, dev server with HMR (`--port`, `--root`). With a `toilconfig.json` it builds
|
|
6
|
-
the server first, then rebuilds it whenever a `server/` file changes (regenerating
|
|
7
|
-
`shared/server.ts`, which Vite HMRs into the client); client-only edits just HMR the client.
|
|
8
|
-
- `toiljs build`, production build. With a `toilconfig.json` it builds the server (toilscript,
|
|
9
|
-
regenerating `shared/server.ts`) first, then the client → `build/client`. `--server` builds
|
|
10
|
-
only the server. Every `server/` file declaring a surface (`@data`/`@rest`/...) is compiled.
|
|
11
|
-
- `toiljs start`, self-host the built app with production hyper-express/uWS static workers,
|
|
12
|
-
SSR/wasm dispatch, daemon support, and a `/_toil` WebSocket channel. Use `--threads <n>`
|
|
13
|
-
(or `server.threads`) to set the worker count; `1` disables the pool.
|
|
14
|
-
- `toiljs configure`, toggle styling features on an existing project (see [styling.md](./styling.md)).
|
|
15
|
-
- `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC
|
|
16
|
-
setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the
|
|
17
|
-
toilscript prettier plugin) so an existing project upgrades in one command.
|
package/docs/client.md
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# Client runtime
|
|
2
|
-
|
|
3
|
-
Everything is on the `Toil` global, no imports needed in route files.
|
|
4
|
-
|
|
5
|
-
## Entry
|
|
6
|
-
|
|
7
|
-
`client/toil.tsx` imports the route table + global styles and mounts the app:
|
|
8
|
-
|
|
9
|
-
```tsx
|
|
10
|
-
import { routes, layout, notFound } from "toiljs/routes";
|
|
11
|
-
import "./styles/main.css";
|
|
12
|
-
Toil.mount(routes, layout, notFound);
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
## API (on `Toil`)
|
|
16
|
-
|
|
17
|
-
- Components: `Link`, `NavLink`, `Head`
|
|
18
|
-
- Navigation: `navigate`, `useRouter`, `useNavigate`
|
|
19
|
-
- Location: `usePathname`, `useSearchParams`, `useParams`, `useNavigationPending`
|
|
20
|
-
- Data: `useLoaderData` (see [routing.md](./routing.md))
|
|
21
|
-
- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route
|
|
22
|
-
- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)
|
|
23
|
-
- IO globals (no `Toil.` prefix): `FastMap`, `FastSet`, `DataWriter`, `DataReader`
|
|
24
|
-
- `parseError(err)` global: message from an unknown caught value (handy in `catch`)
|
|
25
|
-
- `Server` global: the typed RPC surface generated from the server (see [server.md](./server.md))
|
|
26
|
-
- `Server.REST.<controller>.<route>(args)`: a working, typed `fetch` client for your
|
|
27
|
-
`@rest` controllers, e.g. `await Server.REST.todos.getTodo({ params: { id } })` or
|
|
28
|
-
`await Server.REST.todos.add({ body: new AddTodo("milk") })`. `args` is
|
|
29
|
-
`{ params?, body?, query?, headers? }`; returns are typed (`@data` classes are parsed for
|
|
30
|
-
you). The REST client attaches when you import from `shared/server`.
|
|
31
|
-
|
|
32
|
-
## Head example
|
|
33
|
-
|
|
34
|
-
```tsx
|
|
35
|
-
Toil.useHead({
|
|
36
|
-
title: "Blog",
|
|
37
|
-
meta: [{ name: "description", content: "..." }],
|
|
38
|
-
});
|
|
39
|
-
```
|