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
|
@@ -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.
|
|
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:
|
|
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.
|
|
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
|
+
}
|
package/server/runtime/README.md
CHANGED
|
@@ -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
|
+
}
|