toiljs 0.0.34 → 0.0.37
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 +15 -0
- package/README.md +1 -0
- package/as-pect.config.js +8 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +97 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +42 -0
- package/build/client/auth.js +182 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/routing/loader.d.ts +1 -0
- package/build/client/routing/loader.js +37 -0
- package/build/client/routing/mount.js +32 -1
- package/build/client/ssr/markers.d.ts +34 -0
- package/build/client/ssr/markers.js +49 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +88 -1
- package/build/compiler/generate.d.ts +2 -0
- package/build/compiler/generate.js +2 -2
- package/build/compiler/index.js +2 -0
- package/build/compiler/ssr-codegen.d.ts +2 -0
- package/build/compiler/ssr-codegen.js +36 -0
- package/build/compiler/template-build.d.ts +29 -0
- package/build/compiler/template-build.js +150 -0
- package/build/compiler/template.d.ts +22 -0
- package/build/compiler/template.js +169 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +15 -0
- package/build/devserver/host.js +1 -0
- package/build/devserver/module.d.ts +1 -0
- package/build/devserver/module.js +23 -1
- package/docs/README.md +56 -0
- package/docs/auth.md +261 -0
- package/docs/caching.md +115 -0
- package/docs/cookies.md +457 -0
- package/docs/crypto.md +130 -0
- package/docs/data.md +131 -0
- package/docs/getting-started.md +128 -0
- package/docs/routing.md +259 -0
- package/docs/rpc.md +149 -0
- package/docs/ssr.md +184 -0
- package/docs/time.md +43 -0
- package/examples/basic/client/routes/auth.tsx +198 -0
- package/examples/basic/client/routes/cookies.tsx +199 -0
- package/examples/basic/client/routes/features/index.tsx +34 -10
- package/examples/basic/client/routes/hello.tsx +43 -0
- package/examples/basic/client/routes/pq.tsx +260 -0
- package/examples/basic/server/AuthTestHandler.ts +15 -0
- package/examples/basic/server/AuthVerifyHandler.ts +23 -0
- package/examples/basic/server/CacheHandler.ts +25 -0
- package/examples/basic/server/DecoCache.ts +18 -0
- package/examples/basic/server/FastTrapHandler.ts +8 -0
- package/examples/basic/server/SpinHandler.ts +18 -0
- package/examples/basic/server/SsrGreetingRender.ts +27 -0
- package/examples/basic/server/authexample-main.ts +8 -0
- package/examples/basic/server/authtest-main.ts +8 -0
- package/examples/basic/server/authverify-main.ts +8 -0
- package/examples/basic/server/cache-main.ts +8 -0
- package/examples/basic/server/core/AppHandler.ts +243 -0
- package/examples/basic/server/deco-main.ts +18 -0
- package/examples/basic/server/main.ts +2 -0
- package/examples/basic/server/routes/Auth.ts +184 -0
- package/examples/basic/server/routes/PqDemo.ts +130 -0
- package/examples/basic/server/routes/Session.ts +74 -0
- package/examples/basic/server/spin-main.ts +13 -0
- package/examples/basic/server/ssr/greeting.slots.ts +19 -0
- package/examples/basic/server/ssr-main.ts +18 -0
- package/examples/basic/server/toil-server-env.d.ts +94 -0
- package/examples/basic/server/trap-main.ts +8 -0
- package/package.json +5 -3
- package/server/globals/auth.ts +281 -0
- package/server/runtime/README.md +61 -0
- package/server/runtime/env/Server.ts +12 -0
- package/server/runtime/exports/index.ts +17 -0
- package/server/runtime/exports/render.ts +51 -0
- package/server/runtime/http/base64.ts +104 -0
- package/server/runtime/http/cookie.ts +416 -0
- package/server/runtime/http/cookies.ts +197 -0
- package/server/runtime/http/date.ts +72 -0
- package/server/runtime/http/percent.ts +76 -0
- package/server/runtime/http/securecookies.ts +224 -0
- package/server/runtime/index.ts +17 -0
- package/server/runtime/request.ts +24 -0
- package/server/runtime/response.ts +29 -0
- package/server/runtime/ssr/Ssr.ts +43 -0
- package/server/runtime/ssr/encode.ts +110 -0
- package/server/runtime/ssr/escape.ts +83 -0
- package/server/runtime/ssr/slots.ts +144 -0
- package/server/runtime/time.ts +29 -0
- package/src/cli/create.ts +105 -0
- package/src/client/auth.ts +327 -0
- package/src/client/index.ts +5 -1
- package/src/client/routing/loader.ts +56 -0
- package/src/client/routing/mount.tsx +37 -1
- package/src/client/ssr/markers.tsx +140 -0
- package/src/compiler/docs.ts +88 -1
- package/src/compiler/generate.ts +2 -2
- package/src/compiler/index.ts +5 -0
- package/src/compiler/ssr-codegen.ts +85 -0
- package/src/compiler/template-build.ts +275 -0
- package/src/compiler/template.ts +265 -0
- package/src/devserver/crypto.ts +23 -0
- package/src/devserver/host.ts +4 -0
- package/src/devserver/module.ts +39 -1
- package/test/assembly/cookie.spec.ts +302 -0
- package/test/assembly/example.spec.ts +5 -1
- package/test/assembly/ssr.spec.ts +94 -0
- package/test/devserver.test.ts +42 -0
- package/test/ssr-render.test.ts +128 -0
- package/test/ssr-template.test.tsx +348 -0
package/docs/ssr.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# SSR templates
|
|
2
|
+
|
|
3
|
+
toiljs renders pages with a split that keeps the wasm guest tiny and the output
|
|
4
|
+
coherent with the client bundle: the **template** (the HTML shell, with named
|
|
5
|
+
holes) is precompiled and held by the edge, and the guest's `render` entrypoint
|
|
6
|
+
returns only the **hole values** — text, attributes, repeated rows, headers,
|
|
7
|
+
status. The edge splices the values into the template. A 32-byte template hash
|
|
8
|
+
travels with the values so the edge can reject a guest/template skew.
|
|
9
|
+
|
|
10
|
+
This is for server-rendered HTML. JSON/binary API endpoints use
|
|
11
|
+
[Routing](./routing.md) instead.
|
|
12
|
+
|
|
13
|
+
## Authoring a route
|
|
14
|
+
|
|
15
|
+
Opt a route in with `export const ssr = true`. toiljs renders it ONCE at build
|
|
16
|
+
into the template (the static HTML shell, holes removed) and generates its typed
|
|
17
|
+
`Slot` module; at request time the server fills only the holes and the browser
|
|
18
|
+
hydrates the result in place, so an SSR route is served about as fast as a static
|
|
19
|
+
file while still delivering real first-paint HTML and SEO.
|
|
20
|
+
|
|
21
|
+
Mark the dynamic bits of the page with the hole markers from `toiljs/client`.
|
|
22
|
+
They are transparent in the browser (they just render their children), so the
|
|
23
|
+
same component is your normal client UI; only the build and the server treat them
|
|
24
|
+
specially.
|
|
25
|
+
|
|
26
|
+
- `<Hole id="name">{value}</Hole>`, a text hole (HTML-escaped for you).
|
|
27
|
+
- `<RawHtml id="bio" html={s} />`, a raw-HTML block (you own sanitisation, like
|
|
28
|
+
`dangerouslySetInnerHTML`).
|
|
29
|
+
- `<Repeat id="rows" each={items}>{(item) => <li>...</li>}</Repeat>`, a repeated
|
|
30
|
+
region; the row markup is captured once and stamped per item.
|
|
31
|
+
- `<Island>{...}</Island>`, a client-only escape hatch: empty in the server HTML,
|
|
32
|
+
rendered after hydration (no first paint or SEO). Put router-hook-driven or
|
|
33
|
+
otherwise non-server-safe content here.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { Hole, Repeat, RawHtml, useLoaderData } from 'toiljs/client';
|
|
37
|
+
|
|
38
|
+
export const ssr = true;
|
|
39
|
+
export const loader = ({ params }: Toil.LoaderArgs) => loadProfile(params.name);
|
|
40
|
+
|
|
41
|
+
export default function Profile() {
|
|
42
|
+
const d = useLoaderData<typeof loader>();
|
|
43
|
+
return (
|
|
44
|
+
<main>
|
|
45
|
+
<h1>@<Hole id="username">{d.username}</Hole></h1>
|
|
46
|
+
<RawHtml id="bio" html={d.bioHtml} />
|
|
47
|
+
<ul>
|
|
48
|
+
<Repeat id="posts" each={d.posts}>
|
|
49
|
+
{(p) => <li><Hole id="title">{p.title}</Hole></li>}
|
|
50
|
+
</Repeat>
|
|
51
|
+
</ul>
|
|
52
|
+
</main>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The build derives each hole's `Slot` id and the template `HASH` from this render,
|
|
58
|
+
emits the template manifest the edge serves, and the matching `Slot` module you
|
|
59
|
+
import in the server `render` below.
|
|
60
|
+
|
|
61
|
+
## The `render` entrypoint
|
|
62
|
+
|
|
63
|
+
`render(req_ofs, req_len) -> i64` is a wasm export (re-exported by your `main.ts`
|
|
64
|
+
via `export * from 'toiljs/server/runtime/exports'`). It decodes the request,
|
|
65
|
+
runs the SSR router to find a matching `render` function, serializes that
|
|
66
|
+
function's `SlotValues` into the values envelope, and returns it packed as
|
|
67
|
+
`(ptr << 32) | len`.
|
|
68
|
+
|
|
69
|
+
You register render functions with the `Ssr` router (the compiler injects the
|
|
70
|
+
registration for auto-discovered routes):
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { Ssr, SlotValues, HtmlBuilder } from 'toiljs/server/runtime';
|
|
74
|
+
import { Slot, HASH } from './greeting.slots';
|
|
75
|
+
|
|
76
|
+
function renderGreeting(req: Request): SlotValues | null {
|
|
77
|
+
if (req.path != '/hello') return null; // not my route
|
|
78
|
+
const v = new SlotValues(HASH);
|
|
79
|
+
v.setText(Slot.greeting, 'world & <friends>'); // React-escaped
|
|
80
|
+
return v;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Ssr.register(renderGreeting);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A render function returns `SlotValues` for a page it owns, or `null` to let the
|
|
87
|
+
next registered renderer try.
|
|
88
|
+
|
|
89
|
+
## Slots
|
|
90
|
+
|
|
91
|
+
A route's template defines its holes as a small typed module: a `Slot` enum of
|
|
92
|
+
stable numeric ids and a 32-byte `HASH` that pins the values to a specific
|
|
93
|
+
compiled template.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// greeting.slots.ts
|
|
97
|
+
export enum Slot { greeting = 0, count = 1 }
|
|
98
|
+
export const HASH: StaticArray<u8> = /* 32 bytes, generated from the template */;
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
These are generated by the compiler from your rendered template, and are
|
|
102
|
+
hand-writable through the typed API.
|
|
103
|
+
|
|
104
|
+
## `SlotValues`
|
|
105
|
+
|
|
106
|
+
The object a render function fills. Each setter targets a slot id; the kind
|
|
107
|
+
determines how the edge escapes and splices it.
|
|
108
|
+
|
|
109
|
+
| Method | Signature | Use |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `setText` | `setText(slotId, value: string)` | Text content. React-escaped (safe by default). |
|
|
112
|
+
| `setRaw` | `setRaw(slotId, html: string)` | Raw HTML. You are responsible for sanitizing it. |
|
|
113
|
+
| `setAttr` | `setAttr(slotId, value: string)` | An attribute value (attribute-escaped). |
|
|
114
|
+
| `setRepeat` | `setRepeat(slotId, rows: HtmlBuilder)` | A repeated region, pre-stamped row by row. |
|
|
115
|
+
| `setHeader` | `setHeader(name, value)` | A response header. |
|
|
116
|
+
| `setStatus` | `setStatus(code)` | The HTTP status. |
|
|
117
|
+
|
|
118
|
+
Construct it with the route's template hash: `new SlotValues(HASH)`.
|
|
119
|
+
|
|
120
|
+
## `HtmlBuilder`
|
|
121
|
+
|
|
122
|
+
Assembles a repeat region (or any HTML fragment) with the same escaping
|
|
123
|
+
guarantees, chaining `raw` (verbatim markup) and `text` (escaped content):
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const rows = new HtmlBuilder();
|
|
127
|
+
for (let i = 0; i < items.length; i++) {
|
|
128
|
+
rows.raw('<li>').text(items[i]).raw('</li>'); // items[i] is escaped
|
|
129
|
+
}
|
|
130
|
+
v.setRepeat(Slot.count, rows);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Escaping
|
|
134
|
+
|
|
135
|
+
`setText`, `setAttr`, and `HtmlBuilder.text` escape exactly as React does, so
|
|
136
|
+
server-rendered markup and client hydration agree:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
" → " & → & ' → '
|
|
140
|
+
< → < > → >
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
(`'` becomes `'` — React's exact choice — not `'`.) Use `setRaw` /
|
|
144
|
+
`HtmlBuilder.raw` only for markup you have produced or sanitized yourself.
|
|
145
|
+
|
|
146
|
+
## Hydration and SSR-safe routes
|
|
147
|
+
|
|
148
|
+
The browser hydrates the server HTML in place rather than re-rendering it: the
|
|
149
|
+
markup the edge splices is byte-for-byte what React would produce for the same
|
|
150
|
+
data (the holes are escaped exactly as React escapes them, above), so
|
|
151
|
+
`hydrateRoot` matches with no flash and no client re-render.
|
|
152
|
+
|
|
153
|
+
For that to hold, an SSR route and the layouts above it must render under static
|
|
154
|
+
markup: use the hole markers and `useLoaderData`, and move anything that needs
|
|
155
|
+
router hooks or browser-only APIs into an `<Island>`. A route that cannot render
|
|
156
|
+
this way is skipped at build (with a warning) and falls back to normal client
|
|
157
|
+
rendering, so opting in is always safe.
|
|
158
|
+
|
|
159
|
+
Build output for an SSR route lands in `build/client/_ssr/` (the template and its
|
|
160
|
+
binary manifest) alongside the generated `Slot` module; routes without
|
|
161
|
+
`ssr = true` are untouched.
|
|
162
|
+
|
|
163
|
+
## The values envelope
|
|
164
|
+
|
|
165
|
+
For reference, the guest serializes `SlotValues` to this little-endian layout
|
|
166
|
+
(the edge decodes it and splices against the template):
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
u16 status
|
|
170
|
+
[32] template_hash
|
|
171
|
+
u16 n_headers ; then for each: u16 name_len, u16 val_len, name, val
|
|
172
|
+
u16 n_slots ; then for each: u16 slot_id, u8 kind, u32 value_len, value
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
If a value cannot be represented (an overflow), the guest emits a fail-safe 500
|
|
176
|
+
rather than a corrupt page. The `template_hash` lets the edge detect and reject a
|
|
177
|
+
mismatch between the guest's idea of the template and the deployed one.
|
|
178
|
+
|
|
179
|
+
## Dev server
|
|
180
|
+
|
|
181
|
+
The dev server provides the same `render` path as the edge: `WasmServerModule`
|
|
182
|
+
has a `dispatchRender` that runs a fresh instance through the `render` export and
|
|
183
|
+
returns the raw values envelope, mirroring the edge contract, so SSR behaves the
|
|
184
|
+
same locally.
|
package/docs/time.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Time
|
|
2
|
+
|
|
3
|
+
`Time` is the guest's wall-clock. It is the toiljs-blessed way to read the
|
|
4
|
+
current time, backed by the host's `Date.now()` binding (`env.Date.now`). Both
|
|
5
|
+
the edge and the dev server provide that binding, so time behaves identically in
|
|
6
|
+
`toiljs dev` and in production.
|
|
7
|
+
|
|
8
|
+
It is available as an ambient global (`@global`, no import) and is also exported
|
|
9
|
+
from `toiljs/server/runtime`.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Time } from 'toiljs/server/runtime'; // optional; Time is also a global
|
|
13
|
+
|
|
14
|
+
const ms = Time.nowMillis(); // i64 milliseconds since the Unix epoch
|
|
15
|
+
const s = Time.nowSeconds(); // u64 whole seconds since the Unix epoch
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## API
|
|
19
|
+
|
|
20
|
+
| Member | Signature | Description |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `Time.nowMillis()` | `static nowMillis(): i64` | Milliseconds since the Unix epoch (the raw host `Date.now()` value). |
|
|
23
|
+
| `Time.nowSeconds()` | `static nowSeconds(): u64` | Whole seconds since the epoch (`nowMillis() / 1000`). The unit used by sessions and login challenges. |
|
|
24
|
+
|
|
25
|
+
## Semantics
|
|
26
|
+
|
|
27
|
+
`Time` is **wall-clock, not monotonic** — exactly like browser `Date.now()`. It
|
|
28
|
+
tracks the system clock and can step backward across an NTP correction.
|
|
29
|
+
|
|
30
|
+
- Use it to stamp and compare absolute instants: session `iat`/`exp`, login
|
|
31
|
+
challenge expiry, cache ages.
|
|
32
|
+
- Do **not** use it to measure elapsed time or as a high-resolution timer; a
|
|
33
|
+
backward step would produce a negative or zero interval.
|
|
34
|
+
|
|
35
|
+
## Relationship to `Date.now()`
|
|
36
|
+
|
|
37
|
+
ToilScript's `Date.now()` lowers to the same `env.Date.now` host import, so you
|
|
38
|
+
*can* call it directly. Prefer `Time`: it makes the host boundary (and the
|
|
39
|
+
single millisecond unit) explicit and easy to find, and it gives you
|
|
40
|
+
`nowSeconds()` without an open-coded `/ 1000` cast at every call site.
|
|
41
|
+
|
|
42
|
+
`AuthService` uses `Time.nowSeconds()` internally for session `iat`/`exp`, so
|
|
43
|
+
session timing and any timing you do in a handler share one clock.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Auth / session demo. Drives the server's `@user` / `@auth` / `AuthService`
|
|
2
|
+
// surface (server/routes/Session.ts): a dev login mints an HMAC-signed
|
|
3
|
+
// `__Host-toil_sess` cookie (+ a readable `__Secure-toil_user` companion), the
|
|
4
|
+
// guarded `/session/me` route returns the verified user, and logout clears both.
|
|
5
|
+
//
|
|
6
|
+
// Two views of "who am I": `getUser()` reads the readable companion cookie with
|
|
7
|
+
// no round-trip (instant, but UNTRUSTED, a client can forge it), while
|
|
8
|
+
// `GET /session/me` is the server re-verifying the signed session (trusted).
|
|
9
|
+
// The full post-quantum register/login (ML-DSA-44) needs an account store and is
|
|
10
|
+
// stubbed in server/routes/Auth.ts; see docs/auth.md.
|
|
11
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
import { Account } from 'shared/server';
|
|
14
|
+
|
|
15
|
+
/** Read one cookie value from `document.cookie`, or null. */
|
|
16
|
+
function readCookie(name: string): string | null {
|
|
17
|
+
const pairs = (document.cookie || '').split('; ');
|
|
18
|
+
for (const p of pairs) {
|
|
19
|
+
const eq = p.indexOf('=');
|
|
20
|
+
if (eq > 0 && p.slice(0, eq) === name) return p.slice(eq + 1);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function b64urlDecode(s: string): Uint8Array | null {
|
|
26
|
+
try {
|
|
27
|
+
let b = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
28
|
+
while (b.length % 4) b += '=';
|
|
29
|
+
const bin = atob(b);
|
|
30
|
+
const out = new Uint8Array(bin.length);
|
|
31
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
32
|
+
return out;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Decode the readable companion cookie under either name (HTTP `toil_user` or
|
|
39
|
+
* HTTPS `__Secure-toil_user`). UNTRUSTED, display-only, like the generated
|
|
40
|
+
* `getUser()` (which only knows the HTTPS name). */
|
|
41
|
+
function readCompanion(): Account | null {
|
|
42
|
+
const raw = readCookie('toil_user') ?? readCookie('__Secure-toil_user');
|
|
43
|
+
if (raw === null) return null;
|
|
44
|
+
const bytes = b64urlDecode(raw);
|
|
45
|
+
if (bytes === null) return null;
|
|
46
|
+
try {
|
|
47
|
+
return Account.decode(bytes);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const metadata: Toil.Metadata = {
|
|
54
|
+
title: 'Auth',
|
|
55
|
+
description:
|
|
56
|
+
'Sessions and the @user / @auth surface: a dev login mints a signed session cookie, the guarded /session/me returns the verified user, and getUser() reads the readable companion.',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Encode a bare string the way the server reads it (`DataReader.readString`). */
|
|
60
|
+
function encodeString(s: string): Uint8Array {
|
|
61
|
+
return new DataWriter().writeString(s).toBytes();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The server-verified user from `GET /session/me` (binary: string, bool, u64). */
|
|
65
|
+
interface VerifiedUser {
|
|
66
|
+
username: string;
|
|
67
|
+
admin: boolean;
|
|
68
|
+
score: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function Auth(): React.JSX.Element {
|
|
72
|
+
const [username, setUsername] = useState('ada');
|
|
73
|
+
const [companion, setCompanion] = useState<Account | null>(null);
|
|
74
|
+
const [verified, setVerified] = useState<VerifiedUser | null | 'none'>(null);
|
|
75
|
+
const [busy, setBusy] = useState(false);
|
|
76
|
+
const [log, setLog] = useState<string>('');
|
|
77
|
+
|
|
78
|
+
const refreshCompanion = useCallback(() => setCompanion(readCompanion()), []);
|
|
79
|
+
useEffect(refreshCompanion, [refreshCompanion]);
|
|
80
|
+
|
|
81
|
+
const devLogin = useCallback(async () => {
|
|
82
|
+
setBusy(true);
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch('/session/dev-login', { method: 'POST', credentials: 'same-origin', body: encodeString(username) as BodyInit });
|
|
85
|
+
setLog(`POST /session/dev-login -> ${res.status} ${(await res.text()).trim()}`);
|
|
86
|
+
refreshCompanion();
|
|
87
|
+
setVerified(null);
|
|
88
|
+
} finally {
|
|
89
|
+
setBusy(false);
|
|
90
|
+
}
|
|
91
|
+
}, [username, refreshCompanion]);
|
|
92
|
+
|
|
93
|
+
const checkSession = useCallback(async () => {
|
|
94
|
+
setBusy(true);
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch('/session/me', { credentials: 'same-origin' });
|
|
97
|
+
if (res.status === 401) {
|
|
98
|
+
setVerified('none');
|
|
99
|
+
setLog('GET /session/me -> 401 (no valid session)');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const r = new DataReader(new Uint8Array(await res.arrayBuffer()));
|
|
103
|
+
setVerified({ username: r.readString(), admin: r.readBool(), score: r.readU64().toString() });
|
|
104
|
+
setLog('GET /session/me -> 200 (server-verified session)');
|
|
105
|
+
} finally {
|
|
106
|
+
setBusy(false);
|
|
107
|
+
}
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const logout = useCallback(async () => {
|
|
111
|
+
setBusy(true);
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch('/session/logout', { method: 'POST', credentials: 'same-origin' });
|
|
114
|
+
setLog(`POST /session/logout -> ${res.status} ${(await res.text()).trim()}`);
|
|
115
|
+
refreshCompanion();
|
|
116
|
+
setVerified(null);
|
|
117
|
+
} finally {
|
|
118
|
+
setBusy(false);
|
|
119
|
+
}
|
|
120
|
+
}, [refreshCompanion]);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<main style={{ maxWidth: 640, margin: '0 auto', padding: '2rem 1rem', lineHeight: 1.5 }}>
|
|
124
|
+
<h1>Auth and sessions</h1>
|
|
125
|
+
<p>
|
|
126
|
+
A dev login mints an HMAC-signed <code>__Host-toil_sess</code> session cookie plus a
|
|
127
|
+
readable <code>__Secure-toil_user</code> companion. The guarded <code>/session/me</code>{' '}
|
|
128
|
+
route ( <code>@auth</code> ) re-verifies the signed session; <code>getUser()</code> reads
|
|
129
|
+
only the companion (display-only, untrusted). Needs the server running.
|
|
130
|
+
</p>
|
|
131
|
+
|
|
132
|
+
<section style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
133
|
+
<label>
|
|
134
|
+
user{' '}
|
|
135
|
+
<input
|
|
136
|
+
value={username}
|
|
137
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
138
|
+
style={{ padding: '0.3rem 0.5rem' }}
|
|
139
|
+
/>
|
|
140
|
+
</label>
|
|
141
|
+
<button onClick={devLogin} disabled={busy || username.length === 0}>
|
|
142
|
+
Dev login
|
|
143
|
+
</button>
|
|
144
|
+
<button onClick={checkSession} disabled={busy}>
|
|
145
|
+
Check /session/me
|
|
146
|
+
</button>
|
|
147
|
+
<button onClick={logout} disabled={busy}>
|
|
148
|
+
Logout
|
|
149
|
+
</button>
|
|
150
|
+
</section>
|
|
151
|
+
|
|
152
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1.5rem' }}>
|
|
153
|
+
<div style={{ border: '1px solid #2563ff55', borderRadius: 8, padding: '0.75rem 1rem' }}>
|
|
154
|
+
<h3 style={{ marginTop: 0 }}>getUser(), client</h3>
|
|
155
|
+
<p style={{ fontSize: '0.85em', opacity: 0.7, marginTop: 0 }}>
|
|
156
|
+
reads the readable companion cookie, untrusted, instant
|
|
157
|
+
</p>
|
|
158
|
+
{companion ? (
|
|
159
|
+
<pre>
|
|
160
|
+
{JSON.stringify(
|
|
161
|
+
{ username: companion.username, admin: companion.admin, score: String(companion.score) },
|
|
162
|
+
null,
|
|
163
|
+
2,
|
|
164
|
+
)}
|
|
165
|
+
</pre>
|
|
166
|
+
) : (
|
|
167
|
+
<p>no companion cookie</p>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div style={{ border: '1px solid #7c3aed55', borderRadius: 8, padding: '0.75rem 1rem' }}>
|
|
172
|
+
<h3 style={{ marginTop: 0 }}>/session/me, server</h3>
|
|
173
|
+
<p style={{ fontSize: '0.85em', opacity: 0.7, marginTop: 0 }}>
|
|
174
|
+
the server re-verifies the signed session, trusted
|
|
175
|
+
</p>
|
|
176
|
+
{verified === null ? (
|
|
177
|
+
<p>not checked yet</p>
|
|
178
|
+
) : verified === 'none' ? (
|
|
179
|
+
<p>401, no valid session</p>
|
|
180
|
+
) : (
|
|
181
|
+
<pre>{JSON.stringify(verified, null, 2)}</pre>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{log ? (
|
|
187
|
+
<pre style={{ marginTop: '1rem', background: '#0e152099', padding: '0.5rem 0.75rem', borderRadius: 6 }}>
|
|
188
|
+
{log}
|
|
189
|
+
</pre>
|
|
190
|
+
) : null}
|
|
191
|
+
|
|
192
|
+
<p style={{ marginTop: '1.5rem', fontSize: '0.85em', opacity: 0.7 }}>
|
|
193
|
+
The full post-quantum register/login (ML-DSA-44, password-derived) needs an account
|
|
194
|
+
store and is stubbed in <code>server/routes/Auth.ts</code>. See <code>docs/auth.md</code>.
|
|
195
|
+
</p>
|
|
196
|
+
</main>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Demo of the server-side cookie library. In the toilscript server, `Cookie`,
|
|
2
|
+
// `Cookies`, and `SecureCookies` are globals (no import), the full RFC 6265bis
|
|
3
|
+
// surface plus HMAC signing and AES-256-GCM encryption, running in the server wasm.
|
|
4
|
+
// These controls call the `/api/cookies/*` routes in `server/core/AppHandler.ts`.
|
|
5
|
+
// Needs the server running to respond.
|
|
6
|
+
import { useEffect, useState, type CSSProperties } from 'react';
|
|
7
|
+
|
|
8
|
+
export const metadata: Toil.Metadata = {
|
|
9
|
+
title: 'Cookies',
|
|
10
|
+
description:
|
|
11
|
+
'Server-side cookies as a global: the Cookie builder, parsing, HMAC signing, and AES-256-GCM encryption, running in the server wasm.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface SetResp {
|
|
15
|
+
visits: number;
|
|
16
|
+
emitted: string[];
|
|
17
|
+
}
|
|
18
|
+
interface InspectResp {
|
|
19
|
+
raw: string;
|
|
20
|
+
count: number;
|
|
21
|
+
cookies: Record<string, string>;
|
|
22
|
+
session: string | null;
|
|
23
|
+
secret: string | null;
|
|
24
|
+
}
|
|
25
|
+
interface SealResp {
|
|
26
|
+
value: string;
|
|
27
|
+
signed: string;
|
|
28
|
+
unsigned: string | null;
|
|
29
|
+
encrypted: string;
|
|
30
|
+
decrypted: string | null;
|
|
31
|
+
tamperVerifies: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mono: CSSProperties = { fontFamily: 'monospace', fontSize: '0.82rem', wordBreak: 'break-all' };
|
|
35
|
+
const card: CSSProperties = {
|
|
36
|
+
border: '1px solid #1d2530',
|
|
37
|
+
borderRadius: 8,
|
|
38
|
+
padding: '12px 16px',
|
|
39
|
+
margin: '12px 0',
|
|
40
|
+
background: '#0c1218',
|
|
41
|
+
};
|
|
42
|
+
const label: CSSProperties = { opacity: 0.7, fontSize: '0.8rem', marginTop: 6 };
|
|
43
|
+
|
|
44
|
+
export default function CookiesDemo() {
|
|
45
|
+
const [gallery, setGallery] = useState<Record<string, string> | null>(null);
|
|
46
|
+
const [setResp, setSetResp] = useState<SetResp | null>(null);
|
|
47
|
+
const [inspect, setInspect] = useState<InspectResp | null>(null);
|
|
48
|
+
const [cleared, setCleared] = useState<string[] | null>(null);
|
|
49
|
+
const [seal, setSeal] = useState<SealResp | null>(null);
|
|
50
|
+
const [sealInput, setSealInput] = useState('hello toiljs');
|
|
51
|
+
const [jsCookies, setJsCookies] = useState('');
|
|
52
|
+
const [err, setErr] = useState('');
|
|
53
|
+
|
|
54
|
+
const readJs = (): void => setJsCookies(document.cookie || '(nothing visible to JS)');
|
|
55
|
+
useEffect(readJs, []);
|
|
56
|
+
|
|
57
|
+
const guard = async (fn: () => Promise<void>): Promise<void> => {
|
|
58
|
+
setErr('');
|
|
59
|
+
try {
|
|
60
|
+
await fn();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
setErr(String(e));
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const getJSON = async <T,>(url: string): Promise<T> => {
|
|
66
|
+
const res = await fetch(url);
|
|
67
|
+
if (!res.ok) throw new Error(`${url} -> ${String(res.status)}`);
|
|
68
|
+
return (await res.json()) as T;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const showGallery = (): Promise<void> =>
|
|
72
|
+
guard(async () => setGallery(await getJSON<Record<string, string>>('/api/cookies/gallery')));
|
|
73
|
+
const doSet = (): Promise<void> =>
|
|
74
|
+
guard(async () => {
|
|
75
|
+
setSetResp(await getJSON<SetResp>('/api/cookies/set'));
|
|
76
|
+
readJs();
|
|
77
|
+
});
|
|
78
|
+
const doInspect = (): Promise<void> =>
|
|
79
|
+
guard(async () => setInspect(await getJSON<InspectResp>('/api/cookies/inspect')));
|
|
80
|
+
const doClear = (): Promise<void> =>
|
|
81
|
+
guard(async () => {
|
|
82
|
+
const r = await getJSON<{ cleared: string[] }>('/api/cookies/clear');
|
|
83
|
+
setCleared(r.cleared);
|
|
84
|
+
setInspect(null);
|
|
85
|
+
setSetResp(null);
|
|
86
|
+
readJs();
|
|
87
|
+
});
|
|
88
|
+
const doSeal = (): Promise<void> =>
|
|
89
|
+
guard(async () =>
|
|
90
|
+
setSeal(await getJSON<SealResp>('/api/cookies/seal?v=' + encodeURIComponent(sealInput))),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<main style={{ maxWidth: 760 }}>
|
|
95
|
+
<h1>Cookies</h1>
|
|
96
|
+
<p>
|
|
97
|
+
<code>Cookie</code>, <code>Cookies</code>, and <code>SecureCookies</code> are globals in
|
|
98
|
+
the server (no import), exactly like <code>crypto</code>: the full RFC 6265bis surface
|
|
99
|
+
plus HMAC signing and AES-256-GCM encryption, running in the server wasm. See{' '}
|
|
100
|
+
<code>server/core/AppHandler.ts</code>. Needs the server running (<code>toiljs dev</code>).
|
|
101
|
+
</p>
|
|
102
|
+
|
|
103
|
+
{err ? <p style={{ color: '#ff6b6b', ...mono }}>{err}</p> : null}
|
|
104
|
+
|
|
105
|
+
<h2>1. Everything you can do</h2>
|
|
106
|
+
<p>Every attribute and cookie type, with the exact `Set-Cookie` string it serializes to.</p>
|
|
107
|
+
<button onClick={showGallery}>Show the gallery</button>
|
|
108
|
+
{gallery ? (
|
|
109
|
+
<div style={card}>
|
|
110
|
+
{Object.keys(gallery).map((k) => (
|
|
111
|
+
<div key={k}>
|
|
112
|
+
<div style={label}>{k}</div>
|
|
113
|
+
<div style={mono}>{gallery[k]}</div>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</div>
|
|
117
|
+
) : null}
|
|
118
|
+
|
|
119
|
+
<h2>2. Set cookies</h2>
|
|
120
|
+
<p>
|
|
121
|
+
Stores three real cookies: a plain <code>visits</code> counter, an HMAC-signed{' '}
|
|
122
|
+
<code>__Host-session</code>, and an AES-GCM-encrypted <code>secret</code>. The last two
|
|
123
|
+
are <code>HttpOnly</code>, so JavaScript cannot read them, only the server can.
|
|
124
|
+
</p>
|
|
125
|
+
<button onClick={doSet}>Set cookies</button>
|
|
126
|
+
{setResp ? (
|
|
127
|
+
<div style={card}>
|
|
128
|
+
<div>visit #{setResp.visits}</div>
|
|
129
|
+
{setResp.emitted.map((c, i) => (
|
|
130
|
+
<div key={i} style={mono}>
|
|
131
|
+
{c}
|
|
132
|
+
</div>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
) : null}
|
|
136
|
+
|
|
137
|
+
<h2>3. What JS sees vs what the server sees</h2>
|
|
138
|
+
<p>
|
|
139
|
+
<code>document.cookie</code> only exposes non-<code>HttpOnly</code> cookies, so the
|
|
140
|
+
signed session and encrypted secret are hidden from it. The server parses all of them
|
|
141
|
+
and verifies/decrypts the protected ones.
|
|
142
|
+
</p>
|
|
143
|
+
<button onClick={readJs}>Read document.cookie</button>{' '}
|
|
144
|
+
<button onClick={doInspect}>Ask the server (/inspect)</button>
|
|
145
|
+
<div style={card}>
|
|
146
|
+
<div style={label}>document.cookie (browser / JS)</div>
|
|
147
|
+
<div style={mono}>{jsCookies}</div>
|
|
148
|
+
</div>
|
|
149
|
+
{inspect ? (
|
|
150
|
+
<div style={card}>
|
|
151
|
+
<div style={label}>server view (/api/cookies/inspect)</div>
|
|
152
|
+
<div style={mono}>raw: {inspect.raw || '(none)'}</div>
|
|
153
|
+
<div style={mono}>parsed: {JSON.stringify(inspect.cookies)}</div>
|
|
154
|
+
<div style={mono}>session (HMAC-verified): {inspect.session ?? 'null (missing or tampered)'}</div>
|
|
155
|
+
<div style={mono}>secret (AES-GCM-decrypted): {inspect.secret ?? 'null (missing or tampered)'}</div>
|
|
156
|
+
</div>
|
|
157
|
+
) : null}
|
|
158
|
+
|
|
159
|
+
<h2>4. Clear</h2>
|
|
160
|
+
<button onClick={doClear}>Clear the demo cookies</button>
|
|
161
|
+
{cleared ? (
|
|
162
|
+
<div style={card}>
|
|
163
|
+
{cleared.map((c, i) => (
|
|
164
|
+
<div key={i} style={mono}>
|
|
165
|
+
{c}
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
) : null}
|
|
170
|
+
|
|
171
|
+
<h2>5. Sign & encrypt a value</h2>
|
|
172
|
+
<p>
|
|
173
|
+
<code>SecureCookies.signed(key)</code> (HMAC-SHA256, readable but tamper-proof) and{' '}
|
|
174
|
+
<code>SecureCookies.encrypted(key)</code> (AES-256-GCM, confidential). Both bind the
|
|
175
|
+
value to the cookie name, and a tampered signature fails to verify.
|
|
176
|
+
</p>
|
|
177
|
+
<input
|
|
178
|
+
value={sealInput}
|
|
179
|
+
onChange={(e) => setSealInput(e.target.value)}
|
|
180
|
+
style={{ padding: 6, marginRight: 8, minWidth: 220 }}
|
|
181
|
+
/>
|
|
182
|
+
<button onClick={doSeal}>Seal it</button>
|
|
183
|
+
{seal ? (
|
|
184
|
+
<div style={card}>
|
|
185
|
+
<div style={mono}>value: {seal.value}</div>
|
|
186
|
+
<div style={mono}>signed: {seal.signed}</div>
|
|
187
|
+
<div style={mono}>unsigned: {seal.unsigned ?? 'null'}</div>
|
|
188
|
+
<div style={mono}>encrypted: {seal.encrypted}</div>
|
|
189
|
+
<div style={mono}>decrypted: {seal.decrypted ?? 'null'}</div>
|
|
190
|
+
<div style={mono}>tampered signature verifies? {String(seal.tamperVerifies)}</div>
|
|
191
|
+
</div>
|
|
192
|
+
) : null}
|
|
193
|
+
|
|
194
|
+
<p style={{ marginTop: 24 }}>
|
|
195
|
+
<Toil.Link href="/features">Back to features</Toil.Link>
|
|
196
|
+
</p>
|
|
197
|
+
</main>
|
|
198
|
+
);
|
|
199
|
+
}
|