@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,16 +1,16 @@
1
- // Booting an app: the sequence every deployment repeats, written once.
1
+ // Booting a deployment: the sequence every deployment repeats, written once.
2
2
  //
3
- // Before this, running a KetSuite-shaped app meant ~150 lines of hand-written boot
4
- // in the app itself — open a database, migrate, register functions, install a
5
- // bootstrap set, decide who the request is, build the theme, mount the framework's
3
+ // Before this, running a KetSuite-shaped deployment meant ~150 lines of hand-written boot
4
+ // in the deployment itself — open a database, migrate, register functions,
5
+ // decide who the request is, build the theme, mount the framework's
6
6
  // own routes, print something useful, shut down cleanly. Every one of those lines
7
- // is app-agnostic, and every second app would have copied them, drift included.
7
+ // is deployment-agnostic, and every second deployment would have copied them, drift included.
8
8
  //
9
- // What stays with the app is what only the app knows: which modules it ships, which
9
+ // What stays with the deployment is what only it knows: which modules it ships, which
10
10
  // function turns a path into a page, which extra routes it serves, and how to open
11
- // a datastore that is not SQLite. Those arrive through `AppSpec.serve` as data
11
+ // a datastore that is not SQLite. Those arrive through `DeploymentSpec.serve` as data
12
12
  // rather than as a closure the framework has to trust.
13
- import { createAppRegistry } from '../kernel/apps.js';
13
+ import { withoutVersion } from './assets.js';
14
14
  import { translator } from '../kernel/i18n.js';
15
15
  import { KetError } from '../kernel/errors.js';
16
16
  import { createTheme } from '../theme/render.js';
@@ -18,27 +18,32 @@ import { agentDescriptor } from '../agent/capabilities.js';
18
18
  import { migrateOne } from '../data/fleet.js';
19
19
  import { callFn } from './fn.js';
20
20
  import { createKetServer } from './http.js';
21
- import { createSessions, dbSessionStore } from './session.js';
21
+ import { createSessions, dbSessionStore, scopeForSession } from './session.js';
22
22
  import { createTenants, singleTenant } from './tenants.js';
23
23
  import { createJoints } from '../theme/joints.js';
24
24
  import { buildMenu } from '../kernel/menu.js';
25
25
  import { createAdapterPool } from '../data/pool.js';
26
26
  import { document, json, text, withHeaders } from './respond.js';
27
27
  import { join, isAbsolute } from 'node:path';
28
- import { html, each } from '@ketvietlab/ketjs-view';
28
+ import { html, each, renderToString } from '@ketvietlab/ketjs-view';
29
29
  import { sqliteStore } from './config.js';
30
30
  import { bootRuntime } from './runtime.js';
31
+ import { traceOf } from './log/index.js';
31
32
  import { namespacedStorage, storageFromConfig } from './storage/index.js';
33
+ import { randomBytes } from 'node:crypto';
32
34
  export { page, fragment, navigablePage, isNavigationRequest, text, bytes, streamed, raw, withHeaders, } from './respond.js';
33
35
  export { json } from './respond.js';
34
36
  /**
35
- * Opens, migrates, installs, serves. Returns before listening is announced so a
37
+ * Opens, migrates, and serves. Returns before listening is announced so a
36
38
  * caller can print its own banner, or a test can boot on port 0 and never print.
37
39
  */
38
- export async function bootApp(spec, o = {}) {
40
+ export async function bootDeployment(spec, o = {}) {
39
41
  const serve = spec.serve ?? {};
40
42
  const log = o.log ?? console.log;
41
- const { config, modules, manifest } = await bootRuntime(spec, o);
43
+ // `o.log` above is the boot-progress printer this function has always taken;
44
+ // `logSink` is the deployment's operational sink. Different things, and the
45
+ // older name is public API, so the new one is the one that gets qualified.
46
+ const { config, modules, manifest, log: logSink, logger } = await bootRuntime(spec, o);
42
47
  const baseStorage = await (serve.openStorage ?? storageFromConfig)(config);
43
48
  const storages = new Map();
44
49
  const storageFor = (key) => {
@@ -50,31 +55,15 @@ export async function bootApp(spec, o = {}) {
50
55
  }
51
56
  return storage;
52
57
  };
53
- /**
54
- * An empty database is not a useful one to look at, so a first run installs
55
- * enough to see something. A database that has been used is left exactly as it
56
- * is — and with tenants, "first run" is per tenant rather than per deployment.
57
- */
58
- const bootstrap = config.bootstrapApps ?? serve.bootstrap ?? [];
59
- const bootstrapInto = async (key, apps) => {
60
- if (!bootstrap.length || (await apps.enabled()).size !== 0)
61
- return;
62
- for (const name of bootstrap)
63
- await apps.install(name);
64
- log(` first run${key ? ` [${key}]` : ''}, installed: ${[...(await apps.enabled())].sort().join(', ')}`);
65
- };
66
58
  // Opened here only when there is one. With tenants there is no single datastore,
67
59
  // and a nullable field says that more honestly than a default one would.
68
60
  const adapter = serve.tenants ? null : await (serve.openStore ?? sqliteStore)(config);
69
- let apps = null;
70
61
  if (adapter) {
71
62
  if (config.migrateOnBoot) {
72
63
  const ops = await migrateOne(adapter, manifest);
73
64
  if (ops.length)
74
65
  log(` migrate: ${ops.length} operation(s)`);
75
66
  }
76
- apps = await createAppRegistry(manifest, adapter, { autoInstall: config.autoInstall });
77
- await bootstrapInto('', apps);
78
67
  }
79
68
  /**
80
69
  * How a request finds its database. One datastore is the degenerate case of the
@@ -89,33 +78,36 @@ export async function bootApp(spec, o = {}) {
89
78
  * also the isolation you want: a session id from one tenant simply is not in
90
79
  * another's table.
91
80
  *
92
- * An app serving every tenant from one domain cannot do that, because reading
93
- * the session needs the database and knowing the database needs the session. It
94
- * passes `sessions.store` instead one shared store, with the tenant recorded
95
- * on the session. Both work; the framework assumes neither.
81
+ * A deployment serving every tenant from one domain must still resolve the tenant
82
+ * before reading the cookie for example from a trusted gateway assertion, path,
83
+ * or explicit header. It may pass `sessions.store` as one shared identity store;
84
+ * every record is then tenant-bound, and a session never selects a datastore.
96
85
  */
97
86
  const sharedStore = serve.sessions?.store ?? null;
87
+ const configuredSessionSecret = serve.sessions?.secret || config.secret;
88
+ const generatedTenantSecret = serve.sessions && serve.tenants && !configuredSessionSecret ? randomBytes(32).toString('base64url') : null;
98
89
  const sessionOpts = serve.sessions
99
90
  ? {
100
- ...(config.secret ? { secret: config.secret } : {}),
101
91
  secure: config.host !== '127.0.0.1' && config.host !== 'localhost',
102
92
  ...serve.sessions,
93
+ ...(configuredSessionSecret ? { secret: configuredSessionSecret } : {}),
94
+ ...(generatedTenantSecret ? { secret: generatedTenantSecret, ephemeralSecret: true } : {}),
103
95
  }
104
96
  : null;
105
97
  const makeSessions = sessionOpts
106
- ? (a) => createSessions({ ...sessionOpts, store: sharedStore ?? dbSessionStore(a) })
98
+ ? (a, tenant) => createSessions({
99
+ ...sessionOpts,
100
+ ...(sharedStore && tenant !== undefined ? { tenant } : {}),
101
+ store: sharedStore ?? dbSessionStore(a),
102
+ })
107
103
  : null;
108
- // Single datastore: one Sessions, built now. Tenants: one per tenant, built on
109
- // first touch unless a shared store was supplied, in which case it is one
110
- // again and every tenant hands back the same instance.
104
+ // Single datastore: one Sessions, built now. Tenant deployments expose one
105
+ // lease-safe facade per tenant. Facades may share a backing store, but remain
106
+ // distinct so every record and administrative operation stays tenant-bound.
111
107
  const sessions = makeSessions && adapter ? await makeSessions(adapter) : null;
112
- // Built per tenant, because which templates exist depends on what is installed.
113
- const islandRegistry = (live) => {
114
- const disabled = new Set(live.disabledModules ?? []);
108
+ const islandRegistry = () => {
115
109
  const registry = {};
116
110
  for (const module of modules) {
117
- if (disabled.has(module.name))
118
- continue;
119
111
  for (const [name, definition] of Object.entries(module.islands))
120
112
  registry[name] = definition.view;
121
113
  }
@@ -132,21 +124,16 @@ export async function bootApp(spec, o = {}) {
132
124
  }),
133
125
  };
134
126
  // Fills are KTL, so they translate the way templates do.
135
- const jointFactory = (live, locale) => createJoints(live, { translate: translate(locale), islands: islandRegistry(live) });
127
+ const jointFactory = (live, locale) => createJoints(live, { translate: translate(locale), islands: islandRegistry() });
136
128
  const tenants = serve.tenants
137
129
  ? createTenants({
138
130
  spec: serve.tenants,
139
131
  pool: createAdapterPool({
140
- create: (key) => {
141
- const made = serve.tenants.open(key, config);
142
- // The pool wants an Adapter now; opening is the adapter's own job.
143
- return made;
144
- },
132
+ create: (key) => serve.tenants.open(key, config),
145
133
  ...(serve.tenants.max !== undefined ? { max: serve.tenants.max } : {}),
146
134
  ...(serve.tenants.idleMs !== undefined ? { idleMs: serve.tenants.idleMs } : {}),
147
135
  }),
148
136
  manifest,
149
- autoInstall: config.autoInstall,
150
137
  ...(config.migrateOnBoot
151
138
  ? {
152
139
  prepare: async (key, a) => {
@@ -156,39 +143,72 @@ export async function bootApp(spec, o = {}) {
156
143
  },
157
144
  }
158
145
  : {}),
159
- onFirstTouch: (key, made) => bootstrapInto(key, made),
160
146
  ...(makeSessions ? { sessions: makeSessions } : {}),
161
147
  joints: jointFactory,
162
148
  ...themeFactory,
163
149
  })
164
150
  : singleTenant({
165
151
  adapter: adapter,
166
- apps: apps,
167
152
  manifest,
168
153
  joints: jointFactory,
169
154
  ...themeFactory,
170
155
  sessions,
171
156
  });
172
157
  /**
173
- * Sessions, when the app asks for them. Absent, the header shim stays and the
174
- * banner keeps saying so — an app that has not wired auth yet should look like
158
+ * Sessions, when the deployment asks for them. Absent, the header shim stays and the
159
+ * banner keeps saying so — a deployment that has not wired auth yet should look like
175
160
  * one rather than quietly appear to have it.
176
161
  */
177
162
  /** This request's tenant's sessions — the same instance for all, when shared. */
178
163
  const sessionsOf = (url, req) => sessions ? Promise.resolve(sessions) : tenants.ofRequest(url, req, async (t) => t.sessions);
179
164
  // One cookie lookup per request even though scope, permissions and actor all
180
165
  // depend on it. With tenant databases this also avoids three separate leases.
166
+ const authenticationEnabled = Boolean(makeSessions || serve.resolveIdentity);
181
167
  const sessionRecords = new WeakMap();
182
168
  const sessionRecordOf = (url, req) => {
183
- if (!makeSessions)
169
+ if (!authenticationEnabled)
184
170
  return Promise.resolve(null);
185
171
  let record = sessionRecords.get(req);
186
172
  if (!record) {
187
- record = sessionsOf(url, req).then(async (manager) => {
173
+ record = tenants.ofRequest(url, req, async (tenant) => {
174
+ const identity = await serve.resolveIdentity?.({
175
+ adapter: tenant.adapter,
176
+ manifest: tenant.live,
177
+ url,
178
+ req,
179
+ });
180
+ if (identity) {
181
+ const companies = [...new Set(identity.companies)];
182
+ if (!identity.userId ||
183
+ !companies.length ||
184
+ !identity.company ||
185
+ !companies.includes(identity.company))
186
+ return null;
187
+ const now = Date.now();
188
+ return {
189
+ id: `request:${identity.userId}`,
190
+ userId: identity.userId,
191
+ companies,
192
+ company: identity.company,
193
+ branch: identity.branch ?? null,
194
+ branches: identity.branches ?? null,
195
+ securityVersion: identity.securityVersion ?? 0,
196
+ revision: 0,
197
+ createdAt: now,
198
+ expiresAt: now,
199
+ };
200
+ }
201
+ const manager = await sessionsOf(url, req);
188
202
  const raw = (await manager?.of(req)) ?? null;
189
203
  if (!raw || !manager || !serve.resolveSession)
190
204
  return raw;
191
- const resolved = await tenants.ofRequest(url, req, (tenant) => serve.resolveSession({ adapter: tenant.adapter, manifest: tenant.live, record: raw, url, req }));
205
+ const resolved = await serve.resolveSession({
206
+ adapter: tenant.adapter,
207
+ manifest: tenant.live,
208
+ record: raw,
209
+ url,
210
+ req,
211
+ });
192
212
  if (!resolved) {
193
213
  await manager.store.destroy(raw.id);
194
214
  return null;
@@ -221,22 +241,30 @@ export async function bootApp(spec, o = {}) {
221
241
  * a session id issued by one tenant is not a row in another's table at all.
222
242
  */
223
243
  const scopeOf = async (url, req) => {
224
- if (!makeSessions) {
244
+ if (!authenticationEnabled) {
225
245
  const list = (h) => (req.headers[h] ?? '')
226
246
  .split(',')
227
247
  .map((s) => s.trim())
228
248
  .filter(Boolean);
229
249
  const company = req.headers['x-ket-company'] ?? config.defaultCompany;
230
250
  const companies = list('x-ket-companies');
251
+ const branches = list('x-ket-branch');
231
252
  return {
232
253
  company,
233
254
  companies: companies.length ? [...new Set([company, ...companies])] : null,
234
255
  branch: req.headers['x-ket-current-branch'] ?? null,
235
- branches: list('x-ket-branch') || null,
256
+ // An absent development header means unrestricted, matching Scope's
257
+ // null/undefined contract. `[]` is reserved for callers that explicitly
258
+ // construct a scope with no readable branches.
259
+ branches: branches.length ? branches : null,
236
260
  };
237
261
  }
238
- const s = await sessionsOf(url, req);
239
- return s?.scopeOf(await sessionRecordOf(url, req)) ?? { company: null };
262
+ const record = await sessionRecordOf(url, req);
263
+ if (!record) {
264
+ const s = await sessionsOf(url, req);
265
+ return s?.scopeOf(null) ?? { company: null };
266
+ }
267
+ return scopeForSession(record) ?? { company: null };
240
268
  };
241
269
  /**
242
270
  * A locale is only ever one the deployment ships a catalogue for.
@@ -251,30 +279,71 @@ export async function bootApp(spec, o = {}) {
251
279
  config.defaultLocale,
252
280
  config.fallbackLocale,
253
281
  ]);
282
+ const requestLocales = new WeakMap();
254
283
  const localeOf = (url, req) => {
255
- const asked = [
256
- url.searchParams.get('lang'),
257
- ...(req.headers['accept-language'] ?? '')
258
- .split(',')
259
- .map((part) => part.split(';')[0]?.trim())
260
- .flatMap((tag) => (tag ? [tag, tag.split('-')[0]] : [])),
261
- ];
262
- return asked.find((l) => l && known.has(l)) ?? config.defaultLocale;
284
+ const query = url.searchParams.get('lang');
285
+ const header = req.headers['accept-language'] ?? '';
286
+ const held = requestLocales.get(req);
287
+ if (held?.query === query && held.header === header)
288
+ return held.locale;
289
+ let locale = query && known.has(query) ? query : null;
290
+ if (!locale) {
291
+ for (const part of header.split(',')) {
292
+ const tag = part.split(';')[0]?.trim();
293
+ if (!tag)
294
+ continue;
295
+ if (known.has(tag)) {
296
+ locale = tag;
297
+ break;
298
+ }
299
+ const base = tag.split('-')[0];
300
+ if (base && known.has(base)) {
301
+ locale = base;
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ const resolved = locale ?? config.defaultLocale;
307
+ requestLocales.set(req, { query, header, locale: resolved });
308
+ return resolved;
309
+ };
310
+ const translators = new Map();
311
+ const translate = (locale) => {
312
+ const cached = translators.get(locale);
313
+ if (cached)
314
+ return cached;
315
+ const made = Object.freeze(translator(manifest, locale, { fallback: config.fallbackLocale }));
316
+ if (known.has(locale))
317
+ translators.set(locale, made);
318
+ return made;
263
319
  };
264
- const translate = (locale) => translator(manifest, locale, { fallback: config.fallbackLocale });
265
320
  /**
266
- * Every installed module's stylesheets, in dependency order, so a module that
267
- * extends another loads after it and can override it. The app used to name two
268
- * files belonging to another module by hand which meant knowing that module's
269
- * file layout, and going on linking them after it was uninstalled.
321
+ * Every composed module's stylesheets, in dependency order, so a module that
322
+ * extends another loads after it and can override it. A deployment does not
323
+ * name another module's files by hand, so it never needs to know that module's
324
+ * internal file layout.
270
325
  */
271
- const styles = async (req) => tenants.ofRequest(new URL('http://x/'), req, async (t) => {
272
- const on = await t.apps.enabled();
273
- const hrefs = manifest.styles.filter((s) => on.has(s.by)).map((s) => s.href);
274
- return html `${each(hrefs, (h) => h, (h) => html `<link rel="stylesheet" href=${h}>`)}`;
326
+ const styles = async (_req) => html `${each(manifest.styles.map((style) => style.href), (href) => href, (href) => html `<link rel="stylesheet" href=${href}>`)}`;
327
+ /**
328
+ * The context a call's records carry.
329
+ *
330
+ * Correlation and actor are hashed here rather than by the sink, so a raw value
331
+ * never becomes a record in the first place: the framework does not export those,
332
+ * and a log aggregator is an export.
333
+ */
334
+ const callLog = (tenant, scope, actor, correlationId) => logger.child({
335
+ // The tenant the lease already resolved, rather than resolving it a second
336
+ // time: `keyOf` throws for a host this deployment does not serve, and a
337
+ // logger must never be the thing that decides a request fails.
338
+ tenant: tenant || null,
339
+ trace: traceOf(correlationId, config.secret),
340
+ actor: traceOf(actor, config.secret),
341
+ company: scope.company,
275
342
  });
276
343
  const ctx = {
277
344
  manifest,
345
+ deploymentName: spec.name,
346
+ clientCompatibility: serve.clientCompatibility ?? null,
278
347
  config,
279
348
  scopeOf,
280
349
  localeOf,
@@ -291,11 +360,37 @@ export async function bootApp(spec, o = {}) {
291
360
  // The sidebar's search is in the URL like every other list's, so a filtered
292
361
  // menu is a link and the back button walks out of it.
293
362
  const q = url.searchParams.get('menu')?.trim() || undefined;
294
- return tenants.ofRequest(url, req, async (t) => buildMenu(t.live, { allow, translate: (k) => _(k), active: url.pathname, q }));
363
+ // Someone who may call an inspection capability is looking, not working, and
364
+ // `for` describes work. Narrowing their sidebar would hide the very thing
365
+ // they were let in to see.
366
+ const inspecting = allow !== null && (spec.navigation?.audit ?? []).some((key) => allow.includes(key));
367
+ return tenants.ofRequest(url, req, async (t) => buildMenu(t.live, {
368
+ allow,
369
+ translate: (k) => _(k),
370
+ locale: _.locale,
371
+ active: url.pathname,
372
+ q,
373
+ groups: spec.navigation?.groups,
374
+ demote: spec.navigation?.demote,
375
+ // Searching is how someone reaches a surface that is not their daily
376
+ // work, so the search results are the permitted tree, not the narrowed
377
+ // one. Hiding what a person typed the name of would be a bug.
378
+ intent: !inspecting && !q,
379
+ }));
380
+ },
381
+ reportsOf: async (url, req, target) => {
382
+ const allow = await allowFor(url, req);
383
+ return tenants.ofRequest(url, req, async (t) => Object.values(t.live.reports).filter((report) => t.live.routes['/reports/{report}/{id}'] !== undefined &&
384
+ report.target === target &&
385
+ (allow === null || allow.includes(report.source))));
386
+ },
387
+ navigation: spec.navigation ?? null,
388
+ allows: async (name, url, req) => {
389
+ const allow = await allowFor(url, req);
390
+ return allow === null || allow.includes(name);
295
391
  },
296
392
  live: (req) => tenants.ofRequest(new URL('http://x/'), req, async (t) => t.live),
297
- appsOf: (req) => tenants.ofRequest(new URL('http://x/'), req, (t) => t.apps.list()),
298
- callUnchecked: async (name, input, url, req) => {
393
+ callUnchecked: async (name, input, url, req, options) => {
299
394
  const scope = await scopeOf(url, req);
300
395
  const actor = await actorOf(url, req);
301
396
  return tenants.ofRequest(url, req, async (t) => (await callFn(name, input, {
@@ -303,10 +398,39 @@ export async function bootApp(spec, o = {}) {
303
398
  manifest: t.live,
304
399
  scope,
305
400
  actor,
401
+ idempotencyKey: options?.idempotencyKey,
402
+ idempotencyNamespace: options?.idempotencyNamespace,
403
+ idempotencyDigest: options?.idempotencyDigest,
404
+ correlationId: options?.correlationId,
405
+ queueNotify: config.queueNotify,
406
+ log: callLog(t.key, scope, actor, options?.correlationId),
407
+ })).value);
408
+ },
409
+ callUncheckedForVerifiedCompany: async (name, input, companyId, url, req, options) => {
410
+ const company = companyId.trim();
411
+ if (!company)
412
+ throw new KetError({
413
+ code: 'E_VERIFIED_COMPANY_REQUIRED',
414
+ module: 'server',
415
+ message: 'a verified company is required for company-scoped dispatch',
416
+ hint: 'authenticate the external credential and derive one company before dispatching the function',
417
+ });
418
+ const actor = await actorOf(url, req);
419
+ const verifiedScope = { company, companies: [company], branch: null, branches: null };
420
+ return tenants.ofRequest(url, req, async (t) => (await callFn(name, input, {
421
+ adapter: t.adapter,
422
+ manifest: t.live,
423
+ scope: verifiedScope,
424
+ actor,
425
+ idempotencyKey: options?.idempotencyKey,
426
+ idempotencyNamespace: options?.idempotencyNamespace,
427
+ idempotencyDigest: options?.idempotencyDigest,
428
+ correlationId: options?.correlationId,
306
429
  queueNotify: config.queueNotify,
430
+ log: callLog(t.key, verifiedScope, actor, options?.correlationId),
307
431
  })).value);
308
432
  },
309
- call: async (name, input, url, req) => {
433
+ call: async (name, input, url, req, options) => {
310
434
  // One lease for the whole call: the scope and the allow-list are resolved
311
435
  // outside it, so a session lookup never holds a pooled connection.
312
436
  const scope = await scopeOf(url, req);
@@ -318,17 +442,19 @@ export async function bootApp(spec, o = {}) {
318
442
  scope,
319
443
  allow,
320
444
  actor,
445
+ idempotencyKey: options?.idempotencyKey,
446
+ idempotencyNamespace: options?.idempotencyNamespace,
447
+ idempotencyDigest: options?.idempotencyDigest,
448
+ correlationId: options?.correlationId,
321
449
  queueNotify: config.queueNotify,
450
+ log: callLog(t.key, scope, actor, options?.correlationId),
322
451
  })).value);
323
452
  },
324
453
  };
325
454
  /**
326
455
  * Module-contributed routes and assets.
327
456
  *
328
- * Both are looked up per request against the LIVE manifest rather than mounted
329
- * once at boot. That costs a set lookup and buys the property the app model
330
- * claims: switching a module off stops its routes answering and stops its
331
- * stylesheet being served, without a restart.
457
+ * Both are mounted from the immutable deployment manifest.
332
458
  */
333
459
  const routeHandlers = new Map();
334
460
  for (const [path, entry] of Object.entries(manifest.routes))
@@ -336,16 +462,10 @@ export async function bootApp(spec, o = {}) {
336
462
  const moduleRoutes = {};
337
463
  for (const [path, entry] of Object.entries(manifest.routes)) {
338
464
  moduleRoutes[path] = async (url, req, params) => {
339
- const on = await tenants.ofRequest(url, req, (t) => t.apps.enabled());
340
- if (!on.has(entry.by)) {
341
- return text(`${path} belongs to "${entry.by}", which is not installed on this database`, {
342
- status: 404,
343
- });
344
- }
345
465
  // Closed unless the module said otherwise. A browser is sent to the sign-in
346
466
  // page carrying where it was going; anything else gets the status, because a
347
467
  // redirect to an HTML form is a useless answer to a fetch().
348
- if (makeSessions && !entry.anonymous) {
468
+ if (authenticationEnabled && !entry.anonymous) {
349
469
  if (!(await sessionRecordOf(url, req))) {
350
470
  const wantsHtml = String(req.headers.accept ?? '').includes('text/html');
351
471
  return wantsHtml
@@ -359,23 +479,20 @@ export async function bootApp(spec, o = {}) {
359
479
  };
360
480
  }
361
481
  /**
362
- * A module's assets, resolved per request so that switching the module off stops
363
- * them being served — without a restart, and without the app knowing where any
364
- * module keeps its files.
482
+ * A module's assets, resolved without exposing module file layout to the deployment.
365
483
  */
366
484
  const assetMount = {
367
485
  prefix: '/_ket/asset/',
368
- resolve: async (rest, url, req) => {
486
+ resolve: async (rest, _url, _req) => {
369
487
  const slash = rest.indexOf('/');
370
488
  if (slash <= 0)
371
489
  return null;
372
490
  const owner = rest.slice(0, slash);
373
- const file = rest.slice(slash + 1);
491
+ // A version segment names the bytes, not a directory — see assets.ts.
492
+ const file = withoutVersion(rest.slice(slash + 1));
374
493
  const dir = manifest.assets[owner];
375
494
  if (!dir || !file || file.startsWith('..') || isAbsolute(file))
376
495
  return null;
377
- if (!(await tenants.ofRequest(url, req, (t) => t.apps.enabled())).has(owner))
378
- return null;
379
496
  return join(dir, file);
380
497
  },
381
498
  };
@@ -391,30 +508,67 @@ export async function bootApp(spec, o = {}) {
391
508
  .filter(([, f]) => f.anonymous)
392
509
  .map(([k]) => k);
393
510
  const allowFor = async (url, req) => {
394
- if (!makeSessions)
511
+ if (!authenticationEnabled)
395
512
  return null; // no login exists yet; the shim is the identity
513
+ const audience = await serve.resolveAudience?.(url, req);
514
+ const customAudience = Boolean(audience && audience !== 'anonymous' && audience !== 'staff');
396
515
  const record = await sessionRecordOf(url, req);
397
- if (!record)
516
+ if (!record) {
398
517
  return anonymousFns; // a stranger, not an administrator
518
+ }
519
+ // A custom bearer audience is fail-closed unless the deployment explicitly
520
+ // maps it to exact functions. This lets Channel routes call their domain
521
+ // functions without turning a POS/customer token into a staff session.
399
522
  if (!serve.permissions)
400
- return null;
401
- const granted = await serve.permissions(ctx, record.userId);
402
- return granted === null ? null : [...new Set([...anonymousFns, ...granted])];
523
+ return customAudience ? [] : null;
524
+ const granted = await serve.permissions(ctx, record.userId, url, req);
525
+ return granted === null ? (customAudience ? [] : null) : [...new Set([...anonymousFns, ...granted])];
403
526
  };
404
527
  const pages = serve.pages;
528
+ const previewPath = pages?.previewPath ?? '/_ket/preview';
529
+ /**
530
+ * A preview is a request at that one path carrying a token, and only when the
531
+ * deployment named something to answer it — otherwise the path is an ordinary
532
+ * miss rather than a preview that silently renders nothing.
533
+ */
534
+ const isPreviewRequest = (url) => !!pages?.previewResolve && url.pathname === previewPath && !!url.searchParams.get('token');
405
535
  if (pages && !manifest.functions[pages.resolve]) {
406
536
  throw new KetError({
407
537
  code: 'E_PAGE_RESOLVER_MISSING',
408
538
  module: spec.name,
409
- message: `app "${spec.name}" resolves pages with "${pages.resolve}", which no installed module declares`,
410
- hint: `add the module that owns "${pages.resolve.split('.')[0]}" to the app, or drop serve.pages`,
539
+ message: `deployment "${spec.name}" resolves pages with "${pages.resolve}", which no composed module declares`,
540
+ hint: `add the module that owns "${pages.resolve.split('.')[0]}" to the deployment, or drop serve.pages`,
541
+ });
542
+ }
543
+ if (pages?.previewResolve && !manifest.functions[pages.previewResolve]) {
544
+ throw new KetError({
545
+ code: 'E_PREVIEW_RESOLVER_MISSING',
546
+ module: spec.name,
547
+ message: `deployment "${spec.name}" resolves previews with "${pages.previewResolve}", which no composed module declares`,
548
+ hint: `add the module that owns "${pages.previewResolve.split('.')[0]}", or drop serve.pages.previewResolve`,
549
+ });
550
+ }
551
+ if (pages?.previewPath && !pages.previewPath.startsWith('/_ket/')) {
552
+ throw new KetError({
553
+ code: 'E_PREVIEW_PATH_UNRESERVED',
554
+ module: spec.name,
555
+ message: `deployment "${spec.name}" serves previews at "${pages.previewPath}", which a published page could claim`,
556
+ hint: 'keep the preview path under /_ket/, the namespace no page may be published in',
557
+ });
558
+ }
559
+ if (pages?.menuResolve && !manifest.functions[pages.menuResolve]) {
560
+ throw new KetError({
561
+ code: 'E_MENU_RESOLVER_MISSING',
562
+ module: spec.name,
563
+ message: `deployment "${spec.name}" resolves navigation with "${pages.menuResolve}", which no composed module declares`,
564
+ hint: `add the module that owns "${pages.menuResolve.split('.')[0]}", or drop serve.pages.menuResolve`,
411
565
  });
412
566
  }
413
567
  if (pages?.siteResolve && !manifest.functions[pages.siteResolve]) {
414
568
  throw new KetError({
415
569
  code: 'E_SITE_RESOLVER_MISSING',
416
570
  module: spec.name,
417
- message: `app "${spec.name}" resolves sites with "${pages.siteResolve}", which no installed module declares`,
571
+ message: `deployment "${spec.name}" resolves sites with "${pages.siteResolve}", which no composed module declares`,
418
572
  });
419
573
  }
420
574
  for (const selected of availableThemes)
@@ -422,7 +576,7 @@ export async function bootApp(spec, o = {}) {
422
576
  throw new KetError({
423
577
  code: 'E_PAGE_REGION_MISSING',
424
578
  module: spec.name,
425
- message: `app "${spec.name}" navigates through region "${pages.region}", which theme "${selected.name}" does not render`,
579
+ message: `deployment "${spec.name}" navigates through region "${pages.region}", which theme "${selected.name}" does not render`,
426
580
  hint: `add a "${pages.region}" template, or remove serve.pages.region to keep full navigation`,
427
581
  });
428
582
  }
@@ -453,16 +607,25 @@ export async function bootApp(spec, o = {}) {
453
607
  throw new KetError({
454
608
  code: 'E_ROUTE_RESERVED',
455
609
  module: spec.name,
456
- message: `app "${spec.name}" claims "${path}", which is reserved`,
610
+ message: `deployment "${spec.name}" claims "${path}", which is reserved`,
457
611
  hint: '/_ket/ belongs to the framework: health, the agent descriptor, streams and assets',
458
612
  });
459
613
  }
614
+ const reservation = Object.entries(manifest.routePrefixes).find(([prefix]) => path.startsWith(prefix));
615
+ if (reservation) {
616
+ throw new KetError({
617
+ code: 'E_ROUTE_RESERVED',
618
+ module: spec.name,
619
+ message: `deployment "${spec.name}" claims "${path}", inside the prefix reserved by "${reservation[1]}"`,
620
+ hint: 'reserved API routes must be declared by modules through the published route factory',
621
+ });
622
+ }
460
623
  const owner = manifest.routes[path]?.by;
461
624
  if (owner) {
462
625
  throw new KetError({
463
626
  code: 'E_ROUTE_CLASH',
464
627
  module: spec.name,
465
- message: `module "${owner}" and app "${spec.name}" both serve "${path}"`,
628
+ message: `module "${owner}" and deployment "${spec.name}" both serve "${path}"`,
466
629
  hint: 'two owners cannot share one path — rename one, or keep the route in its module',
467
630
  });
468
631
  }
@@ -470,6 +633,15 @@ export async function bootApp(spec, o = {}) {
470
633
  const server = await createKetServer({
471
634
  manifest,
472
635
  adapter,
636
+ log: logger,
637
+ ...(serve.streamStore ? { streamStore: serve.streamStore } : {}),
638
+ ...(serve.streamTimeoutMs === undefined ? {} : { streamTimeoutMs: serve.streamTimeoutMs }),
639
+ ...(serve.streamPollMs === undefined ? {} : { streamPollMs: serve.streamPollMs }),
640
+ ...(serve.resolveStream
641
+ ? { resolveStream: (id, url, req) => serve.resolveStream(ctx, id, url, req) }
642
+ : {}),
643
+ ...(serve.maxJsonBodyBytes !== undefined ? { maxJsonBodyBytes: serve.maxJsonBodyBytes } : {}),
644
+ ...(serve.rateLimit ? { rateLimit: (url, req) => serve.rateLimit(ctx, url, req) } : {}),
473
645
  /**
474
646
  * The HTTP layer gets a pool whose leases go through the tenant runtime, not
475
647
  * the raw one.
@@ -489,6 +661,66 @@ export async function bootApp(spec, o = {}) {
489
661
  resolveDatastore: (url, req) => tenants.keyOf(url, req),
490
662
  }
491
663
  : {}),
664
+ /**
665
+ * The page a person gets when the answer is no.
666
+ *
667
+ * A permission failure used to hand a browser the same JSON a client gets:
668
+ * a code, a function key and a hint about a CLI, on a bare page with no way
669
+ * back. That is a fine answer for a program and a dead end for a person, and
670
+ * the person is the one who arrived by clicking something.
671
+ *
672
+ * The markup uses the same `data-ui` hooks the design system already styles,
673
+ * and loads the deployment's own stylesheets, so this is the product's error
674
+ * screen rather than a second visual language living in the framework.
675
+ */
676
+ renderErrorPage: async ({ code, status, url, req }) => {
677
+ const _ = translate(localeOf(url, req));
678
+ const text = (key, fallback) => {
679
+ const found = _(key);
680
+ return found && found !== key ? found : fallback;
681
+ };
682
+ const kind = code === 'E_FN_NOT_PERMITTED' ? 'forbidden' : status === 404 ? 'missing' : 'failed';
683
+ const copy = {
684
+ forbidden: {
685
+ title: text('backend.error.forbidden.title', 'Bạn không có quyền mở màn hình này'),
686
+ message: text('backend.error.forbidden.message', 'Tài khoản của bạn không được cấp quyền cho màn hình này. Nếu đây là việc bạn cần làm, hãy đề nghị quản trị viên cấp thêm quyền.'),
687
+ },
688
+ missing: {
689
+ title: text('backend.error.missing.title', 'Không tìm thấy màn hình này'),
690
+ message: text('backend.error.missing.message', 'Đường dẫn này không còn tồn tại, hoặc chưa bao giờ tồn tại trong bản triển khai đang chạy.'),
691
+ },
692
+ failed: {
693
+ title: text('backend.error.failed.title', 'Màn hình này không mở được'),
694
+ message: text('backend.error.failed.message', 'Đã có lỗi khi dựng màn hình. Thử lại; nếu vẫn vậy, gửi mã lỗi bên dưới cho người phụ trách hệ thống.'),
695
+ },
696
+ }[kind];
697
+ const back = text('backend.error.back', 'Quay lại trang đầu');
698
+ // Only hooks the design system already declares and styles. The one rule
699
+ // below centres the block on an otherwise empty page, and is inline because
700
+ // an error page has to render even when a stylesheet is what went wrong.
701
+ const head = html `${await styles(req)}<style>
702
+ .ket-error-page {
703
+ display: grid;
704
+ min-block-size: 100dvh;
705
+ place-items: center;
706
+ padding: 2rem;
707
+ }
708
+ </style>`;
709
+ const body = html `<main class="ket-error-page">
710
+ <div data-ui="error" role="alert">
711
+ <p data-ui="error-code">${code}</p>
712
+ <p data-ui="error-message">${copy.title}</p>
713
+ <p data-ui="error-hint">${copy.message}</p>
714
+ <p>
715
+ <a data-ui="action" data-variant="primary" data-size="default" href="/admin">
716
+ <span data-ui="action-label">${back}</span>
717
+ </a>
718
+ </p>
719
+ </div>
720
+ </main>`;
721
+ // Same shape every other document takes: doctype, then the rendered tree.
722
+ return `<!doctype html>${renderToString(ctx.document({ lang: localeOf(url, req), title: copy.title, head, body }))}`;
723
+ },
492
724
  resolveLocale: localeOf,
493
725
  resolveScope: scopeOf,
494
726
  resolveAllow: allowFor,
@@ -510,7 +742,7 @@ export async function bootApp(spec, o = {}) {
510
742
  return null;
511
743
  const chosen = site?.theme ?? fallbackTheme.name;
512
744
  const allowed = availableThemes.find((theme) => theme.name === chosen);
513
- if (!allowed || t.live.disabledModules?.includes(allowed.name))
745
+ if (!allowed)
514
746
  return null;
515
747
  const locale = site?.locale ?? localeOf(url, req);
516
748
  const key = `${t.key}::${t.live.order.join(',')}::${allowed.name}::${locale}`;
@@ -529,12 +761,20 @@ export async function bootApp(spec, o = {}) {
529
761
  * The storefront: a path becomes a page, and a page becomes its sections.
530
762
  *
531
763
  * The lookup runs through callFn like anything else, so the company filter and
532
- * the app-installed check apply to a public page exactly as they do to an API
764
+ * the composed manifest applies to a public page exactly as it does to an API
533
765
  * call — the front of the site is not a second door with different rules.
534
766
  */
535
767
  ...(pages
536
768
  ? {
537
769
  ...(pages.region ? { pageRegion: pages.region } : {}),
770
+ pagePrivate: (url) => isPreviewRequest(url),
771
+ siteTokens: async (url, req) => {
772
+ const resolved = await siteOf(url, req);
773
+ const tokens = resolved?.tokens;
774
+ return tokens && typeof tokens === 'object' && !Array.isArray(tokens)
775
+ ? tokens
776
+ : null;
777
+ },
538
778
  pageScope: async (url, req) => {
539
779
  const resolvedSite = await siteOf(url, req);
540
780
  const site = {
@@ -546,7 +786,11 @@ export async function bootApp(spec, o = {}) {
546
786
  // It was hardcoded there, which made i18n untrue on the first tag of every
547
787
  // storefront page.
548
788
  const locale = resolvedSite?.locale ?? localeOf(url, req);
549
- const row = (await ctx.call(pages.resolve, { path: url.pathname, ...(resolvedSite?.id ? { siteId: resolvedSite.id } : {}) }, url, req));
789
+ // A preview reads by token, not by path: the draft has no address
790
+ // on the site yet, which is the whole reason a link is needed.
791
+ const row = (isPreviewRequest(url)
792
+ ? await ctx.call(pages.previewResolve, { token: url.searchParams.get('token') ?? '' }, url, req)
793
+ : await ctx.call(pages.resolve, { path: url.pathname, ...(resolvedSite?.id ? { siteId: resolvedSite.id } : {}) }, url, req));
550
794
  if (!row) {
551
795
  const _ = translate(locale);
552
796
  return {
@@ -556,11 +800,22 @@ export async function bootApp(spec, o = {}) {
556
800
  sections: [],
557
801
  };
558
802
  }
803
+ // Navigation belongs to the site, not to the page, so it is resolved
804
+ // beside it rather than carried by it. The framework names no
805
+ // module: the deployment says which function answers, the way it
806
+ // already does for the site and the page.
807
+ const menu = pages.menuResolve && resolvedSite?.id
808
+ ? ((await ctx.call(pages.menuResolve, { siteId: resolvedSite.id }, url, req)) ?? [])
809
+ : [];
559
810
  return {
560
811
  site,
561
812
  locale,
562
- page: { id: row.id, path: url.pathname, title: row.title },
563
- meta: {},
813
+ menu,
814
+ page: { id: row.id, path: row.path ?? url.pathname, title: row.title },
815
+ // Whatever the resolver says describes this page. The framework
816
+ // does not name the fields — a module owns them and decides what
817
+ // is public; this only stops hardcoding the answer to "nothing".
818
+ meta: row.meta ?? {},
564
819
  sections: typeof row.layout === 'string' ? JSON.parse(row.layout) : row.layout,
565
820
  };
566
821
  },
@@ -569,16 +824,13 @@ export async function bootApp(spec, o = {}) {
569
824
  routes: {
570
825
  ...moduleRoutes,
571
826
  ...appRoutes,
572
- // The framework's own two, mounted last so an app cannot shadow them by accident.
573
- // Both answer for the tenant that asked: "which apps are on" has no
574
- // deployment-wide answer once there is more than one database.
827
+ // The framework's own two, mounted last so a deployment cannot shadow them.
575
828
  '/_ket/health': async (url, req) => tenants.ofRequest(url, req, async (t) => json({
576
829
  ok: true,
577
- app: spec.name,
830
+ deployment: spec.name,
578
831
  database: t.adapter.name,
579
832
  ...(t.key ? { tenant: t.key } : {}),
580
- apps: [...(await t.apps.enabled())].sort(),
581
- orphans: await t.apps.orphans(),
833
+ modules: manifest.order,
582
834
  locales: Object.keys(manifest.messages ?? {}),
583
835
  })),
584
836
  '/_ket/agent': async (url, req) => tenants.ofRequest(url, req, async (t) => json(agentDescriptor(t.live))),
@@ -586,20 +838,14 @@ export async function bootApp(spec, o = {}) {
586
838
  });
587
839
  const port = await server.listen(config.port);
588
840
  const banner = async () => {
589
- // With tenants there is no deployment-wide list of installed apps, and the
590
- // banner says which mode it is in rather than inventing one.
591
- const enabled = apps ? [...(await apps.enabled())].sort() : [];
592
841
  const at = `http://${config.host}:${port}`;
593
- // A "site" row only means something if a path can become a page; an app that
842
+ // A "site" row only means something if a path can become a page; a deployment that
594
843
  // declares its own "/" route would otherwise be listed twice, once wrongly.
595
844
  const paths = new Map();
596
845
  if (pages)
597
846
  paths.set('/', 'site');
598
- // Module routes belong on the banner too, and only while installed — the list
599
- // is what the deployment actually serves, not what it could serve.
600
- for (const [p, r] of Object.entries(manifest.routes))
601
- if (enabled.includes(r.by))
602
- paths.set(p, p.replace(/^\//, ''));
847
+ for (const p of Object.keys(manifest.routes))
848
+ paths.set(p, p.replace(/^\//, ''));
603
849
  for (const p of Object.keys(appRoutes))
604
850
  paths.set(p, p.replace(/^\//, '') || 'site');
605
851
  const rows = [
@@ -613,7 +859,7 @@ export async function bootApp(spec, o = {}) {
613
859
  ? adapter.name + (config.databaseUrl ? '' : ` (${config.sqliteFile})`)
614
860
  : `${(await tenants.keys()).length} tenant(s), one database each`,
615
861
  ],
616
- ['apps installed', apps ? enabled.join(', ') || '(none)' : 'per tenant'],
862
+ ['modules', manifest.order.join(', ') || '(none)'],
617
863
  ['locales', Object.keys(manifest.messages ?? {}).join(', ') || '(none)'],
618
864
  [
619
865
  'identity',
@@ -621,13 +867,10 @@ export async function bootApp(spec, o = {}) {
621
867
  ? `sessions (${sessions ? sessions.store.name : 'one per tenant'})`
622
868
  : 'X-Ket-Company header',
623
869
  ],
624
- // Silence here would be the wrong kind: a module that declared install:'auto'
625
- // and did not arrive should say why, not look broken.
626
- ...(config.autoInstall ? [] : [['auto-install', 'off (KET_AUTO_INSTALL=0)']]),
627
870
  ];
628
871
  const w = Math.max(...rows.map((r) => r[0].length));
629
872
  const note = makeSessions
630
- ? (sessions?.ephemeralSecret ?? !config.secret)
873
+ ? (sessions?.ephemeralSecret ?? !configuredSessionSecret)
631
874
  ? `\n KET_SECRET is not set, so a signing key was generated for this process.` +
632
875
  `\n Sessions will not survive a restart and will not work across pods.`
633
876
  : ''
@@ -642,12 +885,28 @@ export async function bootApp(spec, o = {}) {
642
885
  if (adapter)
643
886
  await adapter.close();
644
887
  await tenants.close();
888
+ // Last, and after everything that might still have something to say. A buffered
889
+ // sink that is closed first loses precisely the records describing the shutdown.
890
+ logger.info('shutdown');
891
+ await logSink.flush?.();
892
+ await logSink.close?.();
893
+ };
894
+ return {
895
+ name: spec.name,
896
+ manifest,
897
+ adapter,
898
+ tenants,
899
+ config,
900
+ streams: server.streams,
901
+ logger,
902
+ port,
903
+ banner,
904
+ close,
645
905
  };
646
- return { name: spec.name, manifest, adapter, apps, tenants, config, port, banner, close };
647
906
  }
648
- /** bootApp, plus the banner and the signal handling a long-running process wants. */
649
- export async function serveApp(spec, o = {}) {
650
- const booted = await bootApp(spec, o);
907
+ /** bootDeployment, plus the banner and the signal handling a long-running process wants. */
908
+ export async function serveDeployment(spec, o = {}) {
909
+ const booted = await bootDeployment(spec, o);
651
910
  console.log(await booted.banner());
652
911
  for (const signal of ['SIGINT', 'SIGTERM']) {
653
912
  process.on(signal, () => {