@ketvietlab/ketjs 0.1.2 → 0.1.5

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 (307) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +8 -2
  3. package/dist/agent/capabilities.d.ts +1 -3
  4. package/dist/agent/capabilities.d.ts.map +1 -1
  5. package/dist/agent/capabilities.js +10 -1
  6. package/dist/agent/capabilities.js.map +1 -1
  7. package/dist/agent/permissions.d.ts +43 -1
  8. package/dist/agent/permissions.d.ts.map +1 -1
  9. package/dist/agent/permissions.js +50 -2
  10. package/dist/agent/permissions.js.map +1 -1
  11. package/dist/cli.js +246 -97
  12. package/dist/cli.js.map +1 -1
  13. package/dist/codegen/dts.d.ts.map +1 -1
  14. package/dist/codegen/dts.js +1 -0
  15. package/dist/codegen/dts.js.map +1 -1
  16. package/dist/data/changeset.d.ts +12 -0
  17. package/dist/data/changeset.d.ts.map +1 -1
  18. package/dist/data/changeset.js +73 -17
  19. package/dist/data/changeset.js.map +1 -1
  20. package/dist/data/expr.d.ts +25 -1
  21. package/dist/data/expr.d.ts.map +1 -1
  22. package/dist/data/expr.js +47 -6
  23. package/dist/data/expr.js.map +1 -1
  24. package/dist/data/fleet.d.ts +41 -1
  25. package/dist/data/fleet.d.ts.map +1 -1
  26. package/dist/data/fleet.js +100 -11
  27. package/dist/data/fleet.js.map +1 -1
  28. package/dist/data/migrate.d.ts +20 -0
  29. package/dist/data/migrate.d.ts.map +1 -1
  30. package/dist/data/migrate.js +86 -3
  31. package/dist/data/migrate.js.map +1 -1
  32. package/dist/data/physical.d.ts +5 -0
  33. package/dist/data/physical.d.ts.map +1 -0
  34. package/dist/data/physical.js +196 -0
  35. package/dist/data/physical.js.map +1 -0
  36. package/dist/data/pool.d.ts +2 -1
  37. package/dist/data/pool.d.ts.map +1 -1
  38. package/dist/data/pool.js +159 -31
  39. package/dist/data/pool.js.map +1 -1
  40. package/dist/data/query.d.ts +10 -1
  41. package/dist/data/query.d.ts.map +1 -1
  42. package/dist/data/query.js +136 -27
  43. package/dist/data/query.js.map +1 -1
  44. package/dist/data/sqlite.d.ts.map +1 -1
  45. package/dist/data/sqlite.js +350 -52
  46. package/dist/data/sqlite.js.map +1 -1
  47. package/dist/data/time.d.ts +13 -0
  48. package/dist/data/time.d.ts.map +1 -1
  49. package/dist/data/time.js +61 -17
  50. package/dist/data/time.js.map +1 -1
  51. package/dist/index.d.ts +45 -24
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +34 -18
  54. package/dist/index.js.map +1 -1
  55. package/dist/kernel/audit.d.ts +27 -0
  56. package/dist/kernel/audit.d.ts.map +1 -0
  57. package/dist/kernel/audit.js +67 -0
  58. package/dist/kernel/audit.js.map +1 -0
  59. package/dist/kernel/classification.d.ts +26 -0
  60. package/dist/kernel/classification.d.ts.map +1 -0
  61. package/dist/kernel/classification.js +73 -0
  62. package/dist/kernel/classification.js.map +1 -0
  63. package/dist/kernel/compose.d.ts +6 -2
  64. package/dist/kernel/compose.d.ts.map +1 -1
  65. package/dist/kernel/compose.js +269 -15
  66. package/dist/kernel/compose.js.map +1 -1
  67. package/dist/kernel/define.d.ts +2 -3
  68. package/dist/kernel/define.d.ts.map +1 -1
  69. package/dist/kernel/define.js +22 -12
  70. package/dist/kernel/define.js.map +1 -1
  71. package/dist/kernel/diff.d.ts.map +1 -1
  72. package/dist/kernel/diff.js +28 -4
  73. package/dist/kernel/diff.js.map +1 -1
  74. package/dist/kernel/graph.js +1 -1
  75. package/dist/kernel/graph.js.map +1 -1
  76. package/dist/kernel/i18n.d.ts +10 -1
  77. package/dist/kernel/i18n.d.ts.map +1 -1
  78. package/dist/kernel/i18n.js +34 -1
  79. package/dist/kernel/i18n.js.map +1 -1
  80. package/dist/kernel/layout.d.ts +97 -0
  81. package/dist/kernel/layout.d.ts.map +1 -1
  82. package/dist/kernel/layout.js +294 -12
  83. package/dist/kernel/layout.js.map +1 -1
  84. package/dist/kernel/menu.d.ts +33 -2
  85. package/dist/kernel/menu.d.ts.map +1 -1
  86. package/dist/kernel/menu.js +99 -7
  87. package/dist/kernel/menu.js.map +1 -1
  88. package/dist/kernel/modules.d.ts +3 -3
  89. package/dist/kernel/modules.d.ts.map +1 -1
  90. package/dist/kernel/modules.js +21 -21
  91. package/dist/kernel/modules.js.map +1 -1
  92. package/dist/kernel/permissions.d.ts +10 -0
  93. package/dist/kernel/permissions.d.ts.map +1 -0
  94. package/dist/kernel/permissions.js +365 -0
  95. package/dist/kernel/permissions.js.map +1 -0
  96. package/dist/kernel/schedule.d.ts +23 -0
  97. package/dist/kernel/schedule.d.ts.map +1 -0
  98. package/dist/kernel/schedule.js +83 -0
  99. package/dist/kernel/schedule.js.map +1 -0
  100. package/dist/kernel/types.d.ts.map +1 -1
  101. package/dist/kernel/types.js +5 -4
  102. package/dist/kernel/types.js.map +1 -1
  103. package/dist/kernel/workspace.d.ts +92 -14
  104. package/dist/kernel/workspace.d.ts.map +1 -1
  105. package/dist/kernel/workspace.js +128 -49
  106. package/dist/kernel/workspace.js.map +1 -1
  107. package/dist/pdf/assets/Inter-Bold.ttf +0 -0
  108. package/dist/pdf/assets/Inter-Regular.ttf +0 -0
  109. package/dist/pdf/assets/Inter-SemiBold.ttf +0 -0
  110. package/dist/pdf/assets/LICENSE.txt +92 -0
  111. package/dist/pdf/font.d.ts +10 -0
  112. package/dist/pdf/font.d.ts.map +1 -0
  113. package/dist/pdf/font.js +83 -0
  114. package/dist/pdf/font.js.map +1 -0
  115. package/dist/pdf/image.d.ts +8 -0
  116. package/dist/pdf/image.d.ts.map +1 -0
  117. package/dist/pdf/image.js +94 -0
  118. package/dist/pdf/image.js.map +1 -0
  119. package/dist/pdf/index.d.ts +15 -0
  120. package/dist/pdf/index.d.ts.map +1 -0
  121. package/dist/pdf/index.js +17 -0
  122. package/dist/pdf/index.js.map +1 -0
  123. package/dist/pdf/markup.d.ts +19 -0
  124. package/dist/pdf/markup.d.ts.map +1 -0
  125. package/dist/pdf/markup.js +146 -0
  126. package/dist/pdf/markup.js.map +1 -0
  127. package/dist/pdf/render.d.ts +14 -0
  128. package/dist/pdf/render.d.ts.map +1 -0
  129. package/dist/pdf/render.js +387 -0
  130. package/dist/pdf/render.js.map +1 -0
  131. package/dist/scaffold/index.js +6 -6
  132. package/dist/scaffold/index.js.map +1 -1
  133. package/dist/scaffold/templates/{app.test.ts.tmpl → deployment.test.ts.tmpl} +3 -3
  134. package/dist/scaffold/templates/ket.workspace.ts.tmpl +4 -6
  135. package/dist/scaffold/templates/module.ts.tmpl +1 -4
  136. package/dist/server/assets.d.ts +23 -0
  137. package/dist/server/assets.d.ts.map +1 -0
  138. package/dist/server/assets.js +97 -0
  139. package/dist/server/assets.js.map +1 -0
  140. package/dist/server/boot.d.ts +174 -38
  141. package/dist/server/boot.d.ts.map +1 -1
  142. package/dist/server/boot.js +399 -140
  143. package/dist/server/boot.js.map +1 -1
  144. package/dist/server/config.d.ts +30 -10
  145. package/dist/server/config.d.ts.map +1 -1
  146. package/dist/server/config.js +73 -17
  147. package/dist/server/config.js.map +1 -1
  148. package/dist/server/ctx.d.ts +10 -0
  149. package/dist/server/ctx.d.ts.map +1 -1
  150. package/dist/server/ctx.js +284 -48
  151. package/dist/server/ctx.js.map +1 -1
  152. package/dist/server/development.d.ts +10 -0
  153. package/dist/server/development.d.ts.map +1 -0
  154. package/dist/server/development.js +15 -0
  155. package/dist/server/development.js.map +1 -0
  156. package/dist/server/fn.d.ts +22 -3
  157. package/dist/server/fn.d.ts.map +1 -1
  158. package/dist/server/fn.js +116 -32
  159. package/dist/server/fn.js.map +1 -1
  160. package/dist/server/form.d.ts +33 -0
  161. package/dist/server/form.d.ts.map +1 -0
  162. package/dist/server/form.js +47 -0
  163. package/dist/server/form.js.map +1 -0
  164. package/dist/server/http.d.ts +82 -3
  165. package/dist/server/http.d.ts.map +1 -1
  166. package/dist/server/http.js +373 -37
  167. package/dist/server/http.js.map +1 -1
  168. package/dist/server/idem.d.ts +3 -2
  169. package/dist/server/idem.d.ts.map +1 -1
  170. package/dist/server/idem.js +12 -4
  171. package/dist/server/idem.js.map +1 -1
  172. package/dist/server/jobs.js +1 -1
  173. package/dist/server/jobs.js.map +1 -1
  174. package/dist/server/log/combinators.d.ts +42 -0
  175. package/dist/server/log/combinators.d.ts.map +1 -0
  176. package/dist/server/log/combinators.js +270 -0
  177. package/dist/server/log/combinators.js.map +1 -0
  178. package/dist/server/log/console.d.ts +12 -0
  179. package/dist/server/log/console.d.ts.map +1 -0
  180. package/dist/server/log/console.js +96 -0
  181. package/dist/server/log/console.js.map +1 -0
  182. package/dist/server/log/file.d.ts +11 -0
  183. package/dist/server/log/file.d.ts.map +1 -0
  184. package/dist/server/log/file.js +61 -0
  185. package/dist/server/log/file.js.map +1 -0
  186. package/dist/server/log/index.d.ts +23 -0
  187. package/dist/server/log/index.d.ts.map +1 -0
  188. package/dist/server/log/index.js +45 -0
  189. package/dist/server/log/index.js.map +1 -0
  190. package/dist/server/log/logger.d.ts +53 -0
  191. package/dist/server/log/logger.d.ts.map +1 -0
  192. package/dist/server/log/logger.js +94 -0
  193. package/dist/server/log/logger.js.map +1 -0
  194. package/dist/server/log/memory.d.ts +11 -0
  195. package/dist/server/log/memory.d.ts.map +1 -0
  196. package/dist/server/log/memory.js +25 -0
  197. package/dist/server/log/memory.js.map +1 -0
  198. package/dist/server/log/types.d.ts +112 -0
  199. package/dist/server/log/types.d.ts.map +1 -0
  200. package/dist/server/log/types.js +58 -0
  201. package/dist/server/log/types.js.map +1 -0
  202. package/dist/server/policy.d.ts +29 -0
  203. package/dist/server/policy.d.ts.map +1 -0
  204. package/dist/server/policy.js +29 -0
  205. package/dist/server/policy.js.map +1 -0
  206. package/dist/server/queue.d.ts +1 -1
  207. package/dist/server/queue.d.ts.map +1 -1
  208. package/dist/server/queue.js.map +1 -1
  209. package/dist/server/ratelimit.d.ts +48 -0
  210. package/dist/server/ratelimit.d.ts.map +1 -0
  211. package/dist/server/ratelimit.js +102 -0
  212. package/dist/server/ratelimit.js.map +1 -0
  213. package/dist/server/respond.d.ts.map +1 -1
  214. package/dist/server/respond.js +16 -1
  215. package/dist/server/respond.js.map +1 -1
  216. package/dist/server/runtime.d.ts +18 -3
  217. package/dist/server/runtime.d.ts.map +1 -1
  218. package/dist/server/runtime.js +32 -2
  219. package/dist/server/runtime.js.map +1 -1
  220. package/dist/server/schedule.d.ts +18 -0
  221. package/dist/server/schedule.d.ts.map +1 -0
  222. package/dist/server/schedule.js +70 -0
  223. package/dist/server/schedule.js.map +1 -0
  224. package/dist/server/sequence.d.ts +42 -0
  225. package/dist/server/sequence.d.ts.map +1 -0
  226. package/dist/server/sequence.js +97 -0
  227. package/dist/server/sequence.js.map +1 -0
  228. package/dist/server/session.d.ts +21 -0
  229. package/dist/server/session.d.ts.map +1 -1
  230. package/dist/server/session.js +84 -10
  231. package/dist/server/session.js.map +1 -1
  232. package/dist/server/sessionstore.d.ts +3 -1
  233. package/dist/server/sessionstore.d.ts.map +1 -1
  234. package/dist/server/sessionstore.js +7 -3
  235. package/dist/server/sessionstore.js.map +1 -1
  236. package/dist/server/storage/index.d.ts +1 -1
  237. package/dist/server/storage/index.d.ts.map +1 -1
  238. package/dist/server/storage/index.js +61 -1
  239. package/dist/server/storage/index.js.map +1 -1
  240. package/dist/server/storage/types.d.ts +8 -0
  241. package/dist/server/storage/types.d.ts.map +1 -1
  242. package/dist/server/storage/types.js +58 -0
  243. package/dist/server/storage/types.js.map +1 -1
  244. package/dist/server/stream.d.ts +8 -2
  245. package/dist/server/stream.d.ts.map +1 -1
  246. package/dist/server/stream.js +44 -4
  247. package/dist/server/stream.js.map +1 -1
  248. package/dist/server/streamstore.d.ts +10 -0
  249. package/dist/server/streamstore.d.ts.map +1 -1
  250. package/dist/server/streamstore.js +72 -6
  251. package/dist/server/streamstore.js.map +1 -1
  252. package/dist/server/tenants.d.ts +20 -18
  253. package/dist/server/tenants.d.ts.map +1 -1
  254. package/dist/server/tenants.js +133 -84
  255. package/dist/server/tenants.js.map +1 -1
  256. package/dist/server/transport/types.js +2 -2
  257. package/dist/server/transport/types.js.map +1 -1
  258. package/dist/server/worker.d.ts +19 -3
  259. package/dist/server/worker.d.ts.map +1 -1
  260. package/dist/server/worker.js +168 -21
  261. package/dist/server/worker.js.map +1 -1
  262. package/dist/testing.d.ts +26 -10
  263. package/dist/testing.d.ts.map +1 -1
  264. package/dist/testing.js +41 -14
  265. package/dist/testing.js.map +1 -1
  266. package/dist/theme/contracts.d.ts +14 -1
  267. package/dist/theme/contracts.d.ts.map +1 -1
  268. package/dist/theme/contracts.js +35 -6
  269. package/dist/theme/contracts.js.map +1 -1
  270. package/dist/theme/index.d.ts +1 -1
  271. package/dist/theme/index.d.ts.map +1 -1
  272. package/dist/theme/index.js +1 -1
  273. package/dist/theme/index.js.map +1 -1
  274. package/dist/theme/joint-runtime.d.ts +33 -0
  275. package/dist/theme/joint-runtime.d.ts.map +1 -0
  276. package/dist/theme/joint-runtime.js +121 -0
  277. package/dist/theme/joint-runtime.js.map +1 -0
  278. package/dist/theme/joints.d.ts.map +1 -1
  279. package/dist/theme/joints.js +15 -97
  280. package/dist/theme/joints.js.map +1 -1
  281. package/dist/theme/ktl/compile.d.ts +4 -0
  282. package/dist/theme/ktl/compile.d.ts.map +1 -1
  283. package/dist/theme/ktl/compile.js +54 -1
  284. package/dist/theme/ktl/compile.js.map +1 -1
  285. package/dist/theme/ktl/parser.d.ts +4 -0
  286. package/dist/theme/ktl/parser.d.ts.map +1 -1
  287. package/dist/theme/ktl/parser.js +8 -0
  288. package/dist/theme/ktl/parser.js.map +1 -1
  289. package/dist/theme/render.d.ts +8 -0
  290. package/dist/theme/render.d.ts.map +1 -1
  291. package/dist/theme/render.js +88 -118
  292. package/dist/theme/render.js.map +1 -1
  293. package/dist/theme/tokens.d.ts +18 -0
  294. package/dist/theme/tokens.d.ts.map +1 -1
  295. package/dist/theme/tokens.js +33 -0
  296. package/dist/theme/tokens.js.map +1 -1
  297. package/dist/theme/viewmodel.d.ts +24 -0
  298. package/dist/theme/viewmodel.d.ts.map +1 -1
  299. package/dist/theme/viewmodel.js +75 -6
  300. package/dist/theme/viewmodel.js.map +1 -1
  301. package/dist/types.d.ts +320 -56
  302. package/dist/types.d.ts.map +1 -1
  303. package/package.json +6 -2
  304. package/dist/kernel/apps.d.ts +0 -43
  305. package/dist/kernel/apps.d.ts.map +0 -1
  306. package/dist/kernel/apps.js +0 -234
  307. package/dist/kernel/apps.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  // One server function, three surfaces: an HTTP endpoint, the typed client that
2
2
  // calls it, and an agent tool descriptor — all read off the same manifest entry.
3
+ import { isVersioned } from './assets.js';
3
4
  import { createServer } from 'node:http';
4
5
  import { pipeline } from 'node:stream/promises';
5
6
  import { isNavigationRequest, navigablePage } from './respond.js';
@@ -7,12 +8,65 @@ import { readFile } from 'node:fs/promises';
7
8
  import { join, normalize, extname } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { callFn } from './fn.js';
10
- import { createStreams, dbStreamStore, memoryStreamStore } from './stream.js';
11
+ import { partitionTokens, tokensToCss } from '../theme/tokens.js';
12
+ import { createStreams, memoryStreamStore, streamsOf } from './stream.js';
11
13
  import { agentDescriptor } from '../agent/capabilities.js';
12
14
  import { KetError } from '../kernel/errors.js';
15
+ import { FormValidationError } from './form.js';
13
16
  import { KetError as KetErr } from '../kernel/errors.js';
14
17
  import { compileRoutes } from '../kernel/routes.js';
18
+ import { claimRateSlot } from './ratelimit.js';
15
19
  import { html, trustedMarkup } from '@ketvietlab/ketjs-view';
20
+ /**
21
+ * What an error means over HTTP.
22
+ *
23
+ * Every KetError used to answer 400, which said "you sent something wrong" to a
24
+ * person whose request was fine and whose permissions were not. A monitor cannot
25
+ * tell a missing grant from a malformed body, and neither can a client.
26
+ */
27
+ export const statusForError = (code) => {
28
+ if (code === 'E_FN_NOT_PERMITTED')
29
+ return 403;
30
+ if (code === 'E_NOT_FOUND' || code === 'E_UNKNOWN_FUNCTION')
31
+ return 404;
32
+ if (code === 'E_PAYLOAD_TOO_LARGE')
33
+ return 413;
34
+ return 400;
35
+ };
36
+ /** True when this request is a browser navigating, rather than a client calling. */
37
+ export const wantsHtml = (req) => {
38
+ const accept = String(req.headers.accept ?? '');
39
+ if (!accept.includes('text/html'))
40
+ return false;
41
+ // A fragment request is answered by the caller's own error handling; replacing
42
+ // a slot with a full document would paint a page inside a page.
43
+ return String(req.headers['x-ket-navigation'] ?? '') === '';
44
+ };
45
+ /**
46
+ * A page that is not for the public.
47
+ *
48
+ * `no-store` rather than `private`: the reader's own browser cache is a place
49
+ * the draft outlives the link. `no-referrer` because the token is in the URL,
50
+ * and without it the first outbound click hands it to a third party.
51
+ */
52
+ const PRIVATE_PAGE_HEADERS = {
53
+ 'cache-control': 'no-store, max-age=0',
54
+ 'x-robots-tag': 'noindex, nofollow, noarchive',
55
+ 'referrer-policy': 'no-referrer',
56
+ };
57
+ /**
58
+ * The file types a module may publish, and what they are served as.
59
+ *
60
+ * This doubles as the list of what gets served at all, so it has to cover what
61
+ * a browser actually loads from an asset directory rather than only what this
62
+ * repo happens to ship today. It was briefly the shorter list, which 404'd
63
+ * `.jpeg`, `.gif`, `.webp` and `.woff` — all of which had worked, because an
64
+ * unknown type used to fall through to `application/octet-stream` and browsers
65
+ * sniff images successfully.
66
+ *
67
+ * What stays out is source and anything that carries it: `.ts`, `.tsx`, and
68
+ * `.map` — a minified bundle's sourcemap inlines the whole original file.
69
+ */
16
70
  const ASSET_MIME = {
17
71
  '.css': 'text/css',
18
72
  '.js': 'text/javascript',
@@ -20,9 +74,25 @@ const ASSET_MIME = {
20
74
  '.svg': 'image/svg+xml',
21
75
  '.png': 'image/png',
22
76
  '.jpg': 'image/jpeg',
77
+ '.jpeg': 'image/jpeg',
78
+ '.gif': 'image/gif',
79
+ '.webp': 'image/webp',
80
+ '.avif': 'image/avif',
81
+ '.woff': 'font/woff',
23
82
  '.woff2': 'font/woff2',
83
+ '.ttf': 'font/ttf',
84
+ '.otf': 'font/otf',
24
85
  '.ico': 'image/x-icon',
86
+ '.webmanifest': 'application/manifest+json',
87
+ '.pdf': 'application/pdf',
88
+ '.mp4': 'video/mp4',
89
+ '.webm': 'video/webm',
25
90
  };
91
+ /**
92
+ * Extensions are compared in lower case, because a file called `logo.PNG` is
93
+ * a PNG. `extname` reports the case it finds, so the table lookup missed it.
94
+ */
95
+ const assetType = (rel) => ASSET_MIME[extname(rel).toLowerCase()];
26
96
  const json = (res, status, body) => {
27
97
  const s = JSON.stringify(body, null, 2);
28
98
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
@@ -40,6 +110,26 @@ const contentType = (type) => {
40
110
  };
41
111
  const islandScript = '<script type="module" src="/_ket/islands.js"></script>';
42
112
  const viewRuntimeUrl = '/_ket/view/index.js';
113
+ const TOKENS_PATH = '/_ket/tokens.css';
114
+ const tokensLink = `<link rel="stylesheet" href="${TOKENS_PATH}">`;
115
+ /**
116
+ * A theme's declared tokens, linked into the document it renders.
117
+ *
118
+ * `tokens` had been a declaration with no consumer: every theme wrote one, the
119
+ * manifest composed them, `tokensToCss` turned them into `--ket-*` custom
120
+ * properties inside the `ket.theme` layer — and nothing ever put the result on a
121
+ * page, so themes hard-coded a second palette in their own stylesheet instead. The
122
+ * framework owns the cascade order it publishes, which is why it links this rather
123
+ * than asking every theme to.
124
+ */
125
+ const withThemeTokens = (body, css) => {
126
+ if (!css || body.includes(TOKENS_PATH))
127
+ return body;
128
+ const closingHead = body.indexOf('</head>');
129
+ return closingHead < 0
130
+ ? tokensLink + body
131
+ : body.slice(0, closingHead) + tokensLink + body.slice(closingHead);
132
+ };
43
133
  const bootstrapDocument = (body) => {
44
134
  if ((!body.includes('<ket-island') && !body.includes('data-ket-slot=')) ||
45
135
  body.includes('src="/_ket/islands.js"'))
@@ -242,13 +332,88 @@ const send = async (res, result) => {
242
332
  // 'close' — which hung the handler and leaked one fd per cancelled download.
243
333
  await pipeline(result.body, res);
244
334
  };
245
- const readBody = async (req) => {
335
+ const requestUrl = (req) => {
336
+ const target = req.url ?? '/';
337
+ const host = req.headers.host ?? 'localhost';
338
+ const invalid = () => new KetError({
339
+ code: 'E_INVALID_REQUEST_URL',
340
+ message: 'request target or Host header is invalid',
341
+ });
342
+ // KetJS is an origin server, not a forward proxy. Absolute-form and
343
+ // scheme-relative targets can replace the Host-derived authority in WHATWG URL
344
+ // parsing, and a backslash is treated like a slash for special schemes.
345
+ if (!target.startsWith('/') || target.startsWith('//') || target.includes('\\'))
346
+ throw invalid();
347
+ // Parse only an authority. Userinfo and path/query delimiters are all valid to
348
+ // URL(), but none are valid in the Host contract and each can change which
349
+ // tenant a resolver sees.
350
+ const forbiddenHostCharacter = [...host].some((character) => {
351
+ const code = character.charCodeAt(0);
352
+ return (character.trim() === '' ||
353
+ code <= 0x1f ||
354
+ (code >= 0x7f && code <= 0x9f) ||
355
+ '@/\\?#%'.includes(character));
356
+ });
357
+ if (!host || forbiddenHostCharacter)
358
+ throw invalid();
359
+ try {
360
+ const base = new URL(`http://${host}`);
361
+ if (base.username || base.password || base.pathname !== '/' || base.search || base.hash)
362
+ throw invalid();
363
+ const url = new URL(target, base);
364
+ if (url.origin !== base.origin)
365
+ throw invalid();
366
+ return url;
367
+ }
368
+ catch {
369
+ throw invalid();
370
+ }
371
+ };
372
+ const decodeRequestPath = (value) => {
373
+ try {
374
+ return decodeURIComponent(value);
375
+ }
376
+ catch {
377
+ throw new KetError({
378
+ code: 'E_ROUTE_ENCODING',
379
+ message: 'request path contains invalid percent encoding',
380
+ });
381
+ }
382
+ };
383
+ const readBody = async (req, maxBytes) => {
384
+ const declared = Number(req.headers['content-length']);
385
+ if (Number.isFinite(declared) && declared > maxBytes) {
386
+ throw new KetError({
387
+ code: 'E_PAYLOAD_TOO_LARGE',
388
+ message: `JSON body exceeds ${maxBytes} bytes`,
389
+ });
390
+ }
246
391
  const chunks = [];
247
- for await (const c of req)
248
- chunks.push(c);
392
+ let total = 0;
393
+ for await (const held of req) {
394
+ const chunk = Buffer.isBuffer(held) ? held : Buffer.from(held);
395
+ total += chunk.byteLength;
396
+ if (total > maxBytes) {
397
+ throw new KetError({
398
+ code: 'E_PAYLOAD_TOO_LARGE',
399
+ message: `JSON body exceeds ${maxBytes} bytes`,
400
+ });
401
+ }
402
+ chunks.push(chunk);
403
+ }
249
404
  if (!chunks.length)
250
405
  return {};
251
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
406
+ let value;
407
+ try {
408
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
409
+ }
410
+ catch {
411
+ throw new KetError({ code: 'E_INVALID_JSON_BODY', message: 'request body must be valid JSON' });
412
+ }
413
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
414
+ throw new KetError({ code: 'E_INVALID_JSON_BODY', message: 'request body must be a JSON object' });
415
+ }
416
+ return value;
252
417
  };
253
418
  export async function createKetServer(o) {
254
419
  if (!o.adapter && !o.pool)
@@ -258,6 +423,12 @@ export async function createKetServer(o) {
258
423
  code: 'E_NO_RESOLVER',
259
424
  message: 'a pool needs resolveDatastore to know which database a request belongs to',
260
425
  });
426
+ const maxJsonBodyBytes = o.maxJsonBodyBytes ?? 1024 * 1024;
427
+ if (!Number.isSafeInteger(maxJsonBodyBytes) || maxJsonBodyBytes <= 0)
428
+ throw new KetErr({
429
+ code: 'E_JSON_BODY_LIMIT',
430
+ message: 'maxJsonBodyBytes must be a positive safe integer',
431
+ });
261
432
  // Compile once: matching stays cheap per request, and an ambiguous surface is
262
433
  // refused before the server starts rather than depending on object insertion order.
263
434
  const matchRoute = compileRoutes(o.routes ?? {});
@@ -271,14 +442,26 @@ export async function createKetServer(o) {
271
442
  throw new KetErr({
272
443
  code: 'E_UNKNOWN_TENANT',
273
444
  message: `no datastore for ${url.host}${url.pathname}`,
274
- hint: 'resolveDatastore returned null — the request names a tenant this app does not serve',
445
+ hint: 'resolveDatastore returned null — the request names a tenant this deployment does not serve',
275
446
  });
276
447
  }
277
448
  return o.pool.with(key, fn);
278
449
  };
279
- // With one database the stream store lives in it. With a database per tenant it
280
- // does not: whose database a stream belongs to is a separate question, so the
281
- // default stays in memory and the caller passes a store when they have answered it.
450
+ /**
451
+ * Whose database a stream belongs to: the one it is about.
452
+ *
453
+ * This used to be left open. With a database per tenant there is no single
454
+ * adapter, so the store fell back to memory — per process, which meant a job in
455
+ * a worker had nowhere to write and two web instances shared nothing. A stream
456
+ * is about a record, and that record lives in exactly one tenant's database, so
457
+ * that is where its log goes.
458
+ *
459
+ * A store passed in explicitly is the caller saying they have answered the
460
+ * question a different way, and it stays one store for the deployment.
461
+ */
462
+ const declaredStreams = o.streamStore ? createStreams(o.streamStore) : null;
463
+ const noDatabaseStreams = o.streamStore || o.adapter ? null : createStreams(memoryStreamStore());
464
+ const streamsFor = (adapter) => declaredStreams ?? (adapter ? streamsOf(adapter) : noDatabaseStreams);
282
465
  const configuredMounts = o.assets ? (Array.isArray(o.assets) ? o.assets : [o.assets]) : [];
283
466
  // Browser-safe ketjs-view output is framework infrastructure, like /_ket/fn:
284
467
  // callers should not need to find and mount a transitive package themselves.
@@ -297,17 +480,38 @@ export async function createKetServer(o) {
297
480
  return o.islandClients(url, req);
298
481
  return o.islandClients ?? theme?.clients ?? {};
299
482
  };
300
- const streams = await createStreams(o.streamStore ?? (o.adapter ? dbStreamStore(o.adapter) : memoryStreamStore()));
483
+ const tenantForLog = (url, req) => {
484
+ try {
485
+ return o.resolveDatastore?.(url, req) ?? null;
486
+ }
487
+ catch {
488
+ return null;
489
+ }
490
+ };
301
491
  const server = createServer(async (req, res) => {
302
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
492
+ const started = Date.now();
493
+ // The route pattern, never the pathname: a raw path carries record ids and a
494
+ // query string, which is how customer data reaches a log aggregator. An
495
+ // unmatched path stays unnamed for the same reason — the status already says
496
+ // what happened, and the string a stranger typed is not worth keeping.
497
+ let route = '(unmatched)';
498
+ let requestLog = o.log;
303
499
  try {
304
- // Static files. Several mounts, because the app has its own and every
305
- // installed module may ship some and a module's must stop being served the
306
- // moment it is switched off, which is what `resolve` is for: it answers per
307
- // request instead of being fixed when the server was built.
500
+ const url = requestUrl(req);
501
+ // Which tenant this request belongs to, for attribution only.
502
+ //
503
+ // Guarded, and the guard is the point: `resolveDatastore` throws
504
+ // E_UNKNOWN_TENANT for a host this deployment does not serve, and static
505
+ // assets are answered before any tenant is needed. Letting that throw here
506
+ // would mean adding logging had changed what the server serves.
507
+ requestLog = o.log?.child({ tenant: tenantForLog(url, req) });
508
+ // Static files. Several mounts, because the deployment has its own and every
509
+ // composed module may ship some. `resolve` keeps the file lookup behind the
510
+ // same request boundary as every other route.
308
511
  for (const mount of mounts) {
309
512
  if (!url.pathname.startsWith(mount.prefix))
310
513
  continue;
514
+ route = 'asset';
311
515
  // Path traversal is the one thing a static handler must not get wrong.
312
516
  const rel = normalize(url.pathname.slice(mount.prefix.length)).replace(/^(\.\.[/\\])+/, '');
313
517
  const file = mount.dir !== undefined
@@ -317,10 +521,25 @@ export async function createKetServer(o) {
317
521
  : await mount.resolve(rel, url, req);
318
522
  if (file === null)
319
523
  break;
524
+ // A module publishes a *directory*, not a list of files, so anything
525
+ // else that lives in that directory went out with the assets: the TSX an
526
+ // island is authored in, a sourcemap with the original text inlined, a
527
+ // README. They were answered as `application/octet-stream` — a download
528
+ // rather than an error, which is worse than useless for source nobody
529
+ // meant to publish. `ASSET_MIME` is already the list of things a browser
530
+ // loads as a page asset, so it is the list of things that get served.
531
+ const type = assetType(rel);
532
+ if (type === undefined)
533
+ break;
320
534
  try {
321
535
  const body = await readFile(file);
322
- const type = ASSET_MIME[extname(rel)] ?? 'application/octet-stream';
323
- res.writeHead(200, { 'content-type': contentType(type), 'cache-control': 'no-cache' });
536
+ // A URL that names its own content can never go stale, so it is kept
537
+ // rather than revalidated. Anything else keeps the old answer: safe,
538
+ // and re-fetched every time, which is what versioning is for.
539
+ res.writeHead(200, {
540
+ 'content-type': contentType(type),
541
+ 'cache-control': isVersioned(rel) ? 'public, max-age=31536000, immutable' : 'no-cache',
542
+ });
324
543
  return res.end(body);
325
544
  }
326
545
  catch {
@@ -331,16 +550,63 @@ export async function createKetServer(o) {
331
550
  res.writeHead(404, { 'content-type': 'text/plain' });
332
551
  return res.end('not found');
333
552
  }
334
- const route = matchRoute(url.pathname);
335
- if (route) {
336
- const r = await route.value(url, req, route.params);
553
+ // Before anything expensive, including before a route is chosen: refusing
554
+ // after doing the work is a limit that costs what it was meant to save.
555
+ const policy = o.rateLimit?.(url, req);
556
+ if (policy) {
557
+ const verdict = await withDb(url, req, (adapter) => claimRateSlot(adapter, policy));
558
+ if (!verdict.ok) {
559
+ route = `rate:${policy.action}`;
560
+ requestLog?.log({
561
+ level: 'warn',
562
+ event: 'rate_limited',
563
+ fields: {
564
+ action: policy.action,
565
+ limit: policy.limit,
566
+ retryAfterMs: verdict.retryAfterMs,
567
+ method: req.method ?? '',
568
+ },
569
+ });
570
+ res.writeHead(429, {
571
+ 'content-type': 'application/json; charset=utf-8',
572
+ 'retry-after': String(Math.max(1, Math.ceil(verdict.retryAfterMs / 1_000))),
573
+ });
574
+ return res.end(JSON.stringify({ code: 'E_RATE_LIMITED', message: `too many "${policy.action}" requests` }));
575
+ }
576
+ }
577
+ const matched = matchRoute(url.pathname);
578
+ if (matched) {
579
+ route = matched.path;
580
+ const r = await matched.value(url, req, matched.params);
337
581
  return await send(res, r);
338
582
  }
339
- if (url.pathname === '/_ket/manifest')
583
+ // Framework endpoints name themselves: the paths are fixed, so there is no
584
+ // identifier to leak and no cardinality to worry about.
585
+ if (url.pathname === '/_ket/manifest') {
586
+ route = url.pathname;
340
587
  return json(res, 200, o.manifest);
341
- if (url.pathname === '/_ket/agent')
588
+ }
589
+ if (url.pathname === '/_ket/agent') {
590
+ route = url.pathname;
342
591
  return json(res, 200, agentDescriptor(o.manifest));
592
+ }
593
+ if (url.pathname === TOKENS_PATH) {
594
+ route = url.pathname;
595
+ const theme = await resolveTheme(url, req);
596
+ res.writeHead(theme ? 200 : 404, {
597
+ 'content-type': contentType(theme ? 'text/css' : 'text/plain'),
598
+ 'cache-control': 'no-cache',
599
+ });
600
+ if (!theme)
601
+ return res.end('not found');
602
+ // The site's own layer comes after the theme's, so a site that
603
+ // overrides nothing serves exactly what it served before.
604
+ const overrides = (await o.siteTokens?.(url, req)) ?? null;
605
+ const { safe } = partitionTokens(overrides ?? {});
606
+ return res.end(Object.keys(safe).length ? `${theme.tokensCss}\n${tokensToCss(safe, 'ket.app')}` : theme.tokensCss);
607
+ }
343
608
  if (url.pathname === '/_ket/islands.js') {
609
+ route = url.pathname;
344
610
  const theme = await resolveTheme(url, req);
345
611
  const clients = await resolveIslandClients(url, req, theme);
346
612
  res.writeHead(200, {
@@ -352,7 +618,14 @@ export async function createKetServer(o) {
352
618
  // Resumable stream: the client reconnects with ?from=<cursor> and gets
353
619
  // exactly what it missed, never a duplicate and never a gap.
354
620
  if (url.pathname.startsWith('/_ket/stream/')) {
355
- const id = url.pathname.slice('/_ket/stream/'.length);
621
+ route = '/_ket/stream';
622
+ const publicId = decodeRequestPath(url.pathname.slice('/_ket/stream/'.length));
623
+ // A public stream id is never a storage key by default. The resolver is the
624
+ // deployment's authentication and tenant boundary; omitting it keeps this
625
+ // framework endpoint closed, and null does not reveal whether a topic exists.
626
+ const id = await o.resolveStream?.(publicId, url, req);
627
+ if (!id)
628
+ return json(res, 404, { code: 'E_STREAM_NOT_FOUND', message: 'stream not found' });
356
629
  const from = Number(url.searchParams.get('from') ?? 0);
357
630
  res.writeHead(200, {
358
631
  'content-type': 'text/event-stream',
@@ -363,23 +636,41 @@ export async function createKetServer(o) {
363
636
  // moment that happens: the durable log keeps the chunks, and the next
364
637
  // connection resumes from its cursor.
365
638
  let open = true;
639
+ // Two things have to happen when the reader disappears: stop writing, and
640
+ // stop reading. The second is the one that matters here — the tail sleeps
641
+ // between reads, and a read that wakes up after the server has moved on
642
+ // finds a database that is closing.
643
+ const gone = new AbortController();
366
644
  const stop = () => {
367
645
  open = false;
646
+ gone.abort();
368
647
  };
369
648
  req.on('close', stop);
370
649
  res.on('close', stop);
371
- for await (const chunk of streams.tail(id, from, { timeoutMs: 30_000 })) {
650
+ // The lease is held for as long as the tail, which is what keeps this
651
+ // tenant's adapter from being evicted out from under a reader. It leases
652
+ // the datastore, not a connection: the reads inside take one and give it
653
+ // back like any other query.
654
+ return withDb(url, req, async (adapter) => {
655
+ const streams = await streamsFor(adapter);
656
+ for await (const chunk of streams.tail(id, from, {
657
+ timeoutMs: o.streamTimeoutMs ?? 30_000,
658
+ signal: gone.signal,
659
+ ...(o.streamPollMs === undefined ? {} : { pollMs: o.streamPollMs }),
660
+ })) {
661
+ if (!open || res.writableEnded)
662
+ return;
663
+ res.write(`id: ${chunk.seq}\ndata: ${JSON.stringify(chunk.data)}\n\n`);
664
+ }
372
665
  if (!open || res.writableEnded)
373
666
  return;
374
- res.write(`id: ${chunk.seq}\ndata: ${JSON.stringify(chunk.data)}\n\n`);
375
- }
376
- if (!open || res.writableEnded)
377
- return;
378
- res.write('event: done\ndata: {}\n\n');
379
- return res.end();
667
+ res.write('event: done\ndata: {}\n\n');
668
+ return res.end();
669
+ });
380
670
  }
381
671
  if (url.pathname.startsWith('/_ket/fn/') && req.method === 'POST') {
382
- const fnKey = decodeURIComponent(url.pathname.slice('/_ket/fn/'.length));
672
+ route = '/_ket/fn';
673
+ const fnKey = decodeRequestPath(url.pathname.slice('/_ket/fn/'.length));
383
674
  const meta = o.manifest.functions[fnKey];
384
675
  if (meta?.exposure === 'internal') {
385
676
  throw new KetError({
@@ -388,7 +679,10 @@ export async function createKetServer(o) {
388
679
  hint: 'call it from the trusted route that owns its security policy',
389
680
  });
390
681
  }
391
- const args = await readBody(req);
682
+ // Bound the untrusted body before authentication/session resolvers run. A
683
+ // caller must not be able to make those expensive paths retain an unbounded
684
+ // payload first, whether or not Content-Length was supplied.
685
+ const args = await readBody(req, maxJsonBodyBytes);
392
686
  // Resolved before the pool lease, so a session lookup never holds one.
393
687
  const scope = await o.resolveScope?.(url, req);
394
688
  const allow = await o.resolveAllow?.(url, req);
@@ -402,6 +696,7 @@ export async function createKetServer(o) {
402
696
  queueNotify: o.queueNotify,
403
697
  dryRun: url.searchParams.get('dryRun') === '1',
404
698
  idempotencyKey: req.headers['idempotency-key'] ?? null,
699
+ idempotencyNamespace: `fn:http:${scope?.company ?? 'none'}:${actor ?? 'anonymous'}`,
405
700
  }));
406
701
  return json(res, 200, result);
407
702
  }
@@ -412,27 +707,44 @@ export async function createKetServer(o) {
412
707
  return res.end('not found');
413
708
  }
414
709
  const scope = o.pageScope ? await o.pageScope(url, req) : {};
710
+ const privatePage = o.pagePrivate?.(url, req) === true;
415
711
  const pageRegion = o.pageRegion;
416
712
  if (pageRegion && isNavigationRequest(req)) {
417
713
  const page = scope['page'];
418
- return send(res, navigablePage(req, {
714
+ const fragment = navigablePage(req, {
419
715
  title: String(page?.title ?? ''),
420
716
  document: () => html ``,
421
717
  slots: {
422
718
  [pageRegion]: () => html `${trustedMarkup(theme.renderRegion(pageRegion, scope))}`,
423
719
  },
424
- }));
720
+ });
721
+ return send(res, privatePage
722
+ ? { ...fragment, headers: { ...fragment.headers, ...PRIVATE_PAGE_HEADERS } }
723
+ : fragment);
425
724
  }
426
- const fullHtml = bootstrapDocument(theme.renderRegion('layout', scope));
725
+ const fullHtml = bootstrapDocument(withThemeTokens(theme.renderRegion('layout', scope), theme.tokensCss));
427
726
  res.writeHead(200, {
428
727
  'content-type': 'text/html; charset=utf-8',
429
728
  ...(o.pageRegion ? { vary: 'X-Ket-Navigation' } : {}),
729
+ ...(privatePage ? PRIVATE_PAGE_HEADERS : {}),
430
730
  });
431
731
  return res.end(fullHtml);
432
732
  }
433
733
  return json(res, 404, { code: 'E_NOT_FOUND', message: `no route for ${url.pathname}` });
434
734
  }
435
735
  catch (e) {
736
+ // Anything that is not a named contract failure escaped a `try` nobody wrote,
737
+ // and so did a failure that arrived after the headers went out. Both used to
738
+ // be discarded here: the client got a message and the server kept nothing.
739
+ if (!(e instanceof KetError) || res.headersSent) {
740
+ requestLog?.log({
741
+ level: 'error',
742
+ event: 'unhandled',
743
+ durationMs: Date.now() - started,
744
+ error: e,
745
+ fields: { method: req.method ?? '', route, headersSent: res.headersSent },
746
+ });
747
+ }
436
748
  // A streaming response has already sent its headers; there is no status code
437
749
  // left to send, so the only honest thing is to close the socket.
438
750
  if (res.headersSent) {
@@ -440,14 +752,38 @@ export async function createKetServer(o) {
440
752
  res.destroy(e);
441
753
  return;
442
754
  }
755
+ if (e instanceof FormValidationError)
756
+ return json(res, 422, e.toJSON());
757
+ const status = e instanceof KetError ? statusForError(e.code) : 500;
758
+ const code = e instanceof KetError ? e.code : 'E_INTERNAL';
759
+ if (o.renderErrorPage && wantsHtml(req)) {
760
+ // A page that fails to render an error is worse than the error, so the
761
+ // fallback is the JSON that was always there.
762
+ const page = await o.renderErrorPage({ code, status, url: requestUrl(req), req }).catch(() => null);
763
+ if (page !== null) {
764
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
765
+ return res.end(page);
766
+ }
767
+ }
443
768
  if (e instanceof KetError)
444
- return json(res, e.code === 'E_PAYLOAD_TOO_LARGE' ? 413 : 400, e.toJSON());
769
+ return json(res, status, e.toJSON());
445
770
  return json(res, 500, { code: 'E_INTERNAL', message: e.message });
446
771
  }
772
+ finally {
773
+ requestLog?.log({
774
+ // A stylesheet is not an application event. One page load is dozens of
775
+ // asset requests, and at info they would be most of what a deployment
776
+ // says about itself.
777
+ level: route === 'asset' ? 'debug' : 'info',
778
+ event: 'http_request',
779
+ durationMs: Date.now() - started,
780
+ fields: { method: req.method ?? '', route, status: res.statusCode },
781
+ });
782
+ }
447
783
  });
448
784
  return {
449
785
  server,
450
- streams,
786
+ streams: await streamsFor(o.adapter ?? null),
451
787
  listen(port = o.port ?? 3000) {
452
788
  return new Promise((resolve) => server.listen(port, () => resolve(server.address().port)));
453
789
  },