kempo 4.2.19 → 4.2.21

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 (176) hide show
  1. package/app-public/CATCH.page.html +15 -0
  2. package/app-public/kempo-global.global.html +0 -6
  3. package/bin/cli.js +4 -21
  4. package/dist/admin/accounts/groups/[name]/index.page.html +114 -4
  5. package/dist/admin/accounts/groups/index.page.html +89 -4
  6. package/dist/admin/accounts/permissions/index.page.html +86 -4
  7. package/dist/admin/accounts/users/[id]/index.page.html +117 -3
  8. package/dist/admin/accounts/users/index.page.html +118 -4
  9. package/dist/admin/components/AddGroupControl.js +1 -5
  10. package/dist/admin/components/AddGroupControl.js.map +1 -1
  11. package/dist/admin/components/AdminTableControl.js +2 -0
  12. package/dist/admin/components/AdminTableControl.js.map +1 -0
  13. package/dist/admin/components/ClearExpiredSessionsControl.js +1 -5
  14. package/dist/admin/components/ClearExpiredSessionsControl.js.map +1 -1
  15. package/dist/admin/components/GroupDeleteSelected.js +1 -5
  16. package/dist/admin/components/GroupDeleteSelected.js.map +1 -1
  17. package/dist/admin/components/GroupRemoveRecord.js +1 -5
  18. package/dist/admin/components/GroupRemoveRecord.js.map +1 -1
  19. package/dist/admin/components/TableRowDelete.js +1 -3
  20. package/dist/admin/components/TableRowDelete.js.map +1 -1
  21. package/dist/admin/components/TableRowLink.js +1 -7
  22. package/dist/admin/components/TableRowLink.js.map +1 -1
  23. package/dist/admin/components/tableControls/AddMember.js +1 -5
  24. package/dist/admin/components/tableControls/AddMember.js.map +1 -1
  25. package/dist/admin/components/tableControls/AddPermission.js +1 -5
  26. package/dist/admin/components/tableControls/AddPermission.js.map +1 -1
  27. package/dist/admin/components/tableControls/InstallExtension.js +1 -5
  28. package/dist/admin/components/tableControls/InstallExtension.js.map +1 -1
  29. package/dist/admin/components/tableControls/UninstallExtension.js +1 -5
  30. package/dist/admin/components/tableControls/UninstallExtension.js.map +1 -1
  31. package/dist/admin/content/fragments/edit/index.page.html +8 -1
  32. package/dist/admin/content/fragments/index.page.html +88 -3
  33. package/dist/admin/content/globals/edit/index.page.html +8 -1
  34. package/dist/admin/content/globals/index.page.html +103 -8
  35. package/dist/admin/content/pages/edit/index.page.html +8 -1
  36. package/dist/admin/content/pages/index.page.html +225 -28
  37. package/dist/admin/content/templates/edit/index.page.html +8 -1
  38. package/dist/admin/content/templates/index.page.html +117 -3
  39. package/dist/admin/default.template.html +20 -1
  40. package/dist/admin/extensions/index.page.html +59 -2
  41. package/dist/admin/icons/extension_add.svg +1 -0
  42. package/dist/admin/icons/extension_remove.svg +1 -0
  43. package/dist/admin/index.page.html +5 -1
  44. package/dist/admin/init.js.map +1 -1
  45. package/dist/admin/nav.fragment.html +84 -2
  46. package/dist/admin/settings/[owner]/[name]/index.page.html +110 -4
  47. package/dist/admin/settings/index.page.html +89 -4
  48. package/dist/kempo/api/admin-globals/DELETE.js +1 -1
  49. package/dist/kempo/api/admin-globals/DELETE.js.map +1 -1
  50. package/dist/kempo/api/admin-globals/GET.js +1 -1
  51. package/dist/kempo/api/admin-globals/GET.js.map +1 -1
  52. package/dist/kempo/api/admin-globals/POST.js +1 -1
  53. package/dist/kempo/api/admin-globals/POST.js.map +1 -1
  54. package/dist/kempo/api/admin-globals/[id]/GET.js +1 -1
  55. package/dist/kempo/api/admin-globals/[id]/GET.js.map +1 -1
  56. package/dist/kempo/api/admin-globals/[id]/PUT.js +1 -1
  57. package/dist/kempo/api/admin-globals/[id]/PUT.js.map +1 -1
  58. package/dist/kempo/api/admin-globals/[id]/disable/PUT.js +1 -1
  59. package/dist/kempo/api/admin-globals/[id]/disable/PUT.js.map +1 -1
  60. package/dist/kempo/api/admin-globals/[id]/enable/PUT.js +1 -1
  61. package/dist/kempo/api/admin-globals/[id]/enable/PUT.js.map +1 -1
  62. package/dist/kempo/api/extensions/DELETE.js +1 -1
  63. package/dist/kempo/api/extensions/DELETE.js.map +1 -1
  64. package/dist/kempo/api/extensions/POST.js +1 -1
  65. package/dist/kempo/api/extensions/POST.js.map +1 -1
  66. package/dist/kempo/api/extensions/[name]/disable/POST.js +1 -1
  67. package/dist/kempo/api/extensions/[name]/disable/POST.js.map +1 -1
  68. package/dist/kempo/api/extensions/[name]/enable/POST.js +1 -1
  69. package/dist/kempo/api/extensions/[name]/enable/POST.js.map +1 -1
  70. package/dist/kempo/api/extensions/[name]/update/POST.js +1 -1
  71. package/dist/kempo/api/extensions/[name]/update/POST.js.map +1 -1
  72. package/dist/kempo/api/extensions/[name]/update-check/GET.js +1 -1
  73. package/dist/kempo/api/extensions/[name]/update-check/GET.js.map +1 -1
  74. package/dist/kempo/api/fragments/DELETE.js +1 -1
  75. package/dist/kempo/api/fragments/DELETE.js.map +1 -1
  76. package/dist/kempo/api/fragments/GET.js +1 -1
  77. package/dist/kempo/api/fragments/GET.js.map +1 -1
  78. package/dist/kempo/api/fragments/POST.js +1 -1
  79. package/dist/kempo/api/fragments/POST.js.map +1 -1
  80. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  81. package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
  82. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  83. package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
  84. package/dist/kempo/api/fragments/file/GET.js +1 -1
  85. package/dist/kempo/api/fragments/file/GET.js.map +1 -1
  86. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  87. package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
  88. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  89. package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
  90. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  91. package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
  92. package/dist/kempo/api/globals/DELETE.js +1 -1
  93. package/dist/kempo/api/globals/DELETE.js.map +1 -1
  94. package/dist/kempo/api/globals/GET.js +1 -1
  95. package/dist/kempo/api/globals/GET.js.map +1 -1
  96. package/dist/kempo/api/globals/POST.js +1 -1
  97. package/dist/kempo/api/globals/POST.js.map +1 -1
  98. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  99. package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
  100. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  101. package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
  102. package/dist/kempo/api/globals/entry/GET.js +1 -1
  103. package/dist/kempo/api/globals/entry/GET.js.map +1 -1
  104. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  105. package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
  106. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  107. package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
  108. package/dist/kempo/api/pages/DELETE.js +1 -1
  109. package/dist/kempo/api/pages/DELETE.js.map +1 -1
  110. package/dist/kempo/api/pages/GET.js +1 -1
  111. package/dist/kempo/api/pages/GET.js.map +1 -1
  112. package/dist/kempo/api/pages/POST.js +1 -1
  113. package/dist/kempo/api/pages/POST.js.map +1 -1
  114. package/dist/kempo/api/pages/directories/GET.js +1 -1
  115. package/dist/kempo/api/pages/directories/GET.js.map +1 -1
  116. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  117. package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
  118. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  119. package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
  120. package/dist/kempo/api/pages/file/GET.js +1 -1
  121. package/dist/kempo/api/pages/file/GET.js.map +1 -1
  122. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  123. package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
  124. package/dist/kempo/api/pages/file/PUT.js +1 -1
  125. package/dist/kempo/api/pages/file/PUT.js.map +1 -1
  126. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  127. package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
  128. package/dist/kempo/api/pages/rescan/POST.js +2 -0
  129. package/dist/kempo/api/pages/rescan/POST.js.map +1 -0
  130. package/dist/kempo/api/pages/search/GET.js +1 -1
  131. package/dist/kempo/api/pages/search/GET.js.map +1 -1
  132. package/dist/kempo/api/pages/templates/GET.js +1 -1
  133. package/dist/kempo/api/pages/templates/GET.js.map +1 -1
  134. package/dist/kempo/api/templates/DELETE.js +1 -1
  135. package/dist/kempo/api/templates/DELETE.js.map +1 -1
  136. package/dist/kempo/api/templates/GET.js +1 -1
  137. package/dist/kempo/api/templates/GET.js.map +1 -1
  138. package/dist/kempo/api/templates/POST.js +1 -1
  139. package/dist/kempo/api/templates/POST.js.map +1 -1
  140. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  141. package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
  142. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  143. package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
  144. package/dist/kempo/api/templates/file/GET.js +1 -1
  145. package/dist/kempo/api/templates/file/GET.js.map +1 -1
  146. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  147. package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
  148. package/dist/kempo/api/templates/file/PUT.js +1 -1
  149. package/dist/kempo/api/templates/file/PUT.js.map +1 -1
  150. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  151. package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
  152. package/dist/kempo/api/user/[userid]/name/GET.js +2 -0
  153. package/dist/kempo/api/user/[userid]/name/GET.js.map +1 -0
  154. package/dist/kempo/components/UserDisplayName.js +2 -0
  155. package/dist/kempo/components/UserDisplayName.js.map +1 -0
  156. package/dist/kempo/sdk.js +1 -1
  157. package/dist/kempo/sdk.js.map +1 -1
  158. package/middleware/kempo.js +99 -25
  159. package/package.json +16 -9
  160. package/server/utils/admin-global-content/createAdminGlobalContent.js +4 -3
  161. package/server/utils/admin-global-content/deleteAdminGlobalContent.js +2 -2
  162. package/server/utils/admin-global-content/deleteAdminGlobalContentByOwner.js +2 -2
  163. package/server/utils/admin-global-content/disableAdminGlobalContent.js +2 -2
  164. package/server/utils/admin-global-content/enableAdminGlobalContent.js +2 -2
  165. package/server/utils/admin-global-content/getAdminGlobalContent.js +2 -2
  166. package/server/utils/admin-global-content/helpers.js +12 -1
  167. package/server/utils/admin-global-content/listAdminGlobalContent.js +2 -2
  168. package/server/utils/admin-global-content/updateAdminGlobalContent.js +2 -2
  169. package/server/utils/email/sendEmail.js +9 -3
  170. package/server/utils/extensions/uninstallExtension.js +54 -21
  171. package/server/utils/fragments/listFragments.js +10 -1
  172. package/server/utils/hooks/triggerHook.js +15 -13
  173. package/server/utils/pages/listPages.js +57 -7
  174. package/app-public/kempo-global.global-disabled.html +0 -0
  175. package/app-public/new-page.page.html +0 -17
  176. package/app-public/test.page.html +0 -21
@@ -1,10 +1,12 @@
1
- import { dirname, join, extname } from 'path';
1
+ import { dirname, join, extname, isAbsolute, resolve } from 'path';
2
2
  import { readFile, stat, readdir } from 'fs/promises';
3
3
  import { pathToFileURL, fileURLToPath } from 'url';
4
4
  import { renderExternalPage } from 'kempo-server/templating';
5
5
  import getSession from '../server/utils/auth/getSession.js';
6
6
  import currentUserHasPermission from '../server/utils/permissions/currentUserHasPermission.js';
7
7
  import { getEnabledExtensions } from '../server/utils/extensions/scopeCache.js';
8
+ import triggerHook from '../server/utils/hooks/triggerHook.js';
9
+ import { ADMIN_GLOBALS_DIR } from '../server/utils/admin-global-content/helpers.js';
8
10
 
9
11
  const MIME_TYPES = {
10
12
  '.js': 'application/javascript',
@@ -25,8 +27,17 @@ const ROUTE_FILES = ['GET.js','POST.js','PUT.js','DELETE.js','PATCH.js','HEAD.js
25
27
 
26
28
  const NODE_MODULES = join(process.cwd(), 'node_modules');
27
29
 
28
- // Resolves to dist/admin relative to this file — works both in this repo and as a consumer dependency
29
- const ADMIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'admin');
30
+ /*
31
+ Admin portal source. Defaults to dist/admin relative to this file, which works both in this repo
32
+ and as a consumer dependency. Set `middleware.adminRoot` in the server config (resolved from cwd)
33
+ to point at src/admin instead, so admin pages can be edited without rebuilding.
34
+ */
35
+ const DEFAULT_ADMIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'admin');
36
+
37
+ const resolveAdminRoot = adminRoot => {
38
+ if(!adminRoot) return DEFAULT_ADMIN_ROOT;
39
+ return isAbsolute(adminRoot) ? adminRoot : resolve(process.cwd(), adminRoot);
40
+ };
30
41
 
31
42
  const buildResolveDir = (base, url) => {
32
43
  const parts = url.replace(/\/+$/, '').split('/').filter(Boolean);
@@ -51,6 +62,13 @@ const walkDynamic = async (base, segments) => {
51
62
  return { filePath: join(base, head), params: {} };
52
63
  }
53
64
  }
65
+ if(rest.length === 0){
66
+ for(const entry of entries){
67
+ if(entry.isFile() && entry.name === `${head}.page.html`){
68
+ return { filePath: join(base, entry.name), params: {} };
69
+ }
70
+ }
71
+ }
54
72
  for(const entry of entries){
55
73
  if(!entry.isDirectory() || !entry.name.startsWith('[') || !entry.name.endsWith(']')) continue;
56
74
  const paramName = entry.name.slice(1, -1);
@@ -69,15 +87,55 @@ const executeRouteFile = async (filePath, request, response, params = {}) => {
69
87
  return true;
70
88
  };
71
89
 
72
- const serveDir = async (dirPath, method, request, response, resolveDir, rootDir, params = {}) => {
90
+ /*
91
+ Every .page.html render must go through here. The `middleware:before_page` hook lets extensions
92
+ veto a render (throw `{code}` to send that status, or `{redirect}` to redirect), which is how
93
+ extensions guard their own private pages. Rendering a page anywhere without this helper silently
94
+ bypasses those guards.
95
+ */
96
+ const renderGuardedPage = async (pageFilePath, request, response, rootDir, resolveDir, params = {}, extraGlobalDirs = []) => {
97
+ try {
98
+ await triggerHook('middleware:before_page', {
99
+ url: request.url.split('?')[0],
100
+ query: request.query || {},
101
+ params,
102
+ cookies: request.cookies || {},
103
+ }, { bail: true });
104
+ } catch(e) {
105
+ if(e?.redirect) return response.redirect(e.redirect);
106
+ response.writeHead(e?.code || 404, { 'Content-Type': 'text/html; charset=utf-8' });
107
+ response.end('');
108
+ return;
109
+ }
110
+
111
+ const html = await renderExternalPage(pageFilePath, rootDir, resolveDir, {}, {}, 10, extraGlobalDirs);
112
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
113
+ response.end(html);
114
+ };
115
+
116
+ /*
117
+ Directories that contribute *.global.html to an admin render, on top of ADMIN_ROOT itself.
118
+
119
+ Each enabled extension's admin/ directory is read straight from its package at render time — the
120
+ same way its admin pages and public pages are resolved — so nothing is copied into kempo's
121
+ dist/admin on install. Enabling, disabling, upgrading or removing an extension takes effect
122
+ immediately, and a rebuild of kempo cannot discard it.
123
+
124
+ Admin globals authored through the admin UI live in the consumer's project instead, since those
125
+ are genuinely mutable and cannot live inside a package.
126
+ */
127
+ export const adminGlobalDirs = async () => [
128
+ ADMIN_GLOBALS_DIR,
129
+ ...(await getEnabledExtensions()).map(ext => join(NODE_MODULES, ext.name, 'admin')),
130
+ ];
131
+
132
+ const serveDir = async (dirPath, method, request, response, resolveDir, rootDir, params = {}, extraGlobalDirs = []) => {
73
133
  const candidates = [`${method}.js`, 'index.page.html', 'index.js', 'index.html', 'CATCH.js'];
74
134
  for(const candidate of candidates){
75
135
  const candidatePath = join(dirPath, candidate);
76
136
  try { await stat(candidatePath); } catch { continue; }
77
137
  if(candidate.endsWith('.page.html')){
78
- const html = await renderExternalPage(candidatePath, rootDir, resolveDir);
79
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
80
- response.end(html);
138
+ await renderGuardedPage(candidatePath, request, response, rootDir, resolveDir, params, extraGlobalDirs);
81
139
  return true;
82
140
  }
83
141
  if(ROUTE_FILES.includes(candidate)){
@@ -93,6 +151,7 @@ const serveDir = async (dirPath, method, request, response, resolveDir, rootDir,
93
151
 
94
152
  export default config => {
95
153
  const PROJECT_PUBLIC = config.rootPath || join(process.cwd(), 'public');
154
+ const ADMIN_ROOT = resolveAdminRoot(config.adminRoot);
96
155
 
97
156
  return async (request, response, next) => {
98
157
  const { path } = request;
@@ -127,9 +186,10 @@ export default config => {
127
186
  if(!url.startsWith('/admin/extension/')){
128
187
  const segments = url.slice('/admin'.length).replace(/^\//, '').split('/').filter(Boolean);
129
188
  const resolveDir = buildResolveDir(ADMIN_ROOT, url);
189
+ const globalDirs = await adminGlobalDirs();
130
190
 
131
191
  if(segments.length === 0){
132
- const served = await serveDir(ADMIN_ROOT, method, request, response, resolveDir, ADMIN_ROOT);
192
+ const served = await serveDir(ADMIN_ROOT, method, request, response, resolveDir, ADMIN_ROOT, {}, globalDirs);
133
193
  if(served) return;
134
194
  } else {
135
195
  const walked = await walkDynamic(ADMIN_ROOT, segments);
@@ -139,14 +199,12 @@ export default config => {
139
199
  try { fileStat = await stat(filePath); } catch { /* not found */ }
140
200
 
141
201
  if(fileStat?.isDirectory()){
142
- const served = await serveDir(filePath, method, request, response, resolveDir, ADMIN_ROOT, params);
202
+ const served = await serveDir(filePath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs);
143
203
  if(served) return;
144
204
  } else if(fileStat?.isFile()){
145
205
  const name = filePath.split(/[/\\]/).pop();
146
206
  if(name.endsWith('.page.html')){
147
- const html = await renderExternalPage(filePath, ADMIN_ROOT, resolveDir);
148
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
149
- response.end(html);
207
+ await renderGuardedPage(filePath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs);
150
208
  return;
151
209
  }
152
210
  if(ROUTE_FILES.includes(name)){
@@ -174,6 +232,7 @@ export default config => {
174
232
  const adminDir = join(NODE_MODULES, extName, 'admin');
175
233
  const resolveDir = buildResolveDir(ADMIN_ROOT, url);
176
234
  const method = request.method?.toUpperCase() || 'GET';
235
+ const globalDirs = await adminGlobalDirs();
177
236
 
178
237
  const pageCandidates = subPath.endsWith('/')
179
238
  ? [join(adminDir, subPath, 'index.page.html')]
@@ -185,11 +244,9 @@ export default config => {
185
244
  for(const pagePath of pageCandidates){
186
245
  try {
187
246
  await stat(pagePath);
188
- const html = await renderExternalPage(pagePath, ADMIN_ROOT, resolveDir);
189
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
190
- response.end(html);
191
- return;
192
- } catch { /* try next */ }
247
+ } catch { continue; }
248
+ await renderGuardedPage(pagePath, request, response, ADMIN_ROOT, resolveDir, {}, globalDirs);
249
+ return;
193
250
  }
194
251
 
195
252
  const staticPath = join(adminDir, subPath === '/' ? 'index.html' : subPath);
@@ -210,14 +267,12 @@ export default config => {
210
267
  let walkedStat;
211
268
  try { walkedStat = await stat(walkedPath); } catch { /* not found */ }
212
269
  if(walkedStat?.isDirectory()){
213
- const served = await serveDir(walkedPath, method, request, response, resolveDir, ADMIN_ROOT, params);
270
+ const served = await serveDir(walkedPath, method, request, response, resolveDir, ADMIN_ROOT, params, globalDirs);
214
271
  if(served) return;
215
272
  } else if(walkedStat?.isFile()){
216
273
  const name = walkedPath.split(/[/\\]/).pop();
217
274
  if(name.endsWith('.page.html')){
218
- const html = await renderExternalPage(walkedPath, ADMIN_ROOT, resolveDir);
219
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
220
- response.end(html);
275
+ await renderGuardedPage(walkedPath, request, response, ADMIN_ROOT, resolveDir, params, globalDirs);
221
276
  return;
222
277
  }
223
278
  if(ROUTE_FILES.includes(name)){
@@ -276,9 +331,7 @@ export default config => {
276
331
  } else if(fileStat?.isFile()){
277
332
  const name = filePath.split(/[/\\]/).pop();
278
333
  if(name.endsWith('.page.html')){
279
- const html = await renderExternalPage(filePath, PROJECT_PUBLIC, resolveDir);
280
- response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
281
- response.end(html);
334
+ await renderGuardedPage(filePath, request, response, PROJECT_PUBLIC, resolveDir, params);
282
335
  return;
283
336
  }
284
337
  if(ROUTE_FILES.includes(name)){
@@ -292,7 +345,28 @@ export default config => {
292
345
  }
293
346
  }
294
347
 
295
- return next();
348
+ // File not found in extension's public dir — fall through to consumer page routing
349
+ break;
350
+ }
351
+
352
+ /*
353
+ Consumer public page routing: intercept .page.html before kempo-server SSR handles them
354
+ */
355
+ {
356
+ const resolveDir = buildResolveDir(PROJECT_PUBLIC, url);
357
+ const urlPath = url.replace(/\/+$/, '');
358
+
359
+ // Mirror kempo-server's SSR probe: url.page.html and url/index.page.html
360
+ const candidates = [
361
+ join(PROJECT_PUBLIC, urlPath + '.page.html'),
362
+ join(PROJECT_PUBLIC, urlPath, 'index.page.html'),
363
+ ];
364
+
365
+ for(const pageFilePath of candidates){
366
+ try { await stat(pageFilePath); } catch { continue; }
367
+ await renderGuardedPage(pageFilePath, request, response, PROJECT_PUBLIC, resolveDir);
368
+ return;
369
+ }
296
370
  }
297
371
 
298
372
  next();
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.2.19",
3
+ "version": "4.2.21",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
7
- "dev": "node dev.mjs",
7
+ "dev": "node scripts/dev.js",
8
8
  "build": "node scripts/build.js",
9
9
  "db:generate": "drizzle-kit generate",
10
10
  "db:migrate": "drizzle-kit migrate",
@@ -12,7 +12,10 @@
12
12
  "db:studio": "drizzle-kit studio",
13
13
  "makeAdmin": "node ./scripts/make-admin.js",
14
14
  "removeAdmin": "node ./scripts/removeAdmin.js",
15
- "test:email": "node ./scripts/test-email.js"
15
+ "test:email": "node ./scripts/test-email.js",
16
+ "test": "kempo-test -n",
17
+ "test:node": "kempo-test -n",
18
+ "link:local": "npm install ../kempo-server ../kempo-ui --no-save"
16
19
  },
17
20
  "author": "Dustin Poissant",
18
21
  "license": "MIT",
@@ -44,16 +47,20 @@
44
47
  "dotenv": "^17.2.3",
45
48
  "drizzle-kit": "^0.31.7",
46
49
  "drizzle-orm": "^0.44.7",
47
- "html-minifier-terser": "^7.2.0",
48
50
  "inquirer": "^12.6.1",
49
- "kempo": "^4.2.16",
50
- "kempo-server": "^3.1.0",
51
- "kempo-testing-framework": "^1.4.6",
52
- "kempo-ui": "^0.4.6",
53
51
  "lexical": "^0.42.0",
54
52
  "monaco-editor": "^0.55.1",
55
53
  "postgres": "^3.4.7",
56
- "resend": "^6.5.2",
54
+ "resend": "^6.5.2"
55
+ },
56
+ "peerDependencies": {
57
+ "kempo-css": ">=2.2.5",
58
+ "kempo-server": ">=3.1.4",
59
+ "kempo-ui": ">=0.4.39"
60
+ },
61
+ "devDependencies": {
62
+ "html-minifier-terser": "^7.2.0",
63
+ "kempo-testing-framework": "^1.4.6",
57
64
  "terser": "^5.46.2"
58
65
  },
59
66
  "repository": {
@@ -1,10 +1,10 @@
1
- import { readFile, writeFile } from 'fs/promises';
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import crypto from 'crypto';
5
- import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
5
+ import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
6
6
 
7
- export default async ({ adminDir, name, location, priority, owner, markup }) => {
7
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR, name, location, priority, owner, markup }) => {
8
8
  if(!adminDir){
9
9
  return [{ code: 400, msg: 'Admin directory is required' }, null];
10
10
  }
@@ -37,6 +37,7 @@ export default async ({ adminDir, name, location, priority, owner, markup }) =>
37
37
  };
38
38
 
39
39
  entries.push(entry);
40
+ await mkdir(adminDir, { recursive: true });
40
41
  await writeFile(filePath, serializeEntries(entries) + '\n', 'utf-8');
41
42
 
42
43
  return [null, entry];
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
4
+ import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
5
5
 
6
- export default async ({ adminDir, ids }) => {
6
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR, ids }) => {
7
7
  if(!adminDir){
8
8
  return [{ code: 400, msg: 'Admin directory is required' }, null];
9
9
  }
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
4
+ import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
5
5
 
6
- export default async ({ adminDir, owner }) => {
6
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR, owner }) => {
7
7
  if(!adminDir){
8
8
  return [{ code: 400, msg: 'Admin directory is required' }, null];
9
9
  }
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
4
+ import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
5
5
 
6
- export default async ({ adminDir, id }) => {
6
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR, id }) => {
7
7
  if(!adminDir){
8
8
  return [{ code: 400, msg: 'Admin directory is required' }, null];
9
9
  }
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
4
+ import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
5
5
 
6
- export default async ({ adminDir, id }) => {
6
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR, id }) => {
7
7
  if(!adminDir){
8
8
  return [{ code: 400, msg: 'Admin directory is required' }, null];
9
9
  }
@@ -1,9 +1,9 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { ADMIN_GLOBAL_FILE, parseEntries } from './helpers.js';
4
+ import { ADMIN_GLOBAL_FILE, parseEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
5
5
 
6
- export default async ({ adminDir, id }) => {
6
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR, id }) => {
7
7
  if(!adminDir){
8
8
  return [{ code: 400, msg: 'Admin directory is required' }, null];
9
9
  }
@@ -4,6 +4,17 @@ import { existsSync } from 'fs';
4
4
 
5
5
  const ADMIN_GLOBAL_FILE = 'kempo-global.global.html';
6
6
 
7
+ /*
8
+ Admin global content created through the admin UI is per-installation state, so it lives in the
9
+ consumer's project rather than in kempo's dist/admin — `npm run build` deletes dist/ wholesale and
10
+ reinstalling or upgrading kempo replaces it, either of which would silently discard it.
11
+
12
+ Extension-owned admin globals are NOT stored here. Each extension ships its own *.global.html
13
+ inside its package and the middleware scans it at render time, the same way extension pages are
14
+ resolved, so there is nothing to write on install or clean up on uninstall.
15
+ */
16
+ const ADMIN_GLOBALS_DIR = join(process.cwd(), '.kempo', 'admin-globals');
17
+
7
18
  const parseEntries = raw => {
8
19
  const entries = [];
9
20
  const regex = /<content\s([^>]*)>([\s\S]*?)<\/content>/g;
@@ -44,4 +55,4 @@ const serializeEntries = entries => entries.map(e => {
44
55
  return `<content ${attrs}>\n${e.markup}\n</content>`;
45
56
  }).join('\n');
46
57
 
47
- export { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries };
58
+ export { ADMIN_GLOBAL_FILE, ADMIN_GLOBALS_DIR, parseEntries, serializeEntries };
@@ -1,9 +1,9 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { ADMIN_GLOBAL_FILE, parseEntries } from './helpers.js';
4
+ import { ADMIN_GLOBAL_FILE, parseEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
5
5
 
6
- export default async ({ adminDir }) => {
6
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR } = {}) => {
7
7
  if(!adminDir){
8
8
  return [{ code: 400, msg: 'Admin directory is required' }, null];
9
9
  }
@@ -1,9 +1,9 @@
1
1
  import { readFile, writeFile } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
- import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries } from './helpers.js';
4
+ import { ADMIN_GLOBAL_FILE, parseEntries, serializeEntries, ADMIN_GLOBALS_DIR } from './helpers.js';
5
5
 
6
- export default async ({ adminDir, id, name, location, priority, markup }) => {
6
+ export default async ({ adminDir = ADMIN_GLOBALS_DIR, id, name, location, priority, markup }) => {
7
7
  if(!adminDir){
8
8
  return [{ code: 400, msg: 'Admin directory is required' }, null];
9
9
  }
@@ -1,12 +1,18 @@
1
1
  import { Resend } from 'resend';
2
2
 
3
- const resend = new Resend(process.env.RESEND_API_KEY);
3
+ /*
4
+ Constructed on first send, not at import. The Resend constructor throws when the key is missing,
5
+ and this module is reachable from the server SDK, so building it at module scope made an unset
6
+ RESEND_API_KEY fail every route that transitively imports it rather than just disabling email.
7
+ */
8
+ let resend = null;
9
+ const client = () => (resend ??= new Resend(process.env.RESEND_API_KEY));
4
10
 
5
11
  export default async ({ to, subject, html }) => {
6
12
  if(!process.env.RESEND_API_KEY){
7
13
  return [{ code: 500, msg: 'RESEND_API_KEY not configured' }, null];
8
14
  }
9
-
15
+
10
16
  if(!to){
11
17
  return [{ code: 400, msg: 'Recipient email is required' }, null];
12
18
  }
@@ -22,7 +28,7 @@ export default async ({ to, subject, html }) => {
22
28
  const from = process.env.SMTP_FROM || 'onboarding@resend.dev';
23
29
 
24
30
  try {
25
- const { data, error } = await resend.emails.send({
31
+ const { data, error } = await client().emails.send({
26
32
  from,
27
33
  to,
28
34
  subject,
@@ -1,13 +1,28 @@
1
1
  import db from '../../db/index.js';
2
- import { extension, hook, permission, setting, group } from '../../db/schema.js';
3
- import { eq, inArray } from 'drizzle-orm';
2
+ import { extension, hook, permission, setting, group, groupPermission, userGroup } from '../../db/schema.js';
3
+ import { eq, inArray, like } from 'drizzle-orm';
4
4
  import { join } from 'path';
5
5
  import { pathToFileURL } from 'url';
6
6
  import { sql } from 'drizzle-orm';
7
7
  import triggerHook, { clearHandlerCache } from '../hooks/triggerHook.js';
8
8
  import { invalidateScopeCache } from './scopeCache.js';
9
9
 
10
- export default async ({ name }) => {
10
+ /*
11
+ Uninstall an extension.
12
+
13
+ `purgeData` false (the default) deregisters the extension but leaves everything it created in
14
+ place — its tables and their rows, group memberships, permissions, groups and settings — so
15
+ reinstalling restores the site exactly as it was. This is the right mode for reinstalling to pick
16
+ up a fix.
17
+
18
+ `purgeData` true additionally destroys all of that. For an extension like kempo-blog that means
19
+ every post and comment, so it must always be a deliberate choice rather than a default.
20
+
21
+ Hooks and the extension row are removed either way. A registered hook whose module is no longer
22
+ installed cannot be tolerated: `middleware:before_page` is triggered with `bail: true`, so a
23
+ handler that fails to import now aborts the render and every page 404s.
24
+ */
25
+ export default async ({ name, purgeData = false }) => {
11
26
  if(!name){
12
27
  return [{ code: 400, msg: 'Extension name is required' }, null];
13
28
  }
@@ -32,9 +47,9 @@ export default async ({ name }) => {
32
47
  } catch {}
33
48
 
34
49
  /*
35
- Drop declarative schema tables
50
+ Drop declarative schema tables. Only when purging — these hold the extension's actual content.
36
51
  */
37
- if(kempoConfig.schema){
52
+ if(purgeData && kempoConfig.schema){
38
53
  try {
39
54
  const extRoot = join(process.cwd(), 'node_modules', name);
40
55
  const schemaModule = await import(pathToFileURL(join(extRoot, kempoConfig.schema)).href);
@@ -48,25 +63,43 @@ export default async ({ name }) => {
48
63
  }
49
64
 
50
65
  /*
51
- Remove declarative groups
52
- */
53
- const groupNames = (kempoConfig.groups || []).map(g => g.name);
54
- if(groupNames.length){
55
- await db.delete(group).where(inArray(group.name, groupNames)).catch(() => {});
56
- }
66
+ Remove declarative groups, permissions and settings. Only when purging: these are what a
67
+ reinstall would otherwise restore, and group memberships in particular represent real user
68
+ state — every registered user of kempo-blog belongs to kempo-blog:member.
57
69
 
58
- /*
59
- Remove declarative permissions
70
+ userGroup and groupPermission both reference group.name, and groupPermission also references
71
+ permission.name, none of them cascading. The membership and grant rows therefore have to go
72
+ first or these deletes fail the foreign key — which they used to do silently, leaving every
73
+ uninstalled extension's groups and permissions behind forever.
60
74
  */
61
- const permNames = (kempoConfig.permissions || []).map(p => p.name);
62
- if(permNames.length){
63
- await db.delete(permission).where(inArray(permission.name, permNames)).catch(() => {});
64
- }
75
+ if(purgeData){
76
+ const groupNames = (kempoConfig.groups || []).map(g => g.name);
77
+ const permNames = (kempoConfig.permissions || []).map(p => p.name);
65
78
 
66
- /*
67
- Remove declarative settings (owner-namespaced: "extname:settingname")
68
- */
69
- await db.delete(setting).where(eq(setting.owner, name)).catch(() => {});
79
+ try {
80
+ if(groupNames.length){
81
+ await db.delete(userGroup).where(inArray(userGroup.groupName, groupNames));
82
+ await db.delete(groupPermission).where(inArray(groupPermission.groupName, groupNames));
83
+ }
84
+ if(permNames.length){
85
+ await db.delete(groupPermission).where(inArray(groupPermission.permissionName, permNames));
86
+ }
87
+ if(groupNames.length){
88
+ await db.delete(group).where(inArray(group.name, groupNames));
89
+ }
90
+ if(permNames.length){
91
+ await db.delete(permission).where(inArray(permission.name, permNames));
92
+ }
93
+ } catch(error) {
94
+ return [{ code: 500, msg: `Failed to remove extension groups and permissions: ${error.message}` }, null];
95
+ }
96
+
97
+ /*
98
+ Ownership is encoded in the setting name as "extname:settingname" — the setting table has no
99
+ owner column, so these have to be matched by prefix.
100
+ */
101
+ await db.delete(setting).where(like(setting.name, `${name}:%`));
102
+ }
70
103
 
71
104
  try {
72
105
  await db.delete(hook).where(eq(hook.owner, name));
@@ -3,6 +3,14 @@ import { relative } from 'path';
3
3
  import scanDir from '../fs/scanDir.js';
4
4
  import parseFrontmatter from '../fs/parseFrontmatter.js';
5
5
 
6
+ const parseLocations = content => {
7
+ const locations = [];
8
+ const regex = /<location\s[^/]*?name="([^"]+)"[^/]*?\/>/g;
9
+ let match;
10
+ while((match = regex.exec(content)) !== null) locations.push(match[1]);
11
+ return locations;
12
+ };
13
+
6
14
  export default async ({ rootDir }) => {
7
15
  if(!rootDir){
8
16
  return [{ code: 400, msg: 'Root directory is required' }, null];
@@ -24,7 +32,8 @@ export default async ({ rootDir }) => {
24
32
  disabled: isDisabled,
25
33
  author: meta.author || '',
26
34
  createdAt: meta.created || '',
27
- updatedAt: meta.updated || ''
35
+ updatedAt: meta.updated || '',
36
+ locations: parseLocations(content)
28
37
  };
29
38
  });
30
39
 
@@ -1,28 +1,21 @@
1
1
  import db from '../../db/index.js';
2
2
  import { hook } from '../../db/schema.js';
3
3
  import { eq } from 'drizzle-orm';
4
- import { createRequire } from 'module';
5
- import { dirname, join } from 'path';
4
+ import { join } from 'path';
5
+ import { pathToFileURL } from 'url';
6
6
 
7
7
  const handlerCache = new Map();
8
8
 
9
9
  export const clearHandlerCache = () => handlerCache.clear();
10
10
 
11
- const requireFromProject = createRequire(join(process.cwd(), 'package.json'));
12
-
13
11
  const resolveHandler = (owner, callback) => {
14
12
  if(callback.startsWith('./') || callback.startsWith('../')){
15
- try {
16
- const pkgPath = requireFromProject.resolve(`${owner}/package.json`);
17
- return join(dirname(pkgPath), callback);
18
- } catch {
19
- return null;
20
- }
13
+ return join(process.cwd(), 'node_modules', owner, callback);
21
14
  }
22
15
  return callback;
23
16
  };
24
17
 
25
- export default async (event, data = {}) => {
18
+ export default async (event, data = {}, { bail = false } = {}) => {
26
19
  if(!event){
27
20
  return [{ code: 400, msg: 'Event is required' }, null];
28
21
  }
@@ -38,10 +31,17 @@ export default async (event, data = {}) => {
38
31
  let handler = handlerCache.get(handlerPath);
39
32
  if(!handler){
40
33
  try {
41
- const mod = await import(handlerPath);
34
+ const mod = await import(pathToFileURL(handlerPath).href);
42
35
  handler = mod.default || mod;
43
36
  handlerCache.set(handlerPath, handler);
44
- } catch {
37
+ } catch(err) {
38
+ /*
39
+ Never fail silently: a hook that cannot load is indistinguishable from one that
40
+ allowed the request. For bailing events (guards) that would fail open, so rethrow.
41
+ */
42
+ console.error(`[kempo] Hook handler failed to load for "${event}" (${h.owner}): ${err.message}`);
43
+ if(bail) throw err;
44
+ results.push({ hookId: h.id, owner: h.owner, error: err.message });
45
45
  continue;
46
46
  }
47
47
  }
@@ -50,12 +50,14 @@ export default async (event, data = {}) => {
50
50
  const result = await handler(data);
51
51
  results.push({ hookId: h.id, owner: h.owner, result });
52
52
  } catch(err) {
53
+ if(bail) throw err;
53
54
  results.push({ hookId: h.id, owner: h.owner, error: err.message });
54
55
  }
55
56
  }
56
57
 
57
58
  return [null, { results }];
58
59
  } catch(error) {
60
+ if(bail) throw error;
59
61
  return [{ code: 500, msg: 'Failed to trigger hooks' }, null];
60
62
  }
61
63
  };