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
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `Cookies` — parsing and codec helpers, and `CookieMap`, the result of parsing
3
+ * a request `Cookie` header. The write side (building a `Set-Cookie`) lives on
4
+ * the {@link Cookie} builder; this is the read side plus a one-shot serializer.
5
+ *
6
+ * Both `Cookies` and `CookieMap` are ambient globals (`@global`, no import) and
7
+ * are also exported from `toiljs/server/runtime`.
8
+ */
9
+
10
+ import { Cookie, SameSite, CookieEncoding } from './cookie';
11
+ import { percentEncode, percentDecode } from './percent';
12
+
13
+ /** Parse a base-10 signed integer (leading `+`/`-`, then digits), lenient. */
14
+ function parseI64(s: string): i64 {
15
+ let i = 0;
16
+ let neg = false;
17
+ if (s.length > 0 && (s.charCodeAt(0) == 45 || s.charCodeAt(0) == 43)) {
18
+ neg = s.charCodeAt(0) == 45;
19
+ i = 1;
20
+ }
21
+ let r: i64 = 0;
22
+ for (; i < s.length; i++) {
23
+ const c = s.charCodeAt(i);
24
+ if (c < 48 || c > 57) break;
25
+ r = r * 10 + <i64>(c - 48);
26
+ }
27
+ return neg ? -r : r;
28
+ }
29
+
30
+ /** Strip one layer of surrounding DQUOTEs, as servers conventionally do on read. */
31
+ function unquote(s: string): string {
32
+ if (s.length >= 2 && s.charCodeAt(0) == 34 && s.charCodeAt(s.length - 1) == 34) {
33
+ return s.substring(1, s.length - 1);
34
+ }
35
+ return s;
36
+ }
37
+
38
+ /**
39
+ * An ordered, name→value view of the cookies on a request. Backed by parallel
40
+ * arrays (a request carries a handful of cookies; the linear scan beats hashing
41
+ * and keeps the codec-free runtime small, matching `RouteContext`). On a
42
+ * duplicate name the first occurrence wins (it is the most specific per
43
+ * RFC 6265bis ordering).
44
+ */
45
+ @global
46
+ export class CookieMap {
47
+ private keys: Array<string> = new Array<string>();
48
+ private vals: Array<string> = new Array<string>();
49
+
50
+ /** Insert unless `name` is already present (keep-first). */
51
+ set(name: string, value: string): void {
52
+ for (let i = 0; i < this.keys.length; i++) {
53
+ if (this.keys[i] == name) return;
54
+ }
55
+ this.keys.push(name);
56
+ this.vals.push(value);
57
+ }
58
+
59
+ /** The value for `name`, or `null` if absent. */
60
+ get(name: string): string | null {
61
+ for (let i = 0; i < this.keys.length; i++) {
62
+ if (this.keys[i] == name) return this.vals[i];
63
+ }
64
+ return null;
65
+ }
66
+
67
+ has(name: string): bool {
68
+ for (let i = 0; i < this.keys.length; i++) {
69
+ if (this.keys[i] == name) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** A copy of the cookie names, in encounter order. */
75
+ names(): Array<string> {
76
+ const out = new Array<string>();
77
+ for (let i = 0; i < this.keys.length; i++) out.push(this.keys[i]);
78
+ return out;
79
+ }
80
+
81
+ get size(): i32 {
82
+ return this.keys.length;
83
+ }
84
+ }
85
+
86
+ @global
87
+ export class Cookies {
88
+ /**
89
+ * Parse a request `Cookie` header (`a=1; b=2`) into a {@link CookieMap}.
90
+ * Values are percent-decoded (the inverse of the default `Cookie` encoding)
91
+ * and one layer of surrounding quotes is stripped. Malformed pairs and
92
+ * empty names are skipped, never thrown.
93
+ */
94
+ static parse(cookieHeader: string): CookieMap {
95
+ const map = new CookieMap();
96
+ if (cookieHeader.length == 0) return map;
97
+
98
+ const parts = cookieHeader.split(';');
99
+ for (let i = 0; i < parts.length; i++) {
100
+ const pair = parts[i].trim();
101
+ if (pair.length == 0) continue;
102
+
103
+ const eq = pair.indexOf('=');
104
+ let name: string;
105
+ let rawVal: string;
106
+ if (eq < 0) {
107
+ name = pair;
108
+ rawVal = '';
109
+ } else {
110
+ name = pair.substring(0, eq).trim();
111
+ rawVal = pair.substring(eq + 1).trim();
112
+ }
113
+ if (name.length == 0) continue;
114
+
115
+ map.set(name, percentDecode(unquote(rawVal)));
116
+ }
117
+ return map;
118
+ }
119
+
120
+ /** Shorthand: parse `cookieHeader` and return the value for `name`, or `null`. */
121
+ static get(cookieHeader: string, name: string): string | null {
122
+ return Cookies.parse(cookieHeader).get(name);
123
+ }
124
+
125
+ /**
126
+ * One-shot `Set-Cookie` value for `name=value` with no attributes
127
+ * (percent-encoded). For attributes, build a {@link Cookie} and call
128
+ * `cookie.serialize()`.
129
+ */
130
+ static serialize(name: string, value: string): string {
131
+ return new Cookie(name, value).serialize();
132
+ }
133
+
134
+ /**
135
+ * Parse a `Set-Cookie` field value back into a {@link Cookie} (for clients,
136
+ * tests, or proxies). The value is kept verbatim (`CookieEncoding.Raw`) so
137
+ * re-serializing reproduces the original wire form.
138
+ */
139
+ static parseSetCookie(setCookie: string): Cookie {
140
+ const parts = setCookie.split(';');
141
+ const first = parts.length > 0 ? parts[0].trim() : '';
142
+ const eq = first.indexOf('=');
143
+ let name: string;
144
+ let rawVal: string;
145
+ if (eq < 0) {
146
+ name = first;
147
+ rawVal = '';
148
+ } else {
149
+ name = first.substring(0, eq).trim();
150
+ rawVal = first.substring(eq + 1).trim();
151
+ }
152
+
153
+ const c = new Cookie(name, rawVal);
154
+ c.encoding = CookieEncoding.Raw;
155
+
156
+ for (let i = 1; i < parts.length; i++) {
157
+ const av = parts[i].trim();
158
+ if (av.length == 0) continue;
159
+ const aeq = av.indexOf('=');
160
+ let an: string;
161
+ let avv: string;
162
+ if (aeq < 0) {
163
+ an = av.toLowerCase();
164
+ avv = '';
165
+ } else {
166
+ an = av.substring(0, aeq).trim().toLowerCase();
167
+ avv = av.substring(aeq + 1).trim();
168
+ }
169
+
170
+ if (an == 'domain') c.domain(avv);
171
+ else if (an == 'path') c.path(avv);
172
+ else if (an == 'max-age') c.maxAge(parseI64(avv));
173
+ else if (an == 'expires') c.expiresRaw(avv);
174
+ else if (an == 'samesite') {
175
+ const lv = avv.toLowerCase();
176
+ if (lv == 'strict') c.sameSite(SameSite.Strict);
177
+ else if (lv == 'lax') c.sameSite(SameSite.Lax);
178
+ else if (lv == 'none') c.sameSite(SameSite.None);
179
+ } else if (an == 'secure') c.secure(true);
180
+ else if (an == 'httponly') c.httpOnly(true);
181
+ else if (an == 'partitioned') c.partitioned(true);
182
+ else if (an == 'priority') c.priority(avv);
183
+ else c.extension(av); // unknown extension attribute, kept verbatim
184
+ }
185
+ return c;
186
+ }
187
+
188
+ /** Percent-encode a value as the default `Cookie` encoding would. */
189
+ static encodeValue(raw: string): string {
190
+ return percentEncode(raw);
191
+ }
192
+
193
+ /** Percent-decode a value (the inverse of {@link encodeValue}). */
194
+ static decodeValue(enc: string): string {
195
+ return percentDecode(enc);
196
+ }
197
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * IMF-fixdate formatting for the cookie `Expires` attribute, e.g.
3
+ * `Sun, 06 Nov 1994 08:49:37 GMT` (RFC 9110 §5.6.7, the only date format
4
+ * RFC 6265bis permits a server to emit).
5
+ *
6
+ * Pure integer math (no `Date` dependency) so it is deterministic and unit
7
+ * testable: the civil-from-days conversion is Howard Hinnant's algorithm,
8
+ * valid across the whole proleptic Gregorian range. Internal to the cookie
9
+ * library (not a global).
10
+ */
11
+
12
+ const DOW: string[] = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
13
+ const MON: string[] = [
14
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
15
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
16
+ ];
17
+
18
+ function pad2(n: i32): string {
19
+ return n < 10 ? '0' + n.toString() : n.toString();
20
+ }
21
+
22
+ /**
23
+ * Format `epochSeconds` (Unix time, seconds since 1970-01-01T00:00:00Z) as an
24
+ * IMF-fixdate string in GMT. Handles negative inputs (pre-epoch) correctly via
25
+ * floored division.
26
+ */
27
+ export function imfFixdate(epochSeconds: i64): string {
28
+ // Floored division into whole days + remaining seconds-of-day.
29
+ let days: i64 = epochSeconds / 86400;
30
+ let secs: i64 = epochSeconds % 86400;
31
+ if (secs < 0) {
32
+ secs += 86400;
33
+ days -= 1;
34
+ }
35
+
36
+ // 1970-01-01 is a Thursday (index 4 with Sun=0). Positive modulo.
37
+ let wd = <i32>(((days % 7) + 4) % 7);
38
+ if (wd < 0) wd += 7;
39
+
40
+ // Civil date from day count (Hinnant). Shift epoch to 0000-03-01.
41
+ const z: i64 = days + 719468;
42
+ const era: i64 = (z >= 0 ? z : z - 146096) / 146097;
43
+ const doe: i64 = z - era * 146097; // [0, 146096]
44
+ const yoe: i64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
45
+ const y: i64 = yoe + era * 400;
46
+ const doy: i64 = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
47
+ const mp: i64 = (5 * doy + 2) / 153; // [0, 11]
48
+ const d: i64 = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
49
+ const m: i64 = mp < 10 ? mp + 3 : mp - 9; // [1, 12]
50
+ const year: i64 = y + (m <= 2 ? 1 : 0);
51
+
52
+ const hour = <i32>(secs / 3600);
53
+ const minute = <i32>((secs % 3600) / 60);
54
+ const second = <i32>(secs % 60);
55
+
56
+ return (
57
+ DOW[wd] +
58
+ ', ' +
59
+ pad2(<i32>d) +
60
+ ' ' +
61
+ MON[<i32>m - 1] +
62
+ ' ' +
63
+ year.toString() +
64
+ ' ' +
65
+ pad2(hour) +
66
+ ':' +
67
+ pad2(minute) +
68
+ ':' +
69
+ pad2(second) +
70
+ ' GMT'
71
+ );
72
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Percent-encoding for cookie values, matching `encodeURIComponent` /
3
+ * `decodeURIComponent` semantics (the de-facto default of Node's `cookie`
4
+ * package). The unreserved set (`A-Z a-z 0-9 - _ . ! ~ * ' ( )`) is a subset of
5
+ * the RFC 6265bis `cookie-octet` grammar, so the output is always a valid
6
+ * unquoted cookie value and arbitrary UTF-8 round-trips safely.
7
+ *
8
+ * Internal to the cookie library (not a global); surfaced through
9
+ * `Cookies.encodeValue` / `Cookies.decodeValue`.
10
+ */
11
+
12
+ const HEX: string = '0123456789ABCDEF';
13
+
14
+ function isUnreserved(c: i32): bool {
15
+ if (c >= 65 && c <= 90) return true; // A-Z
16
+ if (c >= 97 && c <= 122) return true; // a-z
17
+ if (c >= 48 && c <= 57) return true; // 0-9
18
+ // - _ . ! ~ * ' ( )
19
+ return (
20
+ c == 45 || c == 95 || c == 46 || c == 33 || c == 126 ||
21
+ c == 42 || c == 39 || c == 40 || c == 41
22
+ );
23
+ }
24
+
25
+ function hexVal(c: i32): i32 {
26
+ if (c >= 48 && c <= 57) return c - 48; // 0-9
27
+ if (c >= 65 && c <= 70) return c - 55; // A-F
28
+ if (c >= 97 && c <= 102) return c - 87; // a-f
29
+ return -1;
30
+ }
31
+
32
+ /** Percent-encode `s` (UTF-8) into a cookie-safe value. */
33
+ export function percentEncode(s: string): string {
34
+ const bytes = Uint8Array.wrap(String.UTF8.encode(s));
35
+ let out = '';
36
+ for (let i = 0; i < bytes.length; i++) {
37
+ const c = <i32>bytes[i];
38
+ if (isUnreserved(c)) {
39
+ out += String.fromCharCode(c);
40
+ } else {
41
+ out += '%';
42
+ out += HEX.charAt(c >> 4);
43
+ out += HEX.charAt(c & 15);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Reverse {@link percentEncode}. A `%` not followed by two hex digits is kept
51
+ * literally (lenient, never throws). `+` is preserved as-is (cookies are not
52
+ * form-encoded, so `+` is not a space).
53
+ */
54
+ export function percentDecode(s: string): string {
55
+ const n = s.length;
56
+ const bytes = new Array<u8>();
57
+ let i = 0;
58
+ while (i < n) {
59
+ const c = s.charCodeAt(i);
60
+ if (c == 37 && i + 2 < n) {
61
+ // '%'
62
+ const hi = hexVal(s.charCodeAt(i + 1));
63
+ const lo = hexVal(s.charCodeAt(i + 2));
64
+ if (hi >= 0 && lo >= 0) {
65
+ bytes.push(<u8>((hi << 4) | lo));
66
+ i += 3;
67
+ continue;
68
+ }
69
+ }
70
+ bytes.push(<u8>(c & 0xff));
71
+ i++;
72
+ }
73
+ const arr = new Uint8Array(bytes.length);
74
+ for (let j = 0; j < bytes.length; j++) arr[j] = bytes[j];
75
+ return String.UTF8.decodeUnsafe(arr.dataStart, arr.byteLength);
76
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * `SecureCookies` — tamper-proof and confidential cookie values, built on the
3
+ * ambient `crypto` global (no new host functions).
4
+ *
5
+ * - `SecureCookies.signed(key)` — HMAC-SHA256. The value stays readable but is
6
+ * bound to the cookie name, so it cannot be tampered with or moved to another
7
+ * cookie. Sealed form: `base64url(value) "." base64url(mac)`.
8
+ * - `SecureCookies.encrypted(key)` — AES-256-GCM with a random 96-bit IV and
9
+ * the cookie name as AAD. The value is confidential and authenticated.
10
+ * Sealed form: `base64url(iv ‖ ciphertext ‖ tag)`.
11
+ *
12
+ * Keys are caller-supplied raw bytes (HMAC: any length; AES: 16 or 32 bytes).
13
+ * Extra keys can be added for rotation: seal with the first, open with any.
14
+ *
15
+ * Verification and decryption are panic-free against attacker input: given a
16
+ * valid key, a tampered or truncated sealed value yields `null`, never a trap
17
+ * (`decrypt` reads the host return code directly rather than letting
18
+ * `subtle.decrypt` throw on a bad tag, since toilscript runs with exceptions
19
+ * disabled). Sealing with a misconfigured key (e.g. a wrong-length AES key) is a
20
+ * server-side error and is rejected up front by the factory.
21
+ *
22
+ * Ambient global (`@global`) and exported from `toiljs/server/runtime`.
23
+ */
24
+
25
+ import {
26
+ CryptoKey,
27
+ AlgorithmParams,
28
+ AesGcmParams,
29
+ HmacImportParams,
30
+ HmacParams,
31
+ ALG_AES_GCM,
32
+ ALG_SHA_256,
33
+ USAGE_SIGN,
34
+ USAGE_VERIFY,
35
+ USAGE_ENCRYPT,
36
+ USAGE_DECRYPT,
37
+ } from 'crypto';
38
+ import { DataWriter } from 'data';
39
+ import { webcrypto } from 'bindings/webcrypto';
40
+
41
+ import { Cookie, CookieEncoding } from './cookie';
42
+ import { CookieMap } from './cookies';
43
+ import { base64UrlEncode, base64UrlDecode } from './base64';
44
+
45
+ const MODE_SIGNED: i32 = 0;
46
+ const MODE_ENCRYPTED: i32 = 1;
47
+
48
+ const IV_LEN: i32 = 12;
49
+ const TAG_LEN: i32 = 16;
50
+
51
+ /** Import params carrying just the AES-GCM algorithm id (the host stores the raw key). */
52
+ class AesKeyParams extends AlgorithmParams {
53
+ serialize(w: DataWriter): void {
54
+ w.writeI32(ALG_AES_GCM);
55
+ w.writeI32(0);
56
+ }
57
+ }
58
+
59
+ function utf8(s: string): Uint8Array {
60
+ return Uint8Array.wrap(String.UTF8.encode(s));
61
+ }
62
+
63
+ function fromUtf8(b: Uint8Array): string {
64
+ return String.UTF8.decodeUnsafe(b.dataStart, b.byteLength);
65
+ }
66
+
67
+ /** AES-GCM keys must be 16 or 32 bytes; fail early with a clear message. */
68
+ function assertAesKeyLen(key: Uint8Array): void {
69
+ if (key.length != 16 && key.length != 32) {
70
+ throw new Error('SecureCookies.encrypted requires a 16- or 32-byte key (AES-128/256)');
71
+ }
72
+ }
73
+
74
+ @global
75
+ export class SecureCookies {
76
+ private mode: i32;
77
+ private keys: Array<Uint8Array>;
78
+
79
+ private constructor(mode: i32, key: Uint8Array) {
80
+ this.mode = mode;
81
+ this.keys = new Array<Uint8Array>();
82
+ this.keys.push(key);
83
+ }
84
+
85
+ /** HMAC-SHA256 signer/verifier with `key` (any length). */
86
+ static signed(key: Uint8Array): SecureCookies {
87
+ return new SecureCookies(MODE_SIGNED, key);
88
+ }
89
+
90
+ /** AES-256-GCM (or AES-128-GCM) with `key` (32 or 16 bytes). */
91
+ static encrypted(key: Uint8Array): SecureCookies {
92
+ assertAesKeyLen(key);
93
+ return new SecureCookies(MODE_ENCRYPTED, key);
94
+ }
95
+
96
+ /** Add a fallback key for rotation: sealing uses the first key, opening tries all. */
97
+ addKey(key: Uint8Array): SecureCookies {
98
+ if (this.mode == MODE_ENCRYPTED) assertAesKeyLen(key);
99
+ this.keys.push(key);
100
+ return this;
101
+ }
102
+
103
+ // --- key import (fresh per op: handles are per-request in the host) -----
104
+
105
+ private importHmac(key: Uint8Array): CryptoKey {
106
+ return crypto.subtle.importKey(
107
+ 'raw',
108
+ key,
109
+ new HmacImportParams(ALG_SHA_256),
110
+ false,
111
+ USAGE_SIGN | USAGE_VERIFY,
112
+ );
113
+ }
114
+
115
+ private importAes(key: Uint8Array): CryptoKey {
116
+ return crypto.subtle.importKey(
117
+ 'raw',
118
+ key,
119
+ new AesKeyParams(),
120
+ false,
121
+ USAGE_ENCRYPT | USAGE_DECRYPT,
122
+ );
123
+ }
124
+
125
+ // --- signing ------------------------------------------------------------
126
+
127
+ /** Return the signed (name-bound) sealed value for `name=value`. */
128
+ sign(name: string, value: string): string {
129
+ const k = this.importHmac(this.keys[0]);
130
+ const mac = crypto.subtle.sign(new HmacParams(), k, utf8(name + '=' + value));
131
+ return base64UrlEncode(utf8(value)) + '.' + base64UrlEncode(mac);
132
+ }
133
+
134
+ /** Verify a signed value for `name`, returning the plaintext or `null`. */
135
+ unsign(name: string, sealed: string): string | null {
136
+ const dot = sealed.lastIndexOf('.');
137
+ if (dot < 0) return null;
138
+
139
+ const valBytes = base64UrlDecode(sealed.substring(0, dot));
140
+ const macBytes = base64UrlDecode(sealed.substring(dot + 1));
141
+ if (valBytes == null || macBytes == null) return null;
142
+
143
+ const value = fromUtf8(valBytes);
144
+ const msg = utf8(name + '=' + value);
145
+ for (let i = 0; i < this.keys.length; i++) {
146
+ const k = this.importHmac(this.keys[i]);
147
+ // HMAC verify returns false (not an error) on mismatch -> no throw.
148
+ if (crypto.subtle.verify(new HmacParams(), k, macBytes, msg)) return value;
149
+ }
150
+ return null;
151
+ }
152
+
153
+ // --- encryption ---------------------------------------------------------
154
+
155
+ /** Return the AES-GCM-encrypted sealed value for `name` / `value`. */
156
+ encrypt(name: string, value: string): string {
157
+ const iv = new Uint8Array(IV_LEN);
158
+ crypto.getRandomValues(iv);
159
+
160
+ const k = this.importAes(this.keys[0]);
161
+ const ct = crypto.subtle.encrypt(new AesGcmParams(iv, utf8(name), 128), k, utf8(value));
162
+
163
+ const sealed = new Uint8Array(IV_LEN + ct.length);
164
+ for (let i = 0; i < IV_LEN; i++) sealed[i] = iv[i];
165
+ for (let i = 0; i < ct.length; i++) sealed[IV_LEN + i] = ct[i];
166
+ return base64UrlEncode(sealed);
167
+ }
168
+
169
+ /** Decrypt a sealed value for `name`, returning the plaintext or `null`. */
170
+ decrypt(name: string, sealed: string): string | null {
171
+ const raw = base64UrlDecode(sealed);
172
+ if (raw == null) return null;
173
+ if (raw.length < IV_LEN + TAG_LEN) return null; // need IV + at least the tag
174
+
175
+ const iv = new Uint8Array(IV_LEN);
176
+ for (let i = 0; i < IV_LEN; i++) iv[i] = raw[i];
177
+ const data = raw.subarray(IV_LEN);
178
+ const aad = utf8(name);
179
+
180
+ for (let i = 0; i < this.keys.length; i++) {
181
+ const k = this.importAes(this.keys[i]);
182
+ const params = new AesGcmParams(iv, aad, 128).pack();
183
+ // Raw host call: a bad tag / wrong key returns a negative code, which
184
+ // we turn into `null`. Going through `subtle.decrypt` would throw and
185
+ // (exceptions being disabled) abort the request.
186
+ const len = webcrypto.decrypt(
187
+ k.handle,
188
+ params.dataStart,
189
+ params.byteLength,
190
+ data.dataStart,
191
+ data.byteLength,
192
+ );
193
+ if (len >= 0) {
194
+ const out = new Uint8Array(len);
195
+ if (len > 0) webcrypto.takeResult(out.dataStart, len);
196
+ return fromUtf8(out);
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ // --- cookie helpers -----------------------------------------------------
203
+
204
+ /**
205
+ * Seal `cookie`'s value in place (sign or encrypt per this instance's mode)
206
+ * and mark it `Raw` (the sealed value is already cookie-safe base64url).
207
+ * Returns the same cookie for chaining.
208
+ */
209
+ seal(cookie: Cookie): Cookie {
210
+ cookie.value =
211
+ this.mode == MODE_ENCRYPTED
212
+ ? this.encrypt(cookie.name, cookie.value)
213
+ : this.sign(cookie.name, cookie.value);
214
+ cookie.encoding = CookieEncoding.Raw;
215
+ return cookie;
216
+ }
217
+
218
+ /** Read and open cookie `name` from a parsed jar, or `null` if missing/invalid. */
219
+ open(jar: CookieMap, name: string): string | null {
220
+ const sealed = jar.get(name);
221
+ if (sealed == null) return null;
222
+ return this.mode == MODE_ENCRYPTED ? this.decrypt(name, sealed) : this.unsign(name, sealed);
223
+ }
224
+ }
@@ -19,8 +19,25 @@ export { Response, TOIL_UNHANDLED_HEADER } from './response';
19
19
  export { ToilHandler } from './handlers/ToilHandler';
20
20
  export { Server, ServerEnvironment } from './env/Server';
21
21
 
22
+ // Wall-clock (`Time.nowMillis()` / `Time.nowSeconds()`), backed by the host
23
+ // `Date.now()` binding. Ambient global (`@global`), also re-exported here.
24
+ export { Time } from './time';
25
+
26
+ // Edge SSR (`render` entrypoint): the render router + the typed slot-values
27
+ // API a route's `render(req)` fills. See `./exports/render`.
28
+ export { Ssr, SsrRegistry, RenderFn } from './ssr/Ssr';
29
+ export { SlotValues, SlotValue, HtmlBuilder } from './ssr/slots';
30
+
22
31
  // HTTP layer (`@rest` / `@route`).
23
32
  export { Rest, RestRegistry, RouteFn } from './rest/Rest';
24
33
  export { RouteContext } from './rest/RouteContext';
25
34
  export { matchRoute } from './rest/match';
26
35
  export { RestHandler } from './rest/RestHandler';
36
+
37
+ // Cookies (`Cookie` / `Cookies` / `SecureCookies`). These are also ambient
38
+ // globals (`@global`), so a handler can use them with no import; the re-export
39
+ // keeps them importable and pulls the modules into every build.
40
+ export { Cookie, SameSite, CookieEncoding, CookiePrefix, CookieValidation } from './http/cookie';
41
+ export { Cookies, CookieMap } from './http/cookies';
42
+ export { SecureCookies } from './http/securecookies';
43
+ export { base64UrlEncode, base64UrlDecode } from './http/base64';
@@ -4,6 +4,8 @@
4
4
  * memory. See `envelope.ts`.
5
5
  */
6
6
 
7
+ import { Cookies, CookieMap } from './http/cookies';
8
+
7
9
  export enum Method {
8
10
  GET = 0,
9
11
  POST = 1,
@@ -31,6 +33,9 @@ export class Request {
31
33
  headers: Array<Header>;
32
34
  body: Uint8Array;
33
35
 
36
+ // Lazily parsed `Cookie` header, cached for the life of the request.
37
+ private _cookies: CookieMap | null = null;
38
+
34
39
  constructor(method: Method, path: string, headers: Array<Header>, body: Uint8Array) {
35
40
  this.method = method;
36
41
  this.path = path;
@@ -52,4 +57,23 @@ export class Request {
52
57
  }
53
58
  return null;
54
59
  }
60
+
61
+ /**
62
+ * The request's cookies, parsed from the `Cookie` header (values are
63
+ * percent-decoded). Parsed once and cached; an empty map if there is no
64
+ * `Cookie` header.
65
+ */
66
+ cookies(): CookieMap {
67
+ const cached = this._cookies;
68
+ if (cached != null) return cached;
69
+ const h = this.header('cookie');
70
+ const map = h == null ? new CookieMap() : Cookies.parse(h);
71
+ this._cookies = map;
72
+ return map;
73
+ }
74
+
75
+ /** A single cookie value by name, or `null` if absent. */
76
+ cookie(name: string): string | null {
77
+ return this.cookies().get(name);
78
+ }
55
79
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Header } from './request';
8
+ import { Cookie } from './http/cookie';
8
9
 
9
10
  /**
10
11
  * Marker header on the runtime's fallback 404 (no route matched, no handler
@@ -107,6 +108,34 @@ export class Response {
107
108
  return this;
108
109
  }
109
110
 
111
+ /**
112
+ * Append a `Set-Cookie` for `cookie`. Each call adds its own header entry,
113
+ * so multiple cookies are emitted as separate `Set-Cookie` headers (never
114
+ * folded). Builder-style: returns `this`.
115
+ */
116
+ public setCookie(cookie: Cookie): Response {
117
+ this.headers.push(new Header('set-cookie', cookie.serialize()));
118
+
119
+ return this;
120
+ }
121
+
122
+ /** Shorthand for `setCookie(new Cookie(name, value))` (no attributes). */
123
+ public setCookieKV(name: string, value: string): Response {
124
+ return this.setCookie(new Cookie(name, value));
125
+ }
126
+
127
+ /**
128
+ * Append a `Set-Cookie` that deletes `name`: empty value, `Max-Age=0`, and
129
+ * an epoch `Expires`. `path` (default `/`) and `domain` must match the
130
+ * cookie being cleared for the browser to drop it. Builder-style.
131
+ */
132
+ public clearCookie(name: string, path: string = '/', domain: string = ''): Response {
133
+ const c = new Cookie(name, '').path(path).maxAge(0).expires(0);
134
+ if (domain.length > 0) c.domain(domain);
135
+
136
+ return this.setCookie(c);
137
+ }
138
+
110
139
  /**
111
140
  * Mark this response cacheable at the toil edge and/or the browser.
112
141
  *