toiljs 0.0.34 → 0.0.36

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -0
  3. package/as-pect.config.js +8 -2
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +97 -0
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.d.ts +42 -0
  8. package/build/client/auth.js +179 -0
  9. package/build/client/index.d.ts +5 -1
  10. package/build/client/index.js +3 -1
  11. package/build/client/routing/loader.d.ts +1 -0
  12. package/build/client/routing/loader.js +37 -0
  13. package/build/client/routing/mount.js +32 -1
  14. package/build/client/ssr/markers.d.ts +34 -0
  15. package/build/client/ssr/markers.js +49 -0
  16. package/build/compiler/.tsbuildinfo +1 -1
  17. package/build/compiler/docs.js +88 -1
  18. package/build/compiler/generate.d.ts +2 -0
  19. package/build/compiler/generate.js +2 -2
  20. package/build/compiler/index.js +2 -0
  21. package/build/compiler/ssr-codegen.d.ts +2 -0
  22. package/build/compiler/ssr-codegen.js +36 -0
  23. package/build/compiler/template-build.d.ts +29 -0
  24. package/build/compiler/template-build.js +150 -0
  25. package/build/compiler/template.d.ts +22 -0
  26. package/build/compiler/template.js +169 -0
  27. package/build/devserver/.tsbuildinfo +1 -1
  28. package/build/devserver/crypto.js +15 -0
  29. package/build/devserver/host.js +1 -0
  30. package/build/devserver/module.d.ts +1 -0
  31. package/build/devserver/module.js +23 -1
  32. package/docs/README.md +56 -0
  33. package/docs/auth.md +261 -0
  34. package/docs/caching.md +115 -0
  35. package/docs/cookies.md +457 -0
  36. package/docs/crypto.md +130 -0
  37. package/docs/data.md +131 -0
  38. package/docs/getting-started.md +128 -0
  39. package/docs/routing.md +259 -0
  40. package/docs/rpc.md +149 -0
  41. package/docs/ssr.md +184 -0
  42. package/docs/time.md +43 -0
  43. package/examples/basic/client/routes/auth.tsx +198 -0
  44. package/examples/basic/client/routes/cookies.tsx +199 -0
  45. package/examples/basic/client/routes/features/index.tsx +34 -10
  46. package/examples/basic/client/routes/hello.tsx +43 -0
  47. package/examples/basic/client/routes/pq.tsx +135 -0
  48. package/examples/basic/server/AuthTestHandler.ts +15 -0
  49. package/examples/basic/server/AuthVerifyHandler.ts +23 -0
  50. package/examples/basic/server/CacheHandler.ts +25 -0
  51. package/examples/basic/server/DecoCache.ts +18 -0
  52. package/examples/basic/server/FastTrapHandler.ts +8 -0
  53. package/examples/basic/server/SpinHandler.ts +18 -0
  54. package/examples/basic/server/SsrGreetingRender.ts +27 -0
  55. package/examples/basic/server/authexample-main.ts +8 -0
  56. package/examples/basic/server/authtest-main.ts +8 -0
  57. package/examples/basic/server/authverify-main.ts +8 -0
  58. package/examples/basic/server/cache-main.ts +8 -0
  59. package/examples/basic/server/core/AppHandler.ts +243 -0
  60. package/examples/basic/server/deco-main.ts +18 -0
  61. package/examples/basic/server/main.ts +2 -0
  62. package/examples/basic/server/routes/Auth.ts +184 -0
  63. package/examples/basic/server/routes/PqDemo.ts +109 -0
  64. package/examples/basic/server/routes/Session.ts +73 -0
  65. package/examples/basic/server/spin-main.ts +13 -0
  66. package/examples/basic/server/ssr/greeting.slots.ts +19 -0
  67. package/examples/basic/server/ssr-main.ts +18 -0
  68. package/examples/basic/server/toil-server-env.d.ts +94 -0
  69. package/examples/basic/server/trap-main.ts +8 -0
  70. package/package.json +5 -3
  71. package/server/globals/auth.ts +281 -0
  72. package/server/runtime/README.md +61 -0
  73. package/server/runtime/env/Server.ts +12 -0
  74. package/server/runtime/exports/index.ts +17 -0
  75. package/server/runtime/exports/render.ts +51 -0
  76. package/server/runtime/http/base64.ts +104 -0
  77. package/server/runtime/http/cookie.ts +416 -0
  78. package/server/runtime/http/cookies.ts +197 -0
  79. package/server/runtime/http/date.ts +72 -0
  80. package/server/runtime/http/percent.ts +76 -0
  81. package/server/runtime/http/securecookies.ts +224 -0
  82. package/server/runtime/index.ts +17 -0
  83. package/server/runtime/request.ts +24 -0
  84. package/server/runtime/response.ts +29 -0
  85. package/server/runtime/ssr/Ssr.ts +43 -0
  86. package/server/runtime/ssr/encode.ts +110 -0
  87. package/server/runtime/ssr/escape.ts +83 -0
  88. package/server/runtime/ssr/slots.ts +144 -0
  89. package/server/runtime/time.ts +29 -0
  90. package/src/cli/create.ts +105 -0
  91. package/src/client/auth.ts +322 -0
  92. package/src/client/index.ts +5 -1
  93. package/src/client/routing/loader.ts +56 -0
  94. package/src/client/routing/mount.tsx +37 -1
  95. package/src/client/ssr/markers.tsx +140 -0
  96. package/src/compiler/docs.ts +88 -1
  97. package/src/compiler/generate.ts +2 -2
  98. package/src/compiler/index.ts +5 -0
  99. package/src/compiler/ssr-codegen.ts +85 -0
  100. package/src/compiler/template-build.ts +275 -0
  101. package/src/compiler/template.ts +265 -0
  102. package/src/devserver/crypto.ts +23 -0
  103. package/src/devserver/host.ts +4 -0
  104. package/src/devserver/module.ts +39 -1
  105. package/test/assembly/cookie.spec.ts +302 -0
  106. package/test/assembly/example.spec.ts +5 -1
  107. package/test/assembly/ssr.spec.ts +94 -0
  108. package/test/devserver.test.ts +42 -0
  109. package/test/ssr-render.test.ts +128 -0
  110. 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
+ " → &quot; & → &amp; ' → &#x27;
140
+ < → &lt; > → &gt;
141
+ ```
142
+
143
+ (`'` becomes `&#x27;` — React's exact choice — not `&#39;`.) 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 &amp; 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
+ }