toiljs 0.0.99 → 0.0.101
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 +10 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +35 -1
- package/build/client/auth.js +75 -0
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +26 -0
- package/build/compiler/toil-docs.generated.js +5 -4
- package/docs/README.md +2 -1
- package/docs/concepts/ai-guide.md +132 -0
- package/docs/frontend/data-fetching.md +17 -0
- package/docs/frontend/rendering.md +5 -5
- package/docs/llms.txt +1 -0
- package/docs/services/cookies.md +2 -2
- package/package.json +1 -1
- package/server/auth/AuthController.ts +29 -1
- package/src/client/auth.ts +125 -0
- package/src/client/index.ts +9 -1
- package/src/compiler/docs.ts +26 -0
- package/src/compiler/toil-docs.generated.ts +5 -4
- package/test/pqauth-e2e.test.ts +5 -5
- package/test/pqauth-email.test.ts +83 -44
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
|
-
- [
|
|
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).
|
|
@@ -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>
|
|
@@ -13,6 +13,23 @@ There are two surfaces under `Server`:
|
|
|
13
13
|
|
|
14
14
|
Both are generated from your server code, so if you rename a route or change an argument type, the call site is a compile error until you fix it.
|
|
15
15
|
|
|
16
|
+
> **Rule: never call your own backend with raw `fetch`. Always go through `Server`.**
|
|
17
|
+
> Use `Server.REST.<controller>.<route>(args)` for `@rest` HTTP controllers, or
|
|
18
|
+
> `Server.<service>.<method>(args)` / `Server.<remote>(args)` for `@service` / `@remote`
|
|
19
|
+
> RPC. Raw `fetch` throws away the type safety, the argument/return decoding, and the
|
|
20
|
+
> binary wire format that the generated client handles for you, and it silently breaks
|
|
21
|
+
> when a route is renamed. This applies to AI assistants generating code too.
|
|
22
|
+
>
|
|
23
|
+
> ```ts
|
|
24
|
+
> // WRONG: raw fetch to your backend
|
|
25
|
+
> await fetch('/account/session', { method: 'POST', credentials: 'same-origin' });
|
|
26
|
+
>
|
|
27
|
+
> // RIGHT: the typed client (renames become compile errors, args + result are typed)
|
|
28
|
+
> await Server.REST.account.session();
|
|
29
|
+
> ```
|
|
30
|
+
>
|
|
31
|
+
> `fetch` is only for URLs that are NOT your toiljs backend (a third-party API, a CDN).
|
|
32
|
+
|
|
16
33
|
## Calling a REST endpoint
|
|
17
34
|
|
|
18
35
|
`Server.REST` mirrors your `@rest` controllers. If your backend has a `players` controller with routes on it, you call them like this:
|
|
@@ -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)
|
|
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
|
|
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
|
|
103
|
-
Note over U: React hydrates
|
|
104
|
-
Note over U: Page is interactive
|
|
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.
|
package/docs/services/cookies.md
CHANGED
|
@@ -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')
|
|
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
|
@@ -367,6 +367,34 @@ function isKnownTwoFaMethod(m: u8): bool {
|
|
|
367
367
|
// >>> e.g. `|| m == TWOFA_TOTP || m == TWOFA_SMS` <<<
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* A plausible email address: length 3..254, exactly one `@` (not at either end), a
|
|
372
|
+
* dotted domain (a `.` after the `@`, not adjacent to it or at the very end), and no
|
|
373
|
+
* spaces or control characters. AssemblyScript has no RegExp, so this scans chars.
|
|
374
|
+
* Mirrors the client `isValidEmail`; the client validates first, but a hostile client
|
|
375
|
+
* can skip that, so the server re-checks BEFORE storing (never register `bob.com`).
|
|
376
|
+
*/
|
|
377
|
+
function isValidEmail(email: string): bool {
|
|
378
|
+
if (email.length < 3 || email.length > 254) return false;
|
|
379
|
+
let at: i32 = -1;
|
|
380
|
+
for (let i = 0; i < email.length; i++) {
|
|
381
|
+
const c = email.charCodeAt(i);
|
|
382
|
+
if (c <= 32 || c == 127) return false; // no space / control chars
|
|
383
|
+
if (c == 64) {
|
|
384
|
+
// '@'
|
|
385
|
+
if (at >= 0) return false; // a second '@' is invalid
|
|
386
|
+
at = i;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (at <= 0 || at >= email.length - 1) return false; // '@' present, not first/last
|
|
390
|
+
let dot: i32 = -1;
|
|
391
|
+
for (let i = at + 1; i < email.length; i++) {
|
|
392
|
+
if (email.charCodeAt(i) == 46) dot = i; // last '.' in the domain
|
|
393
|
+
}
|
|
394
|
+
if (dot < 0 || dot == at + 1 || dot == email.length - 1) return false; // dotted domain
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
370
398
|
// Every lookup KEY carries the tenant `host` (realm) as its FIRST field, so the
|
|
371
399
|
// physical key includes the domain and the SAME logical name/email/id addresses a
|
|
372
400
|
// DIFFERENT row per host (cross-domain isolation, on top of toildb's per-tenant
|
|
@@ -551,7 +579,7 @@ class Auth {
|
|
|
551
579
|
if (!r.ok) return fail();
|
|
552
580
|
if (pk.length != AuthService.PUBLIC_KEY_LEN) return fail();
|
|
553
581
|
if (username.length == 0 || username.length > 64) return fail();
|
|
554
|
-
if (email
|
|
582
|
+
if (!isValidEmail(email)) return fail(); // format + length; a bypassing client stores nothing garbage
|
|
555
583
|
|
|
556
584
|
// Proof-of-possession FIRST (before any existence check): the client
|
|
557
585
|
// signed buildRegisterMessage with the matching secret key. Verifying up
|
package/src/client/auth.ts
CHANGED
|
@@ -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;
|
package/src/client/index.ts
CHANGED
|
@@ -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';
|
package/src/compiler/docs.ts
CHANGED
|
@@ -43,6 +43,32 @@ folder whose \`README.md\` links onward to its topic pages:
|
|
|
43
43
|
|
|
44
44
|
${DOC_POINTERS}
|
|
45
45
|
|
|
46
|
+
## Rules you must follow
|
|
47
|
+
|
|
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.
|
|
71
|
+
|
|
46
72
|
\`.toil/docs/\` is regenerated by toiljs; do not edit it by hand. This pointer file is yours to edit.
|
|
47
73
|
`;
|
|
48
74
|
|