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.
Files changed (110) hide show
  1. package/CHANGELOG.md +15 -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 +182 -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 +260 -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 +130 -0
  64. package/examples/basic/server/routes/Session.ts +74 -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 +327 -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
@@ -0,0 +1,13 @@
1
+ import { Server } from 'toiljs/server/runtime';
2
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
3
+ import { SpinHandler } from './SpinHandler';
4
+
5
+ Server.handler = () => {
6
+ return new SpinHandler();
7
+ };
8
+
9
+ export * from 'toiljs/server/runtime/exports';
10
+
11
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
12
+ revertOnError(message, fileName, line, column);
13
+ }
@@ -0,0 +1,19 @@
1
+ // AUTO-GENERATED by toil (edge SSR). Do not edit.
2
+ // Route: greeting. Slot ids match the deployed .slots manifest; HASH is the
3
+ // coherence hash the host checks against the template (deploy-skew guard).
4
+ //
5
+ // (For this example the values are hand-fixed; in a real build `template.ts`
6
+ // computes them from the route's rendered template.)
7
+
8
+ /** Stable hole ids for this route's template. */
9
+ export enum Slot {
10
+ greeting = 0,
11
+ count = 1,
12
+ }
13
+
14
+ /** Coherence hash (32 bytes) baked into the guest and echoed in every values
15
+ * envelope; the host rejects a response whose hash != the deployed template. */
16
+ export const HASH: StaticArray<u8> = [
17
+ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
18
+ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
19
+ ];
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Edge-SSR example entry. Registers the `/hello` render (side-effect import),
3
+ * sets a no-op HTTP handler (so the `handle` export is well-formed), and
4
+ * surfaces the wasm exports — including `render(i32, i32) -> i64`.
5
+ */
6
+ import { Server, ToilHandler } from 'toiljs/server/runtime';
7
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
8
+ import './SsrGreetingRender';
9
+
10
+ Server.handler = () => {
11
+ return new ToilHandler();
12
+ };
13
+
14
+ export * from 'toiljs/server/runtime/exports';
15
+
16
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
17
+ revertOnError(message, fileName, line, column);
18
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Editor-only ambient declarations for the toiljs cookie globals.
3
+ *
4
+ * `Cookie`, `Cookies`, `SecureCookies`, and the `SameSite` / `CookieEncoding` /
5
+ * `CookiePrefix` enums are `@global` in the toiljs server runtime, so a handler
6
+ * uses them with no import (exactly like `crypto`). The toilscript compiler
7
+ * registers them from the runtime; this file just gives the editor their shapes
8
+ * so it does not flag the unimported names. It is auto-included by the server
9
+ * `tsconfig.json` (`include: ["./**/*.ts"]`) and ignored by the compiler.
10
+ *
11
+ * `toiljs create` scaffolds this file; keep it in sync with
12
+ * `toiljs/server/runtime/http/*`.
13
+ */
14
+
15
+ declare enum SameSite {
16
+ Default = 0,
17
+ None = 1,
18
+ Lax = 2,
19
+ Strict = 3,
20
+ }
21
+
22
+ declare enum CookieEncoding {
23
+ Percent = 0,
24
+ Raw = 1,
25
+ Base64Url = 2,
26
+ }
27
+
28
+ declare enum CookiePrefix {
29
+ None = 0,
30
+ Secure = 1,
31
+ Host = 2,
32
+ }
33
+
34
+ declare class CookieValidation {
35
+ valid: bool;
36
+ errors: Array<string>;
37
+ fail(msg: string): void;
38
+ }
39
+
40
+ declare class Cookie {
41
+ name: string;
42
+ value: string;
43
+ encoding: CookieEncoding;
44
+ constructor(name: string, value: string);
45
+ static create(name: string, value: string): Cookie;
46
+ domain(v: string): Cookie;
47
+ path(v: string): Cookie;
48
+ maxAge(seconds: i64): Cookie;
49
+ expires(epochSeconds: i64): Cookie;
50
+ expiresRaw(date: string): Cookie;
51
+ secure(on?: bool): Cookie;
52
+ httpOnly(on?: bool): Cookie;
53
+ sameSite(s: SameSite): Cookie;
54
+ partitioned(on?: bool): Cookie;
55
+ priority(p: string): Cookie;
56
+ extension(av: string): Cookie;
57
+ withEncoding(e: CookieEncoding): Cookie;
58
+ asSecurePrefixed(): Cookie;
59
+ asHostPrefixed(): Cookie;
60
+ detectedPrefix(): CookiePrefix;
61
+ encodedValue(): string;
62
+ validate(): CookieValidation;
63
+ serialize(strict?: bool): string;
64
+ toString(): string;
65
+ }
66
+
67
+ declare class CookieMap {
68
+ set(name: string, value: string): void;
69
+ get(name: string): string | null;
70
+ has(name: string): bool;
71
+ names(): Array<string>;
72
+ readonly size: i32;
73
+ }
74
+
75
+ declare class Cookies {
76
+ static parse(cookieHeader: string): CookieMap;
77
+ static get(cookieHeader: string, name: string): string | null;
78
+ static serialize(name: string, value: string): string;
79
+ static parseSetCookie(setCookie: string): Cookie;
80
+ static encodeValue(raw: string): string;
81
+ static decodeValue(enc: string): string;
82
+ }
83
+
84
+ declare class SecureCookies {
85
+ static signed(key: Uint8Array): SecureCookies;
86
+ static encrypted(key: Uint8Array): SecureCookies;
87
+ addKey(key: Uint8Array): SecureCookies;
88
+ sign(name: string, value: string): string;
89
+ unsign(name: string, sealed: string): string | null;
90
+ encrypt(name: string, value: string): string;
91
+ decrypt(name: string, sealed: string): string | null;
92
+ seal(cookie: Cookie): Cookie;
93
+ open(jar: CookieMap, name: string): string | null;
94
+ }
@@ -0,0 +1,8 @@
1
+ import { Server } from 'toiljs/server/runtime';
2
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
3
+ import { FastTrapHandler } from './FastTrapHandler';
4
+ Server.handler = () => { return new FastTrapHandler(); };
5
+ export * from 'toiljs/server/runtime/exports';
6
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
7
+ revertOnError(message, fileName, line, column);
8
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.34",
4
+ "version": "0.0.37",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -95,7 +95,7 @@
95
95
  },
96
96
  "scripts": {
97
97
  "watch": "tsc -p tsconfig.json --watch",
98
- "build": "npm run build:shared && npm run build:logger && npm run build:client && npm run build:io && npm run build:backend && npm run build:devserver && npm run build:compiler && npm run build:cli",
98
+ "build": "npm run build:shared && npm run build:logger && npm run build:io && npm run build:client && npm run build:backend && npm run build:devserver && npm run build:compiler && npm run build:cli",
99
99
  "build:shared": "tsc -p tsconfig.shared.json",
100
100
  "build:logger": "tsc -p tsconfig.logger.json",
101
101
  "build:client": "tsc -p tsconfig.client.json",
@@ -116,6 +116,7 @@
116
116
  "setup": "npm i && npm run build"
117
117
  },
118
118
  "dependencies": {
119
+ "@btc-vision/post-quantum": "^0.5.3",
119
120
  "@dacely/hyper-express": "6.17.4",
120
121
  "@dacely/toilscript-loader": "^0.1.0",
121
122
  "@eslint-react/eslint-plugin": "^5.8.8",
@@ -124,9 +125,10 @@
124
125
  "@vitejs/plugin-react": "^6.0.2",
125
126
  "eslint-plugin-react-hooks": "^7.1.1",
126
127
  "eslint-plugin-react-refresh": "^0.5.2",
128
+ "hash-wasm": "^4.12.0",
127
129
  "picocolors": "^1.1.1",
128
130
  "sharp": "^0.35.0",
129
- "toilscript": "^0.1.21",
131
+ "toilscript": "^0.1.22",
130
132
  "typescript-eslint": "^8.60.0",
131
133
  "vite": "^8.0.14",
132
134
  "vite-imagetools": "^10.0.0",
@@ -0,0 +1,281 @@
1
+ // AuthService: the server half of the post-quantum auth primitive, available
2
+ // as a no-import global (registered via the toilscript `--lib` mechanism, the
3
+ // same way `crypto` is a global). The client derives an ML-DSA-44 keypair from
4
+ // the password (Argon2id), keeps the public key on the account, and signs a
5
+ // login challenge; the server rebuilds the exact signed message from its OWN
6
+ // stored values and verifies the signature here.
7
+ //
8
+ // Crypto is verify-only on the server: the host never holds a secret. Backed by
9
+ // the `crypto.mldsa_verify` host import (toil-backend `mldsa_verify_import.rs`,
10
+ // and the toiljs dev-server mock).
11
+
12
+ import { DataWriter, DataReader } from 'data';
13
+
14
+ import {
15
+ Server,
16
+ SecureCookies,
17
+ Cookie,
18
+ SameSite,
19
+ Time,
20
+ base64UrlEncode,
21
+ base64UrlDecode,
22
+ } from 'toiljs/server/runtime';
23
+
24
+ // Host import: ML-DSA-44 (FIPS 204) verify. Returns 1 (valid), 0 (invalid), or
25
+ // a negative error code. The keypair is client-derived; only public material
26
+ // crosses this boundary.
27
+ // @ts-ignore: decorator
28
+ @external('env', 'crypto.mldsa_verify')
29
+ declare function __toilMldsaVerify(
30
+ pkPtr: usize,
31
+ pkLen: i32,
32
+ msgPtr: usize,
33
+ msgLen: i32,
34
+ sigPtr: usize,
35
+ sigLen: i32,
36
+ ctxPtr: usize,
37
+ ctxLen: i32,
38
+ ): i32;
39
+
40
+ // HMAC key for signing session cookies. The SAME secret must be configured on
41
+ // every edge instance (a sealed cookie minted by one is opened by another) and
42
+ // must NEVER reach the client. There is no host-config secret mechanism yet, so
43
+ // the tenant supplies one at startup via `AuthService.setSecret(...)` (a
44
+ // build-time constant is consistent across instances). The default below is a
45
+ // well-known DEV placeholder: a deployment that does not call `setSecret` gets a
46
+ // loud, insecure-but-functional session so local dev works out of the box.
47
+ // TODO(secret): replace with a per-deployment host-config secret.
48
+ let __sessionSecret: Uint8Array = Uint8Array.wrap(
49
+ String.UTF8.encode('toil-dev-insecure-session-secret-CHANGE-ME'),
50
+ );
51
+
52
+ // Whether the current request arrived over HTTPS. A TLS edge / proxy signals it
53
+ // with `x-forwarded-proto: https`; absent (plain HTTP, including `toiljs dev`)
54
+ // the session uses plain cookies so they actually round-trip in the browser.
55
+ // Over HTTPS the cookies keep their hardened `__Host-`/`__Secure-` prefixes and
56
+ // the `Secure` flag. The signature + expiry checks are identical either way.
57
+ function __reqIsSecure(): bool {
58
+ const req = Server.currentRequest;
59
+ if (req == null) return false;
60
+ const proto = req.header('x-forwarded-proto');
61
+ return proto != null && proto == 'https';
62
+ }
63
+
64
+ export namespace AuthService {
65
+ /** Signed session cookie name (the HTTPS form). `__Host-` pairs with
66
+ * `asHostPrefixed()` (Secure, Path=/, no Domain) for the strongest browser
67
+ * scoping; over plain HTTP the unprefixed `toil_sess` is used instead. */
68
+ export const SESSION_COOKIE: string = '__Host-toil_sess';
69
+
70
+ /** Base (unprefixed) cookie names; the `__Host-`/`__Secure-` prefixes are
71
+ * added only when the request is secure (see `__reqIsSecure`). */
72
+ const SESSION_BASE: string = 'toil_sess';
73
+ const USER_BASE: string = 'toil_user';
74
+
75
+ /** The session / companion cookie name actually used for `secure`. */
76
+ function sessionCookieName(secure: bool): string {
77
+ return secure ? '__Host-' + SESSION_BASE : SESSION_BASE;
78
+ }
79
+ function userCookieName(secure: bool): string {
80
+ return secure ? '__Secure-' + USER_BASE : USER_BASE;
81
+ }
82
+
83
+ /** Session payload format version (first byte of the sealed payload). */
84
+ const SESSION_VERSION: u8 = 1;
85
+
86
+ /** Default session lifetime if `mintSession` is called without a ttl. */
87
+ export const DEFAULT_SESSION_TTL_SECS: u64 = 86400; // 24h
88
+
89
+ /**
90
+ * Configure the server secret used to sign session cookies. Call once at
91
+ * startup from the tenant's `main.ts`. Must be identical on every edge
92
+ * instance and kept out of any client bundle.
93
+ */
94
+ export function setSecret(secret: Uint8Array): void {
95
+ __sessionSecret = secret;
96
+ }
97
+
98
+ /**
99
+ * The verified session payload (the `@user` codec bytes) for the current
100
+ * request, or `null` if there is no session, the signature does not verify,
101
+ * or it has expired. Reads the ambient request's cookies (no argument), so
102
+ * it is only meaningful during a dispatch.
103
+ */
104
+ export function getSessionBytes(): Uint8Array | null {
105
+ const req = Server.currentRequest;
106
+ if (req == null) return null;
107
+
108
+ const sealed = SecureCookies.signed(__sessionSecret).open(
109
+ req.cookies(),
110
+ sessionCookieName(__reqIsSecure()),
111
+ );
112
+ if (sealed == null) return null;
113
+
114
+ const payload = base64UrlDecode(sealed);
115
+ if (payload == null) return null;
116
+
117
+ const r = new DataReader(payload);
118
+ if (r.readU8() != SESSION_VERSION) return null; // version
119
+ r.readU64(); // iat (unused on read)
120
+ const exp = r.readU64();
121
+ const userBytes = r.readBytes();
122
+ if (!r.ok) return null; // truncated/malformed
123
+
124
+ if (Time.nowSeconds() >= exp) return null; // expired
125
+
126
+ return userBytes;
127
+ }
128
+
129
+ /** Whether the current request carries a valid, unexpired session. The
130
+ * toilscript `@auth` guard calls this before running the route. */
131
+ export function hasSession(): bool {
132
+ return getSessionBytes() != null;
133
+ }
134
+
135
+ /**
136
+ * The authenticated user for the current request, decoded from the verified
137
+ * session, or `null`. Auto-typed to the tenant's `@user` class with NO type
138
+ * argument: the toilscript `@user` transform injects a `@global` subclass
139
+ * `AuthUser extends <YourUser>` and a `__toilDecodeAuthUser` decoder, so this
140
+ * returns the user's own fields. Tenants without a `@user` class never call
141
+ * this, so AssemblyScript skips compiling it (the injected globals are
142
+ * absent there, which is fine).
143
+ */
144
+ // @ts-ignore: AuthUser / __toilDecodeAuthUser are injected by the @user transform
145
+ export function getUser(): AuthUser | null {
146
+ const bytes = getSessionBytes();
147
+ // @ts-ignore: __toilDecodeAuthUser is injected by the @user transform
148
+ return bytes == null ? null : __toilDecodeAuthUser(bytes);
149
+ }
150
+
151
+ /**
152
+ * Mint a signed session cookie carrying `userData` (the `@user` codec bytes,
153
+ * i.e. `myUser.encode()`), valid for `ttlSecs`. Set it on the response with
154
+ * `Response.setCookie(...)`. HMAC-signed, HttpOnly, Secure, SameSite=Lax,
155
+ * `__Host-` scoped. The value stays readable but cannot be forged or moved.
156
+ */
157
+ export function mintSession(userData: Uint8Array, ttlSecs: u64 = DEFAULT_SESSION_TTL_SECS): Cookie {
158
+ const now = Time.nowSeconds();
159
+ const w = new DataWriter();
160
+ w.writeU8(SESSION_VERSION);
161
+ w.writeU64(now);
162
+ w.writeU64(now + ttlSecs);
163
+ w.writeBytes(userData);
164
+
165
+ const secure = __reqIsSecure();
166
+ let cookie = Cookie.create(SESSION_BASE, base64UrlEncode(w.toBytes()))
167
+ .httpOnly()
168
+ .sameSite(SameSite.Lax)
169
+ .maxAge(<i64>ttlSecs);
170
+ cookie = secure ? cookie.asHostPrefixed() : cookie.path('/');
171
+ return SecureCookies.signed(__sessionSecret).seal(cookie);
172
+ }
173
+
174
+ /** A `Set-Cookie` that immediately clears the session (logout). */
175
+ export function clearSession(): Cookie {
176
+ const secure = __reqIsSecure();
177
+ let cookie = Cookie.create(SESSION_BASE, '')
178
+ .httpOnly()
179
+ .sameSite(SameSite.Lax)
180
+ .maxAge(0);
181
+ cookie = secure ? cookie.asHostPrefixed() : cookie.path('/');
182
+ return cookie;
183
+ }
184
+
185
+ /** Readable companion cookie name: a NON-HttpOnly copy of the user data for
186
+ * the client's `getUser()` to display. UNTRUSTED: the server always
187
+ * re-verifies the signed session and never reads this; treat it as
188
+ * display-only (a client can forge it, but only fools its own UI). */
189
+ export const USER_COOKIE: string = '__Secure-toil_user';
190
+
191
+ /**
192
+ * A readable companion cookie carrying `userData` (the `@user` codec bytes,
193
+ * base64url) for the client. Secure + SameSite=Lax but NOT HttpOnly, so the
194
+ * browser exposes it to `document.cookie`. Set it alongside
195
+ * {@link mintSession}; the server NEVER trusts it.
196
+ */
197
+ export function userCookie(userData: Uint8Array, ttlSecs: u64 = DEFAULT_SESSION_TTL_SECS): Cookie {
198
+ const secure = __reqIsSecure();
199
+ let cookie = Cookie.create(USER_BASE, base64UrlEncode(userData))
200
+ .sameSite(SameSite.Lax)
201
+ .maxAge(<i64>ttlSecs);
202
+ cookie = secure ? cookie.asSecurePrefixed() : cookie.path('/');
203
+ return cookie;
204
+ }
205
+
206
+ /** A `Set-Cookie` that clears the readable companion cookie (logout). */
207
+ export function clearUserCookie(): Cookie {
208
+ const secure = __reqIsSecure();
209
+ let cookie = Cookie.create(USER_BASE, '')
210
+ .sameSite(SameSite.Lax)
211
+ .maxAge(0);
212
+ cookie = secure ? cookie.asSecurePrefixed() : cookie.path('/');
213
+ return cookie;
214
+ }
215
+
216
+ /** FIPS 204 signing context (domain separator) for login. Byte-identical
217
+ * on the client signer and this verifier; binds a signature to "login" so
218
+ * it can never validate against another operation reusing the keypair. */
219
+ export const LOGIN_CONTEXT: string = 'qauth:login:v1';
220
+
221
+ /** ML-DSA-44 (FIPS 204, security level 2) fixed sizes. */
222
+ export const PUBLIC_KEY_LEN: i32 = 1312;
223
+ export const SIGNATURE_LEN: i32 = 2420;
224
+
225
+ /**
226
+ * Build the canonical login message `M` the client signs and the server
227
+ * verifies, with a FIXED binary layout (no JSON). The server MUST call this
228
+ * with its OWN stored values, never with fields echoed by the client. Both
229
+ * ends use this exact field order via the byte-identical `DataWriter`:
230
+ *
231
+ * u8 version = 1
232
+ * str sub (username; u32-LE len + UTF-8)
233
+ * str aud (this service's audience; server-config constant)
234
+ * bytes cid (challenge id; u32-LE len + raw)
235
+ * bytes nonce (32 random bytes; u32-LE len + raw)
236
+ * u64 iat (issued-at, seconds, LE)
237
+ * u64 exp (expiry, seconds, LE)
238
+ */
239
+ export function buildLoginMessage(
240
+ sub: string,
241
+ aud: string,
242
+ cid: Uint8Array,
243
+ nonce: Uint8Array,
244
+ iat: u64,
245
+ exp: u64,
246
+ ): Uint8Array {
247
+ const w = new DataWriter();
248
+ w.writeU8(1);
249
+ w.writeString(sub);
250
+ w.writeString(aud);
251
+ w.writeBytes(cid);
252
+ w.writeBytes(nonce);
253
+ w.writeU64(iat);
254
+ w.writeU64(exp);
255
+ return w.toBytes();
256
+ }
257
+
258
+ /**
259
+ * Verify a login signature over `message` against the account's stored
260
+ * `publicKey`, under {@link LOGIN_CONTEXT}. Fail-closed on any size
261
+ * mismatch. `message` should be the output of {@link buildLoginMessage}
262
+ * rebuilt from server-held values.
263
+ */
264
+ export function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool {
265
+ if (publicKey.length != PUBLIC_KEY_LEN || signature.length != SIGNATURE_LEN) {
266
+ return false;
267
+ }
268
+ const ctx = Uint8Array.wrap(String.UTF8.encode(LOGIN_CONTEXT));
269
+ const result = __toilMldsaVerify(
270
+ publicKey.dataStart,
271
+ publicKey.length,
272
+ message.dataStart,
273
+ message.length,
274
+ signature.dataStart,
275
+ signature.length,
276
+ ctx.dataStart,
277
+ ctx.length,
278
+ );
279
+ return result == 1;
280
+ }
281
+ }
@@ -17,6 +17,67 @@ request. This runtime gives you:
17
17
  request, runs your handler, encodes the response, and returns the
18
18
  packed i64 the host expects.
19
19
 
20
+ ## Cookies
21
+
22
+ A complete HTTP cookie layer (RFC 6265bis, including `SameSite`, `Partitioned`/CHIPS,
23
+ and the `__Host-` / `__Secure-` prefixes). `Cookie`, `Cookies`, and `SecureCookies`
24
+ are ambient globals, usable in a handler with **no import**, exactly like `crypto`.
25
+
26
+ Read:
27
+
28
+ ```ts
29
+ const sid = req.cookie('sid'); // string | null
30
+ const jar = req.cookies(); // CookieMap: get / has / names / size
31
+ ```
32
+
33
+ Write (the builder hangs off `Response`; every attribute is a chained setter):
34
+
35
+ ```ts
36
+ return Response.json('{"ok":true}').setCookie(
37
+ Cookie.create('sid', token)
38
+ .httpOnly()
39
+ .secure()
40
+ .sameSite(SameSite.Lax)
41
+ .maxAge(3600)
42
+ .asHostPrefixed(), // forces Secure, Path=/, no Domain
43
+ );
44
+
45
+ resp.clearCookie('sid'); // expires it (Max-Age=0 + epoch Expires)
46
+ ```
47
+
48
+ Each `setCookie` emits its own `Set-Cookie` header (cookies are never folded). The
49
+ builder covers `domain`, `path`, `maxAge`, `expires` (epoch seconds, rendered as an
50
+ IMF-fixdate) / `expiresRaw`, `secure`, `httpOnly`, `sameSite`, `partitioned`,
51
+ `priority`, and arbitrary `extension(...)`. `SameSite=None` and `Partitioned` imply
52
+ `Secure`, and `Max-Age` is clamped to the 400-day cap. `cookie.validate()` returns a
53
+ structured result; `cookie.serialize(true)` throws on a hard violation. Values are
54
+ percent-encoded by default (arbitrary UTF-8 is safe), switchable with
55
+ `.withEncoding(CookieEncoding.Raw)` or `CookieEncoding.Base64Url`.
56
+
57
+ Parse and serialize the request side:
58
+
59
+ ```ts
60
+ const jar = Cookies.parse('a=1; b=2'); // CookieMap
61
+ const one = Cookies.get(header, 'a'); // string | null
62
+ const line = Cookies.serialize('a', 'b'); // 'a=b'
63
+ const cookie = Cookies.parseSetCookie(setCookieLine); // Set-Cookie -> Cookie
64
+ ```
65
+
66
+ Signed and encrypted values with `SecureCookies`, built on the `crypto` global. Keys
67
+ are caller-supplied raw bytes (HMAC: any length, AES: 16 or 32 bytes); add more for
68
+ rotation. Verification and decryption never throw on bad input, a tampered or
69
+ truncated value returns `null`:
70
+
71
+ ```ts
72
+ const signer = SecureCookies.signed(key); // HMAC-SHA256: readable, bound to the cookie name
73
+ const sealed = signer.sign('session', userId);
74
+ const id = signer.unsign('session', sealed); // string | null
75
+
76
+ const box = SecureCookies.encrypted(key); // AES-256-GCM: confidential + authenticated
77
+ resp.setCookie(box.seal(Cookie.create('session', userId).httpOnly()));
78
+ const open = box.open(req.cookies(), 'session'); // string | null
79
+ ```
80
+
20
81
  ## Wire contract
21
82
 
22
83
  Source of truth: `toil-backend/src/http/envelope.rs`.
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { Potential } from '../lang/Potential';
11
11
  import { ToilHandler } from '../handlers/ToilHandler';
12
+ import { Request } from '../request';
12
13
 
13
14
  @final
14
15
  export class ServerEnvironment {
@@ -28,6 +29,16 @@ export class ServerEnvironment {
28
29
  */
29
30
  public _current: Potential<ToilHandler> = null;
30
31
 
32
+ /**
33
+ * The request being dispatched right now. Set by `runtime/exports::handle`
34
+ * immediately after decode and cleared in {@link resetCurrentHandler}, so an
35
+ * ambient accessor like `AuthService.getUser()` can read the current
36
+ * request's cookies with no argument. Strictly single-request lifetime (the
37
+ * wasm processes one request per `handle` and memory resets between them);
38
+ * never cache it across requests.
39
+ */
40
+ public currentRequest: Request | null = null;
41
+
31
42
  /**
32
43
  * Build (or reuse) the handler for this request. Called once per
33
44
  * dispatch from `runtime/exports::handle`.
@@ -45,6 +56,7 @@ export class ServerEnvironment {
45
56
  */
46
57
  public resetCurrentHandler(): void {
47
58
  this._current = null;
59
+ this.currentRequest = null;
48
60
  }
49
61
  }
50
62
 
@@ -17,6 +17,20 @@ import { Server } from '../env/Server';
17
17
  import { decodeRequest, encodeResponse } from '../envelope';
18
18
  import { Response } from '../response';
19
19
 
20
+ // Ensure the cookie library is in every build so its `@global` types
21
+ // (`Cookie`, `Cookies`, `SecureCookies`, ...) register as ambient globals,
22
+ // usable in a handler with no import, even for a `main.ts` that imports only
23
+ // `exports`.
24
+ import '../http/cookie';
25
+ import '../http/cookies';
26
+ import '../http/securecookies';
27
+
28
+ // Surface the edge-SSR `render(i32, i32) -> i64` export. Optional at the host:
29
+ // a build with no SSR routes still exports `render`, but its `Ssr` registry is
30
+ // empty so every call returns the fail-safe empty result. The compiler injects
31
+ // the route-render registrations (and their imports) into the user's main.ts.
32
+ export { render } from './render';
33
+
20
34
  @main
21
35
  export function handle(req_ofs: i32, req_len: i32): i64 {
22
36
  let resp: Response;
@@ -39,6 +53,9 @@ export function handle(req_ofs: i32, req_len: i32): i64 {
39
53
  // garbage return value.
40
54
  resp = Response.badRequest('malformed request envelope');
41
55
  } else {
56
+ // Publish the request ambiently so AuthService.getUser()/hasSession()
57
+ // can read its cookies with no argument. Cleared in resetCurrentHandler.
58
+ Server.currentRequest = req;
42
59
  const handler = Server.currentHandler();
43
60
  handler.onRequestStarted(req);
44
61
  resp = handler.handle(req);
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The `render(i32, i32) -> i64` wasm export: the edge-SSR entrypoint.
3
+ *
4
+ * Mirrors `handle` (see `./index.ts`) but returns a **values envelope** (the
5
+ * hole values) instead of a full HTTP response. The host has the precompiled
6
+ * template mmap'd and splices these values into it, so `render` does NO page
7
+ * rendering: it runs the matched route's generated stamping and serialises a
8
+ * compact list of `(slot_id, kind, bytes)`.
9
+ *
10
+ * The user's `main.ts` surfaces this by re-exporting `./runtime/exports`. A
11
+ * module with no SSR routes simply registers nothing; the host treats a
12
+ * missing `render` export as "no template routes".
13
+ */
14
+
15
+ import { decodeRequest } from '../envelope';
16
+ import { Server } from '../env/Server';
17
+ import { encodeValues, valuesEncodedBound } from '../ssr/encode';
18
+ import { Ssr } from '../ssr/Ssr';
19
+ import { SlotValues, zeroHash } from '../ssr/slots';
20
+
21
+ export function render(req_ofs: i32, req_len: i32): i64 {
22
+ // TAIL DELIVERY: same contract as `handle` — a large request parked above
23
+ // the heap arrives with req_ofs != 0; advance past it before decoding so no
24
+ // allocation lands inside the still-being-read envelope.
25
+ if (req_ofs != 0) {
26
+ heap.alloc(<usize>req_ofs + <usize>req_len);
27
+ }
28
+
29
+ let values: SlotValues;
30
+ const req = decodeRequest(<usize>req_ofs, <usize>req_len);
31
+ if (req == null) {
32
+ // Malformed envelope: emit a fail-safe empty result (zero hash -> the
33
+ // host rejects it as a coherence mismatch -> 500), never a broken page.
34
+ values = new SlotValues(zeroHash()).setStatus(400);
35
+ } else {
36
+ Server.currentRequest = req;
37
+ const hit = Ssr.dispatch(req);
38
+ // No matching route render is a guest/host coherence problem; fail safe.
39
+ values = hit != null ? hit : new SlotValues(zeroHash()).setStatus(500);
40
+ }
41
+
42
+ // Lay out the values envelope immediately past the live heap, exactly like
43
+ // `handle`, so the host's contiguous-region reset stays tight.
44
+ const dst0 = <usize>heap.alloc(valuesEncodedBound(values) + 8);
45
+ const req_end = <usize>req_ofs + <usize>req_len;
46
+ const dst = dst0 < req_end ? req_end : dst0;
47
+
48
+ const total = encodeValues(values, dst);
49
+ Server.resetCurrentHandler();
50
+ return ((<i64>dst) << 32) | (<i64>total);
51
+ }