kempo 4.2.39 → 4.3.0

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 (41) hide show
  1. package/README.md +7 -0
  2. package/app-public/default.template.html +1 -1
  3. package/dist/admin/nav.fragment.html +5 -0
  4. package/dist/admin/realtime/index.page.html +81 -0
  5. package/dist/admin/settings/[owner]/[name]/index.page.html +52 -1
  6. package/dist/admin/settings/index.page.html +12 -1
  7. package/dist/kempo/api/realtime/WS.js +2 -0
  8. package/dist/kempo/api/realtime/WS.js.map +1 -0
  9. package/dist/kempo/api/realtime/connections/GET.js +2 -0
  10. package/dist/kempo/api/realtime/connections/GET.js.map +1 -0
  11. package/dist/kempo/icons/credit_card.svg +1 -0
  12. package/dist/kempo/icons/sensors.svg +1 -0
  13. package/dist/kempo/realtime.js +2 -0
  14. package/dist/kempo/realtime.js.map +1 -0
  15. package/dist/kempo/sdk.js +1 -1
  16. package/dist/kempo/sdk.js.map +1 -1
  17. package/middleware/kempo.js +30 -15
  18. package/package.json +2 -2
  19. package/scripts/init-db.js +4 -1
  20. package/server/db/schema.js +30 -1
  21. package/server/sdk.js +5 -0
  22. package/server/utils/extensions/contentDirs.js +27 -0
  23. package/server/utils/realtime/Hub.js +676 -0
  24. package/server/utils/realtime/channels.js +247 -0
  25. package/server/utils/realtime/closeConnection.js +4 -0
  26. package/server/utils/realtime/constants.js +20 -0
  27. package/server/utils/realtime/duration.js +18 -0
  28. package/server/utils/realtime/getHub.js +15 -0
  29. package/server/utils/realtime/index.js +14 -0
  30. package/server/utils/realtime/listConnections.js +4 -0
  31. package/server/utils/realtime/listSubscribers.js +4 -0
  32. package/server/utils/realtime/pruneMessages.js +45 -0
  33. package/server/utils/realtime/publish.js +86 -0
  34. package/server/utils/realtime/sendToConnection.js +4 -0
  35. package/server/utils/routing/serveUnmatched.js +8 -1
  36. package/server/utils/settings/getPublicSettings.js +4 -2
  37. package/server/utils/settings/getSettingWithMetadata.js +11 -4
  38. package/server/utils/settings/helpers.js +8 -2
  39. package/server/utils/settings/listSettings.js +9 -1
  40. package/server/utils/settings/secretCrypto.js +41 -0
  41. package/server/utils/settings/setSetting.js +31 -10
@@ -6,6 +6,7 @@ import getSession from '../server/utils/auth/getSession.js';
6
6
  import currentUserHasPermission from '../server/utils/permissions/currentUserHasPermission.js';
7
7
  import { LEXICAL_BASE, bundleFileName as lexicalBundleFileName } from '../server/utils/lexical/packages.js';
8
8
  import { getEnabledExtensions } from '../server/utils/extensions/scopeCache.js';
9
+ import { extensionAdminDirs, extensionPublicDirs } from '../server/utils/extensions/contentDirs.js';
9
10
  import triggerHook from '../server/utils/hooks/triggerHook.js';
10
11
  import { ADMIN_GLOBALS_DIR } from '../server/utils/admin-global-content/helpers.js';
11
12
 
@@ -112,7 +113,7 @@ const executeRouteFile = async (filePath, request, response, params = {}) => {
112
113
  extensions guard their own private pages. Rendering a page anywhere without this helper silently
113
114
  bypasses those guards.
114
115
  */
115
- const renderGuardedPage = async (pageFilePath, request, response, rootDir, resolveDir, params = {}, extraGlobalDirs = []) => {
116
+ const renderGuardedPage = async (pageFilePath, request, response, rootDir, resolveDir, params = {}, extraGlobalDirs = [], extraFragmentDirs = []) => {
116
117
  try {
117
118
  await triggerHook('middleware:before_page', {
118
119
  url: request.url.split('?')[0],
@@ -127,7 +128,7 @@ const renderGuardedPage = async (pageFilePath, request, response, rootDir, resol
127
128
  return;
128
129
  }
129
130
 
130
- const html = await renderExternalPage(pageFilePath, rootDir, resolveDir, {}, {}, 10, extraGlobalDirs);
131
+ const html = await renderExternalPage(pageFilePath, rootDir, resolveDir, {}, {}, 10, extraGlobalDirs, extraFragmentDirs);
131
132
  response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
132
133
  response.end(html);
133
134
  };
@@ -145,16 +146,22 @@ const renderGuardedPage = async (pageFilePath, request, response, rootDir, resol
145
146
  */
146
147
  export const adminGlobalDirs = async () => [
147
148
  ADMIN_GLOBALS_DIR,
148
- ...(await getEnabledExtensions()).map(ext => join(NODE_MODULES, ext.name, 'admin')),
149
+ ...await extensionAdminDirs(),
149
150
  ];
150
151
 
151
- const serveDir = async (dirPath, method, request, response, resolveDir, rootDir, params = {}, extraGlobalDirs = []) => {
152
+ /*
153
+ Fragments an admin render may pull from extension packages. ADMIN_GLOBALS_DIR is deliberately not
154
+ here: it holds admin-authored global content and never fragments.
155
+ */
156
+ export const adminFragmentDirs = () => extensionAdminDirs();
157
+
158
+ const serveDir = async (dirPath, method, request, response, resolveDir, rootDir, params = {}, extraGlobalDirs = [], extraFragmentDirs = []) => {
152
159
  const candidates = [`${method}.js`, 'index.page.html', 'index.js', 'index.html', 'CATCH.js'];
153
160
  for(const candidate of candidates){
154
161
  const candidatePath = join(dirPath, candidate);
155
162
  try { await stat(candidatePath); } catch { continue; }
156
163
  if(candidate.endsWith('.page.html')){
157
- await renderGuardedPage(candidatePath, request, response, rootDir, resolveDir, params, extraGlobalDirs);
164
+ await renderGuardedPage(candidatePath, request, response, rootDir, resolveDir, params, extraGlobalDirs, extraFragmentDirs);
158
165
  return true;
159
166
  }
160
167
  if(ROUTE_FILES.includes(candidate)){
@@ -271,9 +278,10 @@ export default config => {
271
278
  const segments = url.slice('/admin'.length).replace(/^\//, '').split('/').filter(Boolean);
272
279
  const resolveDir = buildResolveDir(ADMIN_ROOT, url);
273
280
  const globalDirs = await adminGlobalDirs();
281
+ const fragmentDirs = await adminFragmentDirs();
274
282
 
275
283
  if(segments.length === 0){
276
- const served = await serveDir(ADMIN_ROOT, method, request, response, resolveDir, ADMIN_ROOT, {}, globalDirs);
284
+ const served = await serveDir(ADMIN_ROOT, method, request, response, resolveDir, ADMIN_ROOT, {}, globalDirs, fragmentDirs);
277
285
  if(served) return;
278
286
  } else {
279
287
  const walked = await walkDynamic(ADMIN_ROOT, segments);
@@ -283,12 +291,12 @@ export default config => {
283
291
  try { fileStat = await stat(filePath); } catch { /* not found */ }
284
292
 
285
293
  if(fileStat?.isDirectory()){
286
- const served = await serveDir(filePath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs);
294
+ const served = await serveDir(filePath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs, fragmentDirs);
287
295
  if(served) return;
288
296
  } else if(fileStat?.isFile()){
289
297
  const name = filePath.split(/[/\\]/).pop();
290
298
  if(name.endsWith('.page.html')){
291
- await renderGuardedPage(filePath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs);
299
+ await renderGuardedPage(filePath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs, fragmentDirs);
292
300
  return;
293
301
  }
294
302
  if(ROUTE_FILES.includes(name)){
@@ -317,6 +325,7 @@ export default config => {
317
325
  const resolveDir = buildResolveDir(ADMIN_ROOT, url);
318
326
  const method = request.method?.toUpperCase() || 'GET';
319
327
  const globalDirs = await adminGlobalDirs();
328
+ const fragmentDirs = await adminFragmentDirs();
320
329
 
321
330
  const pageCandidates = subPath.endsWith('/')
322
331
  ? [join(adminDir, subPath, 'index.page.html')]
@@ -329,7 +338,7 @@ export default config => {
329
338
  try {
330
339
  await stat(pagePath);
331
340
  } catch { continue; }
332
- await renderGuardedPage(pagePath, request, response, ADMIN_ROOT, resolveDir, {}, globalDirs);
341
+ await renderGuardedPage(pagePath, request, response, ADMIN_ROOT, resolveDir, {}, globalDirs, fragmentDirs);
333
342
  return;
334
343
  }
335
344
 
@@ -351,12 +360,12 @@ export default config => {
351
360
  let walkedStat;
352
361
  try { walkedStat = await stat(walkedPath); } catch { /* not found */ }
353
362
  if(walkedStat?.isDirectory()){
354
- const served = await serveDir(walkedPath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs);
363
+ const served = await serveDir(walkedPath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs, fragmentDirs);
355
364
  if(served) return;
356
365
  } else if(walkedStat?.isFile()){
357
366
  const name = walkedPath.split(/[/\\]/).pop();
358
367
  if(name.endsWith('.page.html')){
359
- await renderGuardedPage(walkedPath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs);
368
+ await renderGuardedPage(walkedPath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs, fragmentDirs);
360
369
  return;
361
370
  }
362
371
  if(ROUTE_FILES.includes(name)){
@@ -378,6 +387,12 @@ export default config => {
378
387
  */
379
388
  const extensions = await getEnabledExtensions();
380
389
 
390
+ /*
391
+ Resolved once for every public render below — an extension's own scoped pages, the consumer's
392
+ pages, and the 404 alike — so any enabled extension can contribute to any of them.
393
+ */
394
+ const publicContentDirs = await extensionPublicDirs();
395
+
381
396
  for(const ext of extensions){
382
397
  let pkgPath, pkg;
383
398
  try {
@@ -398,7 +413,7 @@ export default config => {
398
413
  const segments = subPath.replace(/^\//, '').split('/').filter(Boolean);
399
414
 
400
415
  if(segments.length === 0){
401
- const served = await serveDir(publicDir, method, request, response, resolveDir, PROJECT_PUBLIC);
416
+ const served = await serveDir(publicDir, method, request, response, resolveDir, PROJECT_PUBLIC, {}, publicContentDirs, publicContentDirs);
402
417
  if(served) return;
403
418
  return next();
404
419
  }
@@ -410,12 +425,12 @@ export default config => {
410
425
  try { fileStat = await stat(filePath); } catch { /* not found */ }
411
426
 
412
427
  if(fileStat?.isDirectory()){
413
- const served = await serveDir(filePath, method, request, response, resolveDir, PROJECT_PUBLIC, params);
428
+ const served = await serveDir(filePath, method, request, response, resolveDir, PROJECT_PUBLIC, params, publicContentDirs, publicContentDirs);
414
429
  if(served) return;
415
430
  } else if(fileStat?.isFile()){
416
431
  const name = filePath.split(/[/\\]/).pop();
417
432
  if(name.endsWith('.page.html')){
418
- await renderGuardedPage(filePath, request, response, PROJECT_PUBLIC, resolveDir, params);
433
+ await renderGuardedPage(filePath, request, response, PROJECT_PUBLIC, resolveDir, params, publicContentDirs, publicContentDirs);
419
434
  return;
420
435
  }
421
436
  if(ROUTE_FILES.includes(name)){
@@ -448,7 +463,7 @@ export default config => {
448
463
 
449
464
  for(const pageFilePath of candidates){
450
465
  try { await stat(pageFilePath); } catch { continue; }
451
- await renderGuardedPage(pageFilePath, request, response, PROJECT_PUBLIC, resolveDir);
466
+ await renderGuardedPage(pageFilePath, request, response, PROJECT_PUBLIC, resolveDir, {}, publicContentDirs, publicContentDirs);
452
467
  return;
453
468
  }
454
469
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.2.39",
3
+ "version": "4.3.0",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "peerDependencies": {
60
60
  "kempo-css": ">=2.2.5",
61
- "kempo-server": ">=3.3.0",
61
+ "kempo-server": ">=3.4.0",
62
62
  "kempo-ui": ">=0.4.39"
63
63
  },
64
64
  "devDependencies": {
@@ -46,6 +46,8 @@ const DEFAULT_PERMISSIONS = [
46
46
  { name: 'system:extensions:uninstall', description: 'Uninstall extensions' },
47
47
  { name: 'system:extensions:manage', description: 'Enable and disable extensions' },
48
48
 
49
+ { name: 'system:realtime:read', description: 'View realtime connections and channels' },
50
+
49
51
  { name: 'system:admin:access', description: 'Access admin panel' },
50
52
  ];
51
53
 
@@ -66,7 +68,8 @@ const DEFAULT_GROUPS = {
66
68
  'system:menus:create', 'system:menus:read', 'system:menus:update', 'system:menus:delete',
67
69
  'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
68
70
  'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
69
- 'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage'
71
+ 'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
72
+ 'system:realtime:read'
70
73
  ]
71
74
  }
72
75
  };
@@ -1,4 +1,4 @@
1
- import { pgTable, text, timestamp, boolean, index, jsonb } from 'drizzle-orm/pg-core';
1
+ import { pgTable, text, timestamp, boolean, index, jsonb, bigserial, bigint } from 'drizzle-orm/pg-core';
2
2
 
3
3
  /*
4
4
  Auth Tables
@@ -114,6 +114,35 @@ export const extension = pgTable('extension', {
114
114
  updatedAt: timestamp('updatedAt').notNull(),
115
115
  });
116
116
 
117
+ /*
118
+ Realtime System
119
+ */
120
+
121
+ /*
122
+ A published message, kept only for channels that opt in to persistence. `id` is what a client
123
+ hands back as `since` to replay what it missed, so it must be ordered within a channel: publish
124
+ takes a per-channel lock, otherwise two rows could commit out of id order and a replay from the
125
+ earlier id would skip the later-committing one for good.
126
+ */
127
+ export const realtimeMessage = pgTable('realtimeMessage', {
128
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
129
+ channel: text('channel').notNull(),
130
+ data: jsonb('data').notNull(),
131
+ createdAt: timestamp('createdAt').notNull(),
132
+ }, table => [
133
+ index('realtimeMessage_channel_id_idx').on(table.channel, table.id),
134
+ index('realtimeMessage_createdAt_idx').on(table.createdAt),
135
+ ]);
136
+
137
+ /*
138
+ How far a channel has been pruned. Ids are shared across channels, so a gap cannot be inferred from
139
+ id arithmetic; a client whose `since` is below this watermark has provably missed messages.
140
+ */
141
+ export const realtimeChannel = pgTable('realtimeChannel', {
142
+ channel: text('channel').primaryKey(),
143
+ prunedThrough: bigint('prunedThrough', { mode: 'number' }).notNull().default(0),
144
+ });
145
+
117
146
  /*
118
147
  Triggers to prevent deletion of system resources
119
148
  */
package/server/sdk.js CHANGED
@@ -196,3 +196,8 @@ export { default as listExtensions } from './utils/extensions/listExtensions.js'
196
196
  export { default as uninstallExtension } from './utils/extensions/uninstallExtension.js';
197
197
  export { default as updateExtension } from './utils/extensions/updateExtension.js';
198
198
 
199
+ /*
200
+ Realtime
201
+ */
202
+
203
+ export * as realtime from './utils/realtime/index.js';
@@ -0,0 +1,27 @@
1
+ import { join } from 'path';
2
+ import { getEnabledExtensions } from './scopeCache.js';
3
+
4
+ const NODE_MODULES = join(process.cwd(), 'node_modules');
5
+
6
+ /*
7
+ The directories an enabled extension may contribute *.global.html and *.fragment.html from, split
8
+ by which half of the site is rendering: `admin/` for the admin portal, `public/` for the live site.
9
+
10
+ Each is read straight from the package at render time, the same way an extension's pages and
11
+ routes are resolved, so nothing is copied anywhere on install: enabling, disabling, upgrading or
12
+ removing an extension takes effect immediately, and there is nothing left behind to clean up.
13
+
14
+ That is also the whole of the on/off switch for this content — a disabled extension drops out of
15
+ these lists and its files are simply never read. There is no `enabled` flag on a packaged file the
16
+ way there is on admin-authored global content, because there is no stored row to carry one.
17
+
18
+ Lives here rather than in middleware/kempo.js so that server utils can reach it too — the 404 page
19
+ is rendered from server/utils/routing/serveUnmatched.js, and importing the middleware from there
20
+ would be circular.
21
+ */
22
+ const extensionDirs = async sub =>
23
+ (await getEnabledExtensions()).map(ext => join(NODE_MODULES, ext.name, sub));
24
+
25
+ export const extensionAdminDirs = () => extensionDirs('admin');
26
+
27
+ export const extensionPublicDirs = () => extensionDirs('public');