@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.
- package/LICENSE +1 -1
- package/README.md +8 -2
- package/dist/agent/capabilities.d.ts +1 -3
- package/dist/agent/capabilities.d.ts.map +1 -1
- package/dist/agent/capabilities.js +10 -1
- package/dist/agent/capabilities.js.map +1 -1
- package/dist/agent/permissions.d.ts +43 -1
- package/dist/agent/permissions.d.ts.map +1 -1
- package/dist/agent/permissions.js +50 -2
- package/dist/agent/permissions.js.map +1 -1
- package/dist/cli.js +246 -97
- package/dist/cli.js.map +1 -1
- package/dist/codegen/dts.d.ts.map +1 -1
- package/dist/codegen/dts.js +1 -0
- package/dist/codegen/dts.js.map +1 -1
- package/dist/data/changeset.d.ts +12 -0
- package/dist/data/changeset.d.ts.map +1 -1
- package/dist/data/changeset.js +73 -17
- package/dist/data/changeset.js.map +1 -1
- package/dist/data/expr.d.ts +25 -1
- package/dist/data/expr.d.ts.map +1 -1
- package/dist/data/expr.js +47 -6
- package/dist/data/expr.js.map +1 -1
- package/dist/data/fleet.d.ts +41 -1
- package/dist/data/fleet.d.ts.map +1 -1
- package/dist/data/fleet.js +100 -11
- package/dist/data/fleet.js.map +1 -1
- package/dist/data/migrate.d.ts +20 -0
- package/dist/data/migrate.d.ts.map +1 -1
- package/dist/data/migrate.js +86 -3
- package/dist/data/migrate.js.map +1 -1
- package/dist/data/physical.d.ts +5 -0
- package/dist/data/physical.d.ts.map +1 -0
- package/dist/data/physical.js +196 -0
- package/dist/data/physical.js.map +1 -0
- package/dist/data/pool.d.ts +2 -1
- package/dist/data/pool.d.ts.map +1 -1
- package/dist/data/pool.js +159 -31
- package/dist/data/pool.js.map +1 -1
- package/dist/data/query.d.ts +10 -1
- package/dist/data/query.d.ts.map +1 -1
- package/dist/data/query.js +136 -27
- package/dist/data/query.js.map +1 -1
- package/dist/data/sqlite.d.ts.map +1 -1
- package/dist/data/sqlite.js +350 -52
- package/dist/data/sqlite.js.map +1 -1
- package/dist/data/time.d.ts +13 -0
- package/dist/data/time.d.ts.map +1 -1
- package/dist/data/time.js +61 -17
- package/dist/data/time.js.map +1 -1
- package/dist/index.d.ts +45 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -18
- package/dist/index.js.map +1 -1
- package/dist/kernel/audit.d.ts +27 -0
- package/dist/kernel/audit.d.ts.map +1 -0
- package/dist/kernel/audit.js +67 -0
- package/dist/kernel/audit.js.map +1 -0
- package/dist/kernel/classification.d.ts +26 -0
- package/dist/kernel/classification.d.ts.map +1 -0
- package/dist/kernel/classification.js +73 -0
- package/dist/kernel/classification.js.map +1 -0
- package/dist/kernel/compose.d.ts +6 -2
- package/dist/kernel/compose.d.ts.map +1 -1
- package/dist/kernel/compose.js +269 -15
- package/dist/kernel/compose.js.map +1 -1
- package/dist/kernel/define.d.ts +2 -3
- package/dist/kernel/define.d.ts.map +1 -1
- package/dist/kernel/define.js +22 -12
- package/dist/kernel/define.js.map +1 -1
- package/dist/kernel/diff.d.ts.map +1 -1
- package/dist/kernel/diff.js +28 -4
- package/dist/kernel/diff.js.map +1 -1
- package/dist/kernel/graph.js +1 -1
- package/dist/kernel/graph.js.map +1 -1
- package/dist/kernel/i18n.d.ts +10 -1
- package/dist/kernel/i18n.d.ts.map +1 -1
- package/dist/kernel/i18n.js +34 -1
- package/dist/kernel/i18n.js.map +1 -1
- package/dist/kernel/layout.d.ts +97 -0
- package/dist/kernel/layout.d.ts.map +1 -1
- package/dist/kernel/layout.js +294 -12
- package/dist/kernel/layout.js.map +1 -1
- package/dist/kernel/menu.d.ts +33 -2
- package/dist/kernel/menu.d.ts.map +1 -1
- package/dist/kernel/menu.js +99 -7
- package/dist/kernel/menu.js.map +1 -1
- package/dist/kernel/modules.d.ts +3 -3
- package/dist/kernel/modules.d.ts.map +1 -1
- package/dist/kernel/modules.js +21 -21
- package/dist/kernel/modules.js.map +1 -1
- package/dist/kernel/permissions.d.ts +10 -0
- package/dist/kernel/permissions.d.ts.map +1 -0
- package/dist/kernel/permissions.js +365 -0
- package/dist/kernel/permissions.js.map +1 -0
- package/dist/kernel/schedule.d.ts +23 -0
- package/dist/kernel/schedule.d.ts.map +1 -0
- package/dist/kernel/schedule.js +83 -0
- package/dist/kernel/schedule.js.map +1 -0
- package/dist/kernel/types.d.ts.map +1 -1
- package/dist/kernel/types.js +5 -4
- package/dist/kernel/types.js.map +1 -1
- package/dist/kernel/workspace.d.ts +92 -14
- package/dist/kernel/workspace.d.ts.map +1 -1
- package/dist/kernel/workspace.js +128 -49
- package/dist/kernel/workspace.js.map +1 -1
- package/dist/pdf/assets/Inter-Bold.ttf +0 -0
- package/dist/pdf/assets/Inter-Regular.ttf +0 -0
- package/dist/pdf/assets/Inter-SemiBold.ttf +0 -0
- package/dist/pdf/assets/LICENSE.txt +92 -0
- package/dist/pdf/font.d.ts +10 -0
- package/dist/pdf/font.d.ts.map +1 -0
- package/dist/pdf/font.js +83 -0
- package/dist/pdf/font.js.map +1 -0
- package/dist/pdf/image.d.ts +8 -0
- package/dist/pdf/image.d.ts.map +1 -0
- package/dist/pdf/image.js +94 -0
- package/dist/pdf/image.js.map +1 -0
- package/dist/pdf/index.d.ts +15 -0
- package/dist/pdf/index.d.ts.map +1 -0
- package/dist/pdf/index.js +17 -0
- package/dist/pdf/index.js.map +1 -0
- package/dist/pdf/markup.d.ts +19 -0
- package/dist/pdf/markup.d.ts.map +1 -0
- package/dist/pdf/markup.js +146 -0
- package/dist/pdf/markup.js.map +1 -0
- package/dist/pdf/render.d.ts +14 -0
- package/dist/pdf/render.d.ts.map +1 -0
- package/dist/pdf/render.js +387 -0
- package/dist/pdf/render.js.map +1 -0
- package/dist/scaffold/index.js +6 -6
- package/dist/scaffold/index.js.map +1 -1
- package/dist/scaffold/templates/{app.test.ts.tmpl → deployment.test.ts.tmpl} +3 -3
- package/dist/scaffold/templates/ket.workspace.ts.tmpl +4 -6
- package/dist/scaffold/templates/module.ts.tmpl +1 -4
- package/dist/server/assets.d.ts +23 -0
- package/dist/server/assets.d.ts.map +1 -0
- package/dist/server/assets.js +97 -0
- package/dist/server/assets.js.map +1 -0
- package/dist/server/boot.d.ts +174 -38
- package/dist/server/boot.d.ts.map +1 -1
- package/dist/server/boot.js +399 -140
- package/dist/server/boot.js.map +1 -1
- package/dist/server/config.d.ts +30 -10
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +73 -17
- package/dist/server/config.js.map +1 -1
- package/dist/server/ctx.d.ts +10 -0
- package/dist/server/ctx.d.ts.map +1 -1
- package/dist/server/ctx.js +284 -48
- package/dist/server/ctx.js.map +1 -1
- package/dist/server/development.d.ts +10 -0
- package/dist/server/development.d.ts.map +1 -0
- package/dist/server/development.js +15 -0
- package/dist/server/development.js.map +1 -0
- package/dist/server/fn.d.ts +22 -3
- package/dist/server/fn.d.ts.map +1 -1
- package/dist/server/fn.js +116 -32
- package/dist/server/fn.js.map +1 -1
- package/dist/server/form.d.ts +33 -0
- package/dist/server/form.d.ts.map +1 -0
- package/dist/server/form.js +47 -0
- package/dist/server/form.js.map +1 -0
- package/dist/server/http.d.ts +82 -3
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +373 -37
- package/dist/server/http.js.map +1 -1
- package/dist/server/idem.d.ts +3 -2
- package/dist/server/idem.d.ts.map +1 -1
- package/dist/server/idem.js +12 -4
- package/dist/server/idem.js.map +1 -1
- package/dist/server/jobs.js +1 -1
- package/dist/server/jobs.js.map +1 -1
- package/dist/server/log/combinators.d.ts +42 -0
- package/dist/server/log/combinators.d.ts.map +1 -0
- package/dist/server/log/combinators.js +270 -0
- package/dist/server/log/combinators.js.map +1 -0
- package/dist/server/log/console.d.ts +12 -0
- package/dist/server/log/console.d.ts.map +1 -0
- package/dist/server/log/console.js +96 -0
- package/dist/server/log/console.js.map +1 -0
- package/dist/server/log/file.d.ts +11 -0
- package/dist/server/log/file.d.ts.map +1 -0
- package/dist/server/log/file.js +61 -0
- package/dist/server/log/file.js.map +1 -0
- package/dist/server/log/index.d.ts +23 -0
- package/dist/server/log/index.d.ts.map +1 -0
- package/dist/server/log/index.js +45 -0
- package/dist/server/log/index.js.map +1 -0
- package/dist/server/log/logger.d.ts +53 -0
- package/dist/server/log/logger.d.ts.map +1 -0
- package/dist/server/log/logger.js +94 -0
- package/dist/server/log/logger.js.map +1 -0
- package/dist/server/log/memory.d.ts +11 -0
- package/dist/server/log/memory.d.ts.map +1 -0
- package/dist/server/log/memory.js +25 -0
- package/dist/server/log/memory.js.map +1 -0
- package/dist/server/log/types.d.ts +112 -0
- package/dist/server/log/types.d.ts.map +1 -0
- package/dist/server/log/types.js +58 -0
- package/dist/server/log/types.js.map +1 -0
- package/dist/server/policy.d.ts +29 -0
- package/dist/server/policy.d.ts.map +1 -0
- package/dist/server/policy.js +29 -0
- package/dist/server/policy.js.map +1 -0
- package/dist/server/queue.d.ts +1 -1
- package/dist/server/queue.d.ts.map +1 -1
- package/dist/server/queue.js.map +1 -1
- package/dist/server/ratelimit.d.ts +48 -0
- package/dist/server/ratelimit.d.ts.map +1 -0
- package/dist/server/ratelimit.js +102 -0
- package/dist/server/ratelimit.js.map +1 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +16 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/runtime.d.ts +18 -3
- package/dist/server/runtime.d.ts.map +1 -1
- package/dist/server/runtime.js +32 -2
- package/dist/server/runtime.js.map +1 -1
- package/dist/server/schedule.d.ts +18 -0
- package/dist/server/schedule.d.ts.map +1 -0
- package/dist/server/schedule.js +70 -0
- package/dist/server/schedule.js.map +1 -0
- package/dist/server/sequence.d.ts +42 -0
- package/dist/server/sequence.d.ts.map +1 -0
- package/dist/server/sequence.js +97 -0
- package/dist/server/sequence.js.map +1 -0
- package/dist/server/session.d.ts +21 -0
- package/dist/server/session.d.ts.map +1 -1
- package/dist/server/session.js +84 -10
- package/dist/server/session.js.map +1 -1
- package/dist/server/sessionstore.d.ts +3 -1
- package/dist/server/sessionstore.d.ts.map +1 -1
- package/dist/server/sessionstore.js +7 -3
- package/dist/server/sessionstore.js.map +1 -1
- package/dist/server/storage/index.d.ts +1 -1
- package/dist/server/storage/index.d.ts.map +1 -1
- package/dist/server/storage/index.js +61 -1
- package/dist/server/storage/index.js.map +1 -1
- package/dist/server/storage/types.d.ts +8 -0
- package/dist/server/storage/types.d.ts.map +1 -1
- package/dist/server/storage/types.js +58 -0
- package/dist/server/storage/types.js.map +1 -1
- package/dist/server/stream.d.ts +8 -2
- package/dist/server/stream.d.ts.map +1 -1
- package/dist/server/stream.js +44 -4
- package/dist/server/stream.js.map +1 -1
- package/dist/server/streamstore.d.ts +10 -0
- package/dist/server/streamstore.d.ts.map +1 -1
- package/dist/server/streamstore.js +72 -6
- package/dist/server/streamstore.js.map +1 -1
- package/dist/server/tenants.d.ts +20 -18
- package/dist/server/tenants.d.ts.map +1 -1
- package/dist/server/tenants.js +133 -84
- package/dist/server/tenants.js.map +1 -1
- package/dist/server/transport/types.js +2 -2
- package/dist/server/transport/types.js.map +1 -1
- package/dist/server/worker.d.ts +19 -3
- package/dist/server/worker.d.ts.map +1 -1
- package/dist/server/worker.js +168 -21
- package/dist/server/worker.js.map +1 -1
- package/dist/testing.d.ts +26 -10
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +41 -14
- package/dist/testing.js.map +1 -1
- package/dist/theme/contracts.d.ts +14 -1
- package/dist/theme/contracts.d.ts.map +1 -1
- package/dist/theme/contracts.js +35 -6
- package/dist/theme/contracts.js.map +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/joint-runtime.d.ts +33 -0
- package/dist/theme/joint-runtime.d.ts.map +1 -0
- package/dist/theme/joint-runtime.js +121 -0
- package/dist/theme/joint-runtime.js.map +1 -0
- package/dist/theme/joints.d.ts.map +1 -1
- package/dist/theme/joints.js +15 -97
- package/dist/theme/joints.js.map +1 -1
- package/dist/theme/ktl/compile.d.ts +4 -0
- package/dist/theme/ktl/compile.d.ts.map +1 -1
- package/dist/theme/ktl/compile.js +54 -1
- package/dist/theme/ktl/compile.js.map +1 -1
- package/dist/theme/ktl/parser.d.ts +4 -0
- package/dist/theme/ktl/parser.d.ts.map +1 -1
- package/dist/theme/ktl/parser.js +8 -0
- package/dist/theme/ktl/parser.js.map +1 -1
- package/dist/theme/render.d.ts +8 -0
- package/dist/theme/render.d.ts.map +1 -1
- package/dist/theme/render.js +88 -118
- package/dist/theme/render.js.map +1 -1
- package/dist/theme/tokens.d.ts +18 -0
- package/dist/theme/tokens.d.ts.map +1 -1
- package/dist/theme/tokens.js +33 -0
- package/dist/theme/tokens.js.map +1 -1
- package/dist/theme/viewmodel.d.ts +24 -0
- package/dist/theme/viewmodel.d.ts.map +1 -1
- package/dist/theme/viewmodel.js +75 -6
- package/dist/theme/viewmodel.js.map +1 -1
- package/dist/types.d.ts +320 -56
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
- package/dist/kernel/apps.d.ts +0 -43
- package/dist/kernel/apps.d.ts.map +0 -1
- package/dist/kernel/apps.js +0 -234
- package/dist/kernel/apps.js.map +0 -1
package/dist/server/boot.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
// Booting
|
|
1
|
+
// Booting a deployment: the sequence every deployment repeats, written once.
|
|
2
2
|
//
|
|
3
|
-
// Before this, running a KetSuite-shaped
|
|
4
|
-
// in the
|
|
5
|
-
//
|
|
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
|
|
7
|
+
// is deployment-agnostic, and every second deployment would have copied them, drift included.
|
|
8
8
|
//
|
|
9
|
-
// What stays with the
|
|
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 `
|
|
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 {
|
|
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,
|
|
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
|
|
40
|
+
export async function bootDeployment(spec, o = {}) {
|
|
39
41
|
const serve = spec.serve ?? {};
|
|
40
42
|
const log = o.log ?? console.log;
|
|
41
|
-
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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({
|
|
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.
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
|
174
|
-
* banner keeps saying so —
|
|
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 (!
|
|
169
|
+
if (!authenticationEnabled)
|
|
184
170
|
return Promise.resolve(null);
|
|
185
171
|
let record = sessionRecords.get(req);
|
|
186
172
|
if (!record) {
|
|
187
|
-
record =
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
239
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
267
|
-
* extends another loads after it and can override it.
|
|
268
|
-
*
|
|
269
|
-
* file layout
|
|
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 (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 (!
|
|
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: `
|
|
410
|
-
hint: `add the module that owns "${pages.resolve.split('.')[0]}" to the
|
|
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: `
|
|
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: `
|
|
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: `
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
|
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
|
-
|
|
830
|
+
deployment: spec.name,
|
|
578
831
|
database: t.adapter.name,
|
|
579
832
|
...(t.key ? { tenant: t.key } : {}),
|
|
580
|
-
|
|
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;
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
['
|
|
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 ?? !
|
|
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
|
-
/**
|
|
649
|
-
export async function
|
|
650
|
-
const booted = await
|
|
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, () => {
|