toiljs 0.0.100 → 0.0.102

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/docs/README.md CHANGED
@@ -100,6 +100,7 @@ Run `toiljs dev`, open the browser, and both are live with hot reload. That is t
100
100
  [Time](./services/time.md).
101
101
 
102
102
  **Concepts and reference**
103
- - [Compute tiers (L1 to L4)](./concepts/tiers.md), [Types (u64, u256, and friends)](./concepts/types.md),
103
+ - [Writing toiljs correctly (AI + human quick rules)](./concepts/ai-guide.md),
104
+ [Compute tiers (L1 to L4)](./concepts/tiers.md), [Types (u64, u256, and friends)](./concepts/types.md),
104
105
  [Every decorator](./concepts/decorators.md), [Configuration](./concepts/config.md),
105
106
  [Security and SRI](./concepts/security.md).
@@ -71,6 +71,17 @@ Set `TOIL_AUTH_DOMAIN` explicitly if your site answers on multiple hostnames, ot
71
71
  get different `ToilUserId`s from different aliases. Once users exist, changing it changes everyone's id, so
72
72
  pick it before launch.
73
73
 
74
+ ## Email confirmation
75
+
76
+ Two plain env vars (tenant-readable, like `PUBLIC_BASE_URL`) control the built-in email-verification flow. They are separate on purpose: sending a verify email at registration is one decision, blocking login until confirmed is another.
77
+
78
+ | Key | What it does |
79
+ | --- | --- |
80
+ | `AUTH_EMAIL_CONFIRMATION` | Registration emails a one-time confirm link (`/confirm#token=…`) and stores the account **unconfirmed**. Login is **not** blocked: the user can sign in, and you nudge them to verify. Use this for "verify at signup, but let people in". |
81
+ | `AUTH_REQUIRE_EMAIL_CONFIRMATION` | The stricter form: does everything above AND **refuses login** until the email is confirmed (a distinguishable status the client surfaces as `EmailNotConfirmedError`, so the UI can prompt "confirm your email / resend"). Implies `AUTH_EMAIL_CONFIRMATION`. On the managed edge this is force-set from the per-domain Dacely setting. |
82
+
83
+ With neither set, accounts are auto-confirmed at registration and no confirm email is sent. The one-time link is delivered by email; in `toiljs dev` it is drawn in the console (no mailer needed). Both use the standard non-enumerating responses (a generic 200 that never reveals whether an address exists).
84
+
74
85
  ## Argon2id strength (known limitation)
75
86
 
76
87
  The built-in controller currently uses **demo-light** Argon2id params (32 MiB, 2 iterations, 1 lane) so it
@@ -0,0 +1,132 @@
1
+ # Writing toiljs correctly
2
+
3
+ This is a fast, high-signal cheat-sheet for writing toiljs and toilscript code that compiles and behaves. It is aimed at AI assistants generating code (and at humans in a hurry): the patterns to follow, the mistakes to avoid, and a link to the deep page whenever you need more. It does not re-teach the framework; each section points you at the authoritative doc.
4
+
5
+ ## Project shape
6
+
7
+ A toiljs project has three top-level folders, and knowing which one a file belongs to tells you which rules apply:
8
+
9
+ - **`client/`**: your React app that runs in the browser. Pages live under `client/routes/`, one file per URL, and each route file `export default`s its component. See [Frontend](../frontend/README.md) and [Routing](../frontend/routing.md).
10
+ - **`shared/`**: a generated typed bridge (`shared/server.ts`) that lets the browser call the backend with full types. It is regenerated on every build, so **never hand-edit it**.
11
+ - **`server/`**: your backend, written in TypeScript but compiled by **toilscript** to WebAssembly. It runs on the Dacely edge, not in Node or the browser. See [Backend](../backend/README.md).
12
+
13
+ Most code runs on the L1 request tier. Long-lived connections (`@stream`) and scheduled work (`@daemon`) are opt-in tiers in their own entry files. See [Compute tiers](./tiers.md).
14
+
15
+ ## Client rules (the browser, React side)
16
+
17
+ **Call the backend only through the generated `Server` client.** This is the number one rule. Use `Server.REST.<controller>.<route>(args)` for `@rest` HTTP controllers, or `Server.<service>.<method>(args)` / `Server.<remote>(args)` for RPC. Raw `fetch` to your own backend throws away the type safety, the argument and result decoding, and the binary wire codec, and it silently breaks when a route is renamed. `fetch` is only for third-party URLs (an external API, a CDN). See [Fetching data](../frontend/data-fetching.md).
18
+
19
+ ```ts
20
+ // WRONG: raw fetch to your own backend
21
+ await fetch('/account/session', { method: 'POST' });
22
+
23
+ // RIGHT: the typed client (renames become compile errors; args + result typed)
24
+ await Server.REST.account.session();
25
+ ```
26
+
27
+ **Use the ambient `Toil.*` globals with no import.** `Toil` is the `toiljs/client` package exposed as a global and typed via a generated `toil-env.d.ts`, so it autocompletes with no import: `Toil.Link` / `Toil.NavLink` for navigation, `Toil.useParams`, `Toil.useLoaderData`, `Toil.Image`, `Toil.useHead` / `Toil.Head`, `Toil.Form` / `Toil.useAction`, and more. `Server` and `parseError` are also bare globals; `FastMap`, `FastSet`, `DataWriter`, and `DataReader` are bare globals too (write `new DataWriter()`, not `new Toil.DataWriter()`). Full index: [The Toil global](../frontend/toil-global.md).
28
+
29
+ **Load page data with a `loader`, not a `useEffect` fetch.** A route file exports a `loader`, and the component reads its result with `Toil.useLoaderData`. The loader runs on navigation in parallel with the route chunk, integrates with `loading.tsx`, caching, and SSR hydration. A `useEffect` fetch runs only after mount (slower, invisible to the server).
30
+
31
+ ```tsx
32
+ // client/routes/blog/[id].tsx
33
+ export const loader = async ({ params }: Toil.LoaderArgs) => {
34
+ return Server.REST.blog.get({ params: { id: params.id } });
35
+ };
36
+
37
+ export default function BlogPost() {
38
+ const post = Toil.useLoaderData<typeof loader>();
39
+ return <article><h1>{post.title}</h1></article>;
40
+ }
41
+ ```
42
+
43
+ **Mutate with `Toil.useAction` or `<Toil.Form>`, then revalidate.** Both track pending and error state and refetch the affected loader data on success, so the page updates without a manual refetch. Use `<Toil.Form>` for form submits, `useAction` for anything else (a delete button, a toggle). Reach for `Toil.revalidate()` / `router.revalidate()` after a write that these do not cover.
44
+
45
+ **Per-route SEO with `export const metadata`** (or `Toil.useHead` / `<Toil.Head>` from inside a component). Opt a route into edge SSR with `export const ssr = true`. See [Metadata and SEO](../frontend/metadata.md) and [Rendering and SSR](../frontend/rendering.md).
46
+
47
+ **Typed hrefs.** `Toil.Link` `href`, `Toil.navigate`, and `router.push` are all type-checked against your real routes, so a typo is a compile error. Use `Toil.href(str)` only when a URL is assembled from data and TypeScript cannot prove it is a real route. See [Navigation](../frontend/navigation.md).
48
+
49
+ **`getUser()` on the client is display-only.** It reads a readable session cookie with no network call, so it is instant but **forgeable**. Use it to render "logged in as ...", never to gate real access. The authoritative check is a server route guarded with `@auth`. See [Auth usage](../auth/usage.md).
50
+
51
+ ## Server rules (toilscript, compiles to WASM)
52
+
53
+ The decorators, each doing one job (full list in [Decorators](./decorators.md)):
54
+
55
+ - **`@data`** on a class: generates the binary and JSON codec so the value can cross the wire and the database. Every field needs a default; field order **is** the binary layout, so add new fields at the end. See [Data types](../backend/data.md).
56
+ - **`@rest('name')`** on a class mounts an HTTP controller at `/name`; **`@get`/`@post`/`@put`/`@del`/`@patch`** on its methods declare routes (`@del`, not `@delete`, which is reserved). See [REST](../backend/rest.md).
57
+ - **`@service`** + **`@remote`** expose typed RPC callable from your own frontend as `Server.<service>.<method>()`. See [RPC](../backend/rpc.md).
58
+ - **`@user`** declares the authenticated-user shape and enables `AuthService.getUser()`. **Exactly one `@user` per project.** **`@auth`** guards a route or a whole `@rest` class. See [Auth](../auth/README.md).
59
+ - **`@ratelimit(strategy, limit, window)`** caps how often a caller may hit a route, rejected at the edge before your code runs. See [Security](./security.md).
60
+ - **`@database`** + **`@collection`** declare a ToilDB schema (below).
61
+
62
+ ```ts
63
+ @rest('players')
64
+ class Players {
65
+ @get('/:id')
66
+ public get(ctx: RouteContext): Response {
67
+ const id = ctx.param('id');
68
+ return Response.json(`{"id":"${id}"}`);
69
+ }
70
+ }
71
+ ```
72
+
73
+ **`@auth` rejects with 401 before your handler runs.** So inside an `@auth`-guarded handler, `AuthService.getUser()!` is guaranteed non-null and safe to assert. Without `@auth`, `getUser()` can be null, so null-check it instead. Either way the server re-verifies the signed session, so it (not the client cookie) is the real authorization boundary.
74
+
75
+ ```ts
76
+ @auth
77
+ @get('/settings')
78
+ public settings(): Response {
79
+ const user = AuthService.getUser()!; // safe: @auth guarantees a session
80
+ return Response.text('hi ' + user.username);
81
+ }
82
+ ```
83
+
84
+ **Server code is a strict, WASM-targeted dialect (AssemblyScript), not full TypeScript.** The real constraints, verified against the toilscript standard library:
85
+
86
+ - **Use explicit value types**, never `number`: `i32` / `u32` / `i64` / `u64` / `f64` (and `i8`/`u8`/`i16`/`u16`/`f32`, plus `u128`..`u256`), `bool`, `string`, `Uint8Array`. Plain `number` resolves to `f64`, which is wrong for ids and counts. Integer math **wraps** on overflow (it never throws), and integer `/` truncates. See [The type system](./types.md).
87
+ - **No `any`.** Every value has a concrete type; that is what lets it compile to WASM.
88
+ - **No arbitrary npm.** Only the toilscript standard library plus the toiljs host APIs.
89
+ - **Use `null`, not `undefined`** for "no value" (`T | null`, narrowed with `if (x != null)` or a `!` assertion).
90
+ - **No usable built-in `RegExp`** (it is a host stub that throws). Parse strings by hand.
91
+ - **Structured values are `@data` classes, not object literals.** Encode and decode raw binary with `DataWriter` / `DataReader` (imported on the server from the `data` module: `import { DataWriter, DataReader } from 'data';`). For dynamic JSON, use the ambient `JSON` value tree.
92
+ - **Read config with `Environment.get(key)` and secrets with `Environment.getSecure(key)`** (each returns `string | null`; the two buckets are disjoint so a secret can never leak through `get`). See [Environment](../services/environment.md).
93
+ - **Return a runtime `Response`** (`Response.json` / `.text` / `.html` / `.bytes` / `.notFound` / ...), or return a `@data` value and let toiljs serialize it.
94
+
95
+ **ToilDB has seven collection families; pick the one that matches the job** (do not force everything into one). Declare each as a `static` `@collection` field on a `@database` class, typed by its family, and reach it statically (`AppDb.users.get(...)`). See [The database](../database/README.md) and [Setup](../database/setup.md).
96
+
97
+ - **`Documents<K,V>`**: the default record store, looked up by id (users, posts, orders).
98
+ - **`Unique<K,V>`**: a globally one-of-a-kind claim (usernames, emails, slugs).
99
+ - **`Counter<K>`**: a running total many callers bump at once (likes, views); `add` a delta only.
100
+ - **`Events<K,V>`**: an append-only log kept in order (feeds, audit trails).
101
+ - **`Capacity<K>`**: a limited quantity handed out without overselling (tickets, seats).
102
+ - **`Membership<K,M>`**: sets of who belongs to what (followers, tags, room members).
103
+ - **`View<K,V>`**: a precomputed read-optimized snapshot (leaderboards, home pages).
104
+
105
+ **Data access is gated by function kind.** A `@get` route is a read-only **Query**; a `@post` route is an **Action** (may write); a plain `@remote` defaults to read-only Query, so add **`@action`** to let it write. Scans (`events.latest`, `membership.list`) are barred from request handlers: do them in a `@derive` and have the request read the resulting `View`. The compiler enforces this and the edge re-checks it.
106
+
107
+ ## Common mistakes
108
+
109
+ A short do-not list. Every one of these is a real, avoidable error:
110
+
111
+ - **Raw `fetch` to your own backend.** Go through `Server.*` instead.
112
+ - **Importing `Toil.*`.** They are ambient globals; importing them is wrong.
113
+ - **Fetching page data in a `useEffect`.** Use a route `loader` + `Toil.useLoaderData`.
114
+ - **A plain `<a>` for in-app links.** Use `Toil.Link` (a bare `<a>` triggers a full reload).
115
+ - **More than one `@user`.** Exactly one per project.
116
+ - **Trusting client `getUser()` for authorization.** It is display-only and forgeable; enforce on the server with `@auth`.
117
+ - **`any`, `number`, or `RegExp` in server code.** Use explicit value types; there is no usable `RegExp`.
118
+ - **Reordering `@data` fields** (or changing a field type) on a stored type. Field order is the layout; add at the end and use `@migrate` to evolve. See [Data types](../backend/data.md).
119
+ - **Writing from a plain `@remote` or `@get`.** Reads are the default; a write needs `@action`.
120
+ - **Calling a scan (`events.latest`, `membership.list`) from a request handler.** Do it in a `@derive`.
121
+ - **Hand-editing `shared/server.ts` or `.toil/`.** They are generated; rebuild instead.
122
+
123
+ ## Where to go deeper
124
+
125
+ - Concepts: [Decorators](./decorators.md), [Type system](./types.md), [Security](./security.md), [Compute tiers](./tiers.md).
126
+ - Backend: [Overview](../backend/README.md), [REST](../backend/rest.md), [RPC](../backend/rpc.md), [Data types](../backend/data.md).
127
+ - Frontend: [Overview](../frontend/README.md), [Fetching data](../frontend/data-fetching.md), [Routing](../frontend/routing.md), [Navigation](../frontend/navigation.md), [The Toil global](../frontend/toil-global.md).
128
+ - Database: [ToilDB overview](../database/README.md), [Setup](../database/setup.md).
129
+ - Auth: [Overview](../auth/README.md), [Usage](../auth/usage.md).
130
+ - Services: [Environment](../services/environment.md), [Rate limiting](../services/ratelimit.md).
131
+ </content>
132
+ </invoke>
@@ -93,15 +93,15 @@ sequenceDiagram
93
93
  participant B as Build
94
94
  participant Edge as Dacely edge
95
95
  participant U as Browser
96
- B->>Edge: prebuilt template (HTML with holes) + a coherence hash
96
+ B->>Edge: prebuilt template (HTML with holes) plus a coherence hash
97
97
  U->>Edge: GET / (ssr route)
98
- Edge->>Edge: run the wasm render -> small "hole values" list
98
+ Edge->>Edge: run the wasm render to get a small hole-values list
99
99
  Edge->>Edge: splice values into the template
100
100
  Edge-->>U: real first-paint HTML
101
101
  Note over U: Content is visible immediately
102
- U->>Edge: fetch JS + route chunk
103
- Note over U: React hydrates: attaches to the existing HTML
104
- Note over U: Page is interactive; no redraw, no flash
102
+ U->>Edge: fetch JS plus route chunk
103
+ Note over U: React hydrates and attaches to the existing HTML
104
+ Note over U: Page is interactive with no redraw or flash
105
105
  ```
106
106
 
107
107
  For SSR to hydrate cleanly, the HTML the server produced and the HTML the browser would produce must match byte-for-byte. toiljs guarantees this by escaping hole values exactly as React does and by carrying a hash that ties the running backend to the exact template it was built against. Authoring the server side of an SSR route (the hole markers in the page and the matching `render` function in `server/`) is a deeper topic that lives with the [backend](../backend/README.md). For most pages you only need `export const ssr = true` and to keep the page "SSR-safe" (below).
package/docs/llms.txt CHANGED
@@ -86,6 +86,7 @@ This is the canonical documentation, organized by section. Each link points to a
86
86
  - [Time](services/time.md): **`Time` is the edge's clock: the blessed way to read the current time in your backend.** It gives you the number of milliseconds or seconds since a fixed reference point, so you can stamp events and compute expiries.
87
87
 
88
88
  ## Concepts and reference
89
+ - [Writing toiljs correctly](concepts/ai-guide.md): A fast, high-signal cheat-sheet for writing toiljs and toilscript code that compiles and behaves, aimed at AI assistants (and humans in a hurry): the client + server + ToilDB patterns to follow, the common mistakes to avoid, and links to the deep pages.
89
90
  - [Configuration (`toil.config.ts`)](concepts/config.md): `toil.config.ts` is the one file that configures your whole toiljs app: the client (React/Vite) side and the server (WebAssembly) side.
90
91
  - [Decorators reference](concepts/decorators.md): Every feature of a toiljs backend, an HTTP route, an RPC method, a database collection, a scheduled job, is switched on by a **decorator**.
91
92
  - [Security](concepts/security.md): Security in toil is not a single feature you switch on.
@@ -16,8 +16,8 @@ sequenceDiagram
16
16
  participant B as Browser
17
17
  participant S as Your handler
18
18
  B->>S: Request<br/>Cookie: theme=dark
19
- Note over S: req.cookie('theme') -> "dark"
20
- S-->>B: Response<br/>Set-Cookie: theme=light; ...
19
+ Note over S: req.cookie('theme') returns "dark"
20
+ S-->>B: Response<br/>Set-Cookie: theme=light
21
21
  Note over B: browser stores the new cookie<br/>and sends it next time
22
22
  ```
23
23
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.100",
4
+ "version": "0.0.102",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -169,26 +169,42 @@ function realm(ctx: RouteContext): string {
169
169
  return h;
170
170
  }
171
171
 
172
- /**
173
- * Whether this domain requires a confirmed email before login. A plain
174
- * (tenant-readable) env var so an app can opt in itself; the edge ALSO force-sets
175
- * it to "true" from the per-domain platform toggle `HostConfig.require_email_confirmation`
176
- * (reserved key `TOIL_AUTH_REQUIRE_EMAIL_CONFIRMATION`), so a Dacely per-domain
177
- * setting can turn it on without the app changing a line.
178
- */
179
- function requireConfirmation(): bool {
180
- const v = Environment.get('AUTH_REQUIRE_EMAIL_CONFIRMATION');
172
+ /** A lenient truthy read of a plain env var, ALIGNED with the edge's HostConfig
173
+ * parse (`!matches!(v.trim(), "0"|"false"|"no"|"off")`). A strict `== "true"` would
174
+ * let a tenant slip a truthy-but-non-canonical value ("1"/"on"/"TRUE"/"true ") past
175
+ * the guest while the edge treated it as opted-in and skipped the force-on
176
+ * injection, defeating the platform mandate. Same parse on both sides closes that. */
177
+ function envTruthy(key: string): bool {
178
+ const v = Environment.get(key);
181
179
  if (v == null) return false;
182
- // Lenient truthy, ALIGNED with the edge's HostConfig parse
183
- // (`!matches!(v.trim(), "0"|"false"|"no"|"off")`). A strict `== "true"` here
184
- // let a tenant slip a truthy-but-non-canonical value ("1"/"on"/"TRUE"/"true ")
185
- // past the guest while the edge treated it as "already opted in" and skipped
186
- // the force-on injection -> the platform mandate was defeatable. Same parse on
187
- // both sides closes that gap.
188
180
  const t = v.trim().toLowerCase();
189
181
  return t.length != 0 && t != '0' && t != 'false' && t != 'no' && t != 'off';
190
182
  }
191
183
 
184
+ /**
185
+ * Whether REGISTRATION emails a confirm link and stores the account UNCONFIRMED.
186
+ * This is the register-time email-verification switch; on its own it does NOT block
187
+ * login (an unconfirmed user can still sign in; the app can nudge them to verify).
188
+ * Turned on by `AUTH_EMAIL_CONFIRMATION`, and IMPLIED by {@link requireConfirmation}
189
+ * (you cannot require a confirmation you never send).
190
+ */
191
+ function sendConfirmationEmail(): bool {
192
+ return envTruthy('AUTH_EMAIL_CONFIRMATION') || requireConfirmation();
193
+ }
194
+
195
+ /**
196
+ * Whether this domain BLOCKS login until the email is confirmed (the stricter
197
+ * form; it also sends the confirm email at register, via {@link sendConfirmationEmail}).
198
+ * A plain (tenant-readable) env var so an app can opt in itself; the edge ALSO
199
+ * force-sets it to "true" from the per-domain platform toggle
200
+ * `HostConfig.require_email_confirmation` (reserved key
201
+ * `TOIL_AUTH_REQUIRE_EMAIL_CONFIRMATION`), so a Dacely per-domain setting can turn it
202
+ * on without the app changing a line.
203
+ */
204
+ function requireConfirmation(): bool {
205
+ return envTruthy('AUTH_REQUIRE_EMAIL_CONFIRMATION');
206
+ }
207
+
192
208
  /**
193
209
  * The absolute origin the emailed confirm/reset links point at. Prefer the
194
210
  * tenant-set `PUBLIC_BASE_URL` (e.g. `https://app.example.com`); fall back to the
@@ -367,6 +383,34 @@ function isKnownTwoFaMethod(m: u8): bool {
367
383
  // >>> e.g. `|| m == TWOFA_TOTP || m == TWOFA_SMS` <<<
368
384
  }
369
385
 
386
+ /**
387
+ * A plausible email address: length 3..254, exactly one `@` (not at either end), a
388
+ * dotted domain (a `.` after the `@`, not adjacent to it or at the very end), and no
389
+ * spaces or control characters. AssemblyScript has no RegExp, so this scans chars.
390
+ * Mirrors the client `isValidEmail`; the client validates first, but a hostile client
391
+ * can skip that, so the server re-checks BEFORE storing (never register `bob.com`).
392
+ */
393
+ function isValidEmail(email: string): bool {
394
+ if (email.length < 3 || email.length > 254) return false;
395
+ let at: i32 = -1;
396
+ for (let i = 0; i < email.length; i++) {
397
+ const c = email.charCodeAt(i);
398
+ if (c <= 32 || c == 127) return false; // no space / control chars
399
+ if (c == 64) {
400
+ // '@'
401
+ if (at >= 0) return false; // a second '@' is invalid
402
+ at = i;
403
+ }
404
+ }
405
+ if (at <= 0 || at >= email.length - 1) return false; // '@' present, not first/last
406
+ let dot: i32 = -1;
407
+ for (let i = at + 1; i < email.length; i++) {
408
+ if (email.charCodeAt(i) == 46) dot = i; // last '.' in the domain
409
+ }
410
+ if (dot < 0 || dot == at + 1 || dot == email.length - 1) return false; // dotted domain
411
+ return true;
412
+ }
413
+
370
414
  // Every lookup KEY carries the tenant `host` (realm) as its FIRST field, so the
371
415
  // physical key includes the domain and the SAME logical name/email/id addresses a
372
416
  // DIFFERENT row per host (cross-domain isolation, on top of toildb's per-tenant
@@ -551,7 +595,7 @@ class Auth {
551
595
  if (!r.ok) return fail();
552
596
  if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
553
597
  if (username.length == 0 || username.length > 64) return fail();
554
- if (email.length == 0 || email.length > 254) return fail();
598
+ if (!isValidEmail(email)) return fail(); // format + length; a bypassing client stores nothing garbage
555
599
 
556
600
  // Proof-of-possession FIRST (before any existence check): the client
557
601
  // signed buildRegisterMessage with the matching secret key. Verifying up
@@ -575,7 +619,11 @@ class Auth {
575
619
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
576
620
  }
577
621
 
578
- const mustConfirm = requireConfirmation();
622
+ // Send the confirm email + store unconfirmed when EITHER flag is on
623
+ // (AUTH_EMAIL_CONFIRMATION or the stricter AUTH_REQUIRE_EMAIL_CONFIRMATION).
624
+ // Whether login is then BLOCKED is a separate check (requireConfirmation,
625
+ // used in login/finish), so a domain can verify-at-register without gating.
626
+ const mustConfirm = sendConfirmationEmail();
579
627
  const a = new AuthAccount();
580
628
  a.username = username;
581
629
  a.email = email;
@@ -218,6 +218,12 @@ export async function register(
218
218
  opts: AuthOptions = {},
219
219
  ): Promise<void> {
220
220
  const baseUrl = opts.baseUrl ?? '/auth';
221
+ // Validate BEFORE any crypto or network. The password never leaves this module,
222
+ // so the client is the ONLY place its policy can be enforced.
223
+ if (!isValidUsername(username)) throw new InvalidUsernameError();
224
+ if (!isValidEmail(email)) throw new InvalidEmailError();
225
+ const weak = checkPassword(password);
226
+ if (weak.length > 0) throw new WeakPasswordError(weak);
221
227
  const oprf = ristretto255_oprf.oprf;
222
228
  const pw = utf8(password.normalize('NFKC'));
223
229
 
@@ -448,6 +454,12 @@ export enum AuthErrorCode {
448
454
  ConfirmationInvalid = 'confirmation_invalid',
449
455
  /** reset: the password-reset link was invalid or expired. */
450
456
  PasswordResetInvalid = 'password_reset_invalid',
457
+ /** register/reset: the password does not meet the strength policy (see WeakPasswordError.unmet). */
458
+ WeakPassword = 'weak_password',
459
+ /** register: the email address is not a valid format / length. */
460
+ InvalidEmail = 'invalid_email',
461
+ /** register: the username is empty or too long. */
462
+ InvalidUsername = 'invalid_username',
451
463
  }
452
464
 
453
465
  /**
@@ -548,6 +560,107 @@ export class PasswordResetInvalidError extends AuthError {
548
560
  }
549
561
  }
550
562
 
563
+ // --- INPUT VALIDATION (client-side) --------------------------------------------------------------
564
+ //
565
+ // The password NEVER leaves this module (it is stretched into a key), so the SERVER
566
+ // cannot enforce a password policy: it is enforced HERE, before any crypto or
567
+ // network. Email + username are validated here too (and the server re-validates the
568
+ // email format independently, since a hostile client can skip this). All exported so
569
+ // a form can show live feedback with the SAME rules register/reset enforce.
570
+
571
+ /** One password requirement. See {@link checkPassword} / {@link DEFAULT_PASSWORD_POLICY}. */
572
+ export type PasswordRule = 'minLength' | 'maxLength' | 'digit' | 'uppercase' | 'special';
573
+
574
+ /** The enforced password strength policy. */
575
+ export interface PasswordPolicy {
576
+ /** Minimum length. Default 8. */
577
+ readonly minLength: number;
578
+ /** Maximum length (guards against absurd inputs before Argon2id). Default 256. */
579
+ readonly maxLength: number;
580
+ /** Require at least one digit (0-9). Default true. */
581
+ readonly digit: boolean;
582
+ /** Require at least one uppercase letter (A-Z). Default true. */
583
+ readonly uppercase: boolean;
584
+ /** Require at least one special (non-alphanumeric) character. Default true. */
585
+ readonly special: boolean;
586
+ }
587
+
588
+ /** The default policy: at least 8 characters, a digit, an uppercase letter, a special character. */
589
+ export const DEFAULT_PASSWORD_POLICY: PasswordPolicy = {
590
+ minLength: 8,
591
+ maxLength: 256,
592
+ digit: true,
593
+ uppercase: true,
594
+ special: true,
595
+ };
596
+
597
+ /** Human labels for the requirements, for messages or a live checklist. */
598
+ export const PASSWORD_RULE_LABELS: Record<PasswordRule, string> = {
599
+ minLength: 'at least 8 characters',
600
+ maxLength: 'at most 256 characters',
601
+ digit: 'a number',
602
+ uppercase: 'an uppercase letter',
603
+ special: 'a special character',
604
+ };
605
+
606
+ /**
607
+ * Which password rules are UNMET (an empty array means the password is acceptable).
608
+ * Pure and exported, so a form can show live feedback as the user types using the
609
+ * SAME rules {@link register} / {@link resetPassword} enforce.
610
+ */
611
+ export function checkPassword(
612
+ password: string,
613
+ policy: PasswordPolicy = DEFAULT_PASSWORD_POLICY,
614
+ ): PasswordRule[] {
615
+ const unmet: PasswordRule[] = [];
616
+ if (password.length < policy.minLength) unmet.push('minLength');
617
+ if (password.length > policy.maxLength) unmet.push('maxLength');
618
+ if (policy.digit && !/[0-9]/.test(password)) unmet.push('digit');
619
+ if (policy.uppercase && !/[A-Z]/.test(password)) unmet.push('uppercase');
620
+ if (policy.special && !/[^A-Za-z0-9]/.test(password)) unmet.push('special');
621
+ return unmet;
622
+ }
623
+
624
+ /** Whether `email` is a plausible address (length 3 to 254, one `@`, a dotted domain). */
625
+ export function isValidEmail(email: string): boolean {
626
+ return email.length >= 3 && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
627
+ }
628
+
629
+ /** Whether `username` is a valid length (1 to 64), matching the server bound. */
630
+ export function isValidUsername(username: string): boolean {
631
+ return username.length >= 1 && username.length <= 64;
632
+ }
633
+
634
+ /** register/reset: the password failed the policy. `unmet` lists which rules failed so a
635
+ * UI can render a precise checklist. {@link AuthErrorCode.WeakPassword}. */
636
+ export class WeakPasswordError extends AuthError {
637
+ readonly unmet: readonly PasswordRule[];
638
+ constructor(unmet: readonly PasswordRule[]) {
639
+ super(
640
+ AuthErrorCode.WeakPassword,
641
+ 'Password must have ' + unmet.map((r) => PASSWORD_RULE_LABELS[r]).join(', ') + '.',
642
+ );
643
+ this.name = 'WeakPasswordError';
644
+ this.unmet = unmet;
645
+ }
646
+ }
647
+
648
+ /** register: the email address is not a valid format ({@link AuthErrorCode.InvalidEmail}). */
649
+ export class InvalidEmailError extends AuthError {
650
+ constructor() {
651
+ super(AuthErrorCode.InvalidEmail, 'Please enter a valid email address.');
652
+ this.name = 'InvalidEmailError';
653
+ }
654
+ }
655
+
656
+ /** register: the username is empty or too long ({@link AuthErrorCode.InvalidUsername}). */
657
+ export class InvalidUsernameError extends AuthError {
658
+ constructor() {
659
+ super(AuthErrorCode.InvalidUsername, 'Username must be 1 to 64 characters.');
660
+ this.name = 'InvalidUsernameError';
661
+ }
662
+ }
663
+
551
664
  /** The 2FA method values, mirroring the server enum (append-only). */
552
665
  export const TwoFactorMethod = { None: 0, Email: 1 } as const;
553
666
 
@@ -665,6 +778,9 @@ export async function resetPassword(
665
778
  opts: AuthOptions = {},
666
779
  ): Promise<void> {
667
780
  const baseUrl = opts.baseUrl ?? '/auth';
781
+ // The new password is subject to the same policy as registration.
782
+ const weak = checkPassword(newPassword);
783
+ if (weak.length > 0) throw new WeakPasswordError(weak);
668
784
  const rawToken = fromHex(token);
669
785
  const oprf = ristretto255_oprf.oprf;
670
786
  const pw = utf8(newPassword.normalize('NFKC'));
@@ -741,4 +857,13 @@ export const Auth = {
741
857
  TwoFactorCodeError,
742
858
  ConfirmationInvalidError,
743
859
  PasswordResetInvalidError,
860
+ WeakPasswordError,
861
+ InvalidEmailError,
862
+ InvalidUsernameError,
863
+ // Input validation (exported so a form can show live feedback with the enforced rules).
864
+ checkPassword,
865
+ isValidEmail,
866
+ isValidUsername,
867
+ DEFAULT_PASSWORD_POLICY,
868
+ PASSWORD_RULE_LABELS,
744
869
  } as const;
@@ -29,8 +29,16 @@ export {
29
29
  TwoFactorCodeError,
30
30
  ConfirmationInvalidError,
31
31
  PasswordResetInvalidError,
32
+ WeakPasswordError,
33
+ InvalidEmailError,
34
+ InvalidUsernameError,
35
+ checkPassword,
36
+ isValidEmail,
37
+ isValidUsername,
38
+ DEFAULT_PASSWORD_POLICY,
39
+ PASSWORD_RULE_LABELS,
32
40
  } from './auth.js';
33
- export type { KdfParams, AuthOptions } from './auth.js';
41
+ export type { KdfParams, AuthOptions, PasswordPolicy, PasswordRule } from './auth.js';
34
42
  export { Link } from './navigation/Link.js';
35
43
  export type { LinkProps } from './navigation/Link.js';
36
44
  export { NavLink, matchActive } from './navigation/NavLink.js';
@@ -45,18 +45,29 @@ ${DOC_POINTERS}
45
45
 
46
46
  ## Rules you must follow
47
47
 
48
- - **Never call the toiljs backend with raw \`fetch\`. Always use the generated typed client.**
49
- Use \`Server.REST.<controller>.<route>(args)\` for \`@rest\` HTTP controllers, or
50
- \`Server.<service>.<method>(args)\` / \`Server.<remote>(args)\` for \`@service\` / \`@remote\` RPC.
51
- \`Server\` is a global (no import needed in client code). Raw \`fetch\` to your own backend
52
- throws away type safety, the argument/return decoding, and the binary wire format, and it
53
- breaks silently on a rename.
54
- Wrong: \`await fetch('/account/session', { method: 'POST' })\`.
55
- Right: \`await Server.REST.account.session()\`.
56
- \`fetch\` is acceptable ONLY for third-party URLs that are not your toiljs backend.
57
- See \`.toil/docs/frontend/data-fetching.md\`.
58
- - Use the ambient \`Toil.*\` globals (no import) for the client: \`Toil.Link\`, \`Toil.useLoaderData\`,
59
- \`Toil.Image\`, \`Toil.useHead\`, and so on. See \`.toil/docs/frontend/toil-global.md\` for the full list.
48
+ Read \`.toil/docs/concepts/ai-guide.md\` first, it is the concise correct-usage cheat-sheet. The essentials:
49
+
50
+ - **Never call the toiljs backend with raw \`fetch\`.** Use the generated typed client:
51
+ \`Server.REST.<controller>.<route>(args)\` for \`@rest\` HTTP controllers, or
52
+ \`Server.<service>.<method>(args)\` / \`Server.<remote>(args)\` for \`@service\` / \`@remote\` RPC. \`Server\` is a
53
+ global (no import). Raw \`fetch\` to your own backend throws away type safety and the wire codec and
54
+ breaks silently on a rename. Wrong: \`await fetch('/account/session', { method: 'POST' })\`. Right:
55
+ \`await Server.REST.account.session()\`. \`fetch\` is only for third-party URLs.
56
+ - Use the ambient \`Toil.*\` globals with NO import (\`Toil.Link\`, \`Toil.useLoaderData\`, \`Toil.Image\`,
57
+ \`Toil.Form\`, \`Toil.useHead\`, ...). \`Server\`, \`FastMap\`, \`FastSet\`, \`DataWriter\`, \`DataReader\` are bare
58
+ globals too. Load page data with \`export const loader\` + \`Toil.useLoaderData(loader)\`, NOT a \`useEffect\`
59
+ fetch; mutate with \`Toil.useAction\` / \`<Toil.Form>\` then revalidate.
60
+ - Client \`getUser()\` is DISPLAY-ONLY and forgeable, never gate real access on it. Enforce access on the
61
+ SERVER with \`@auth\` (it re-verifies the signed session, so inside an \`@auth\` handler
62
+ \`AuthService.getUser()!\` is guaranteed non-null).
63
+ - Server code is strict AssemblyScript: explicit value types (\`i32\`/\`u32\`/\`i64\`/\`u64\`/\`f64\`/\`bool\`/
64
+ \`string\`/\`Uint8Array\`), no \`any\`, no plain \`number\`, no usable \`RegExp\`. Structured values are \`@data\`
65
+ classes; exactly one \`@user\` per project; read config with \`Environment.get\` and secrets with
66
+ \`Environment.getSecure\`.
67
+ - Never hand-edit \`shared/server.ts\` or anything under \`.toil/\` (both are generated).
68
+
69
+ See \`.toil/docs/frontend/data-fetching.md\`, \`.toil/docs/frontend/toil-global.md\`, and
70
+ \`.toil/docs/concepts/decorators.md\` for detail.
60
71
 
61
72
  \`.toil/docs/\` is regenerated by toiljs; do not edit it by hand. This pointer file is yours to edit.
62
73
  `;