kempo 4.6.1 → 4.7.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 (188) hide show
  1. package/README.md +5 -0
  2. package/app-public/account/index.page.html +2 -0
  3. package/app-public/nav.fragment.html +2 -1
  4. package/app-templates/emails/email.template.html +14 -0
  5. package/app-templates/emails/reset-password.page.html +19 -0
  6. package/app-templates/emails/test.page.html +21 -0
  7. package/app-templates/emails/verify-email.page.html +18 -0
  8. package/bin/cli.js +3 -0
  9. package/dist/admin/components/Dashboard.js +2 -2
  10. package/dist/admin/components/Dashboard.js.map +1 -1
  11. package/dist/admin/components/Menus.js +103 -0
  12. package/dist/admin/components/Menus.js.map +1 -0
  13. package/dist/admin/components/tableControls/ExtensionDetails.js +1 -1
  14. package/dist/admin/components/tableControls/ExtensionDetails.js.map +1 -1
  15. package/dist/admin/content/menus/index.page.html +11 -0
  16. package/dist/admin/extensions/browse/index.page.html +4 -1
  17. package/dist/admin/extensions/index.page.html +1 -0
  18. package/dist/admin/nav.fragment.html +6 -0
  19. package/dist/kempo/api/extensions/POST.js +1 -1
  20. package/dist/kempo/api/extensions/POST.js.map +1 -1
  21. package/dist/kempo/api/extensions/known/GET.js +1 -1
  22. package/dist/kempo/api/extensions/known/GET.js.map +1 -1
  23. package/dist/kempo/api/extensions/rating/PUT.js +1 -1
  24. package/dist/kempo/api/extensions/rating/PUT.js.map +1 -1
  25. package/dist/kempo/api/fragments/DELETE.js +1 -1
  26. package/dist/kempo/api/fragments/DELETE.js.map +1 -1
  27. package/dist/kempo/api/fragments/GET.js +1 -1
  28. package/dist/kempo/api/fragments/GET.js.map +1 -1
  29. package/dist/kempo/api/fragments/POST.js +1 -1
  30. package/dist/kempo/api/fragments/POST.js.map +1 -1
  31. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  32. package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
  33. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  34. package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
  35. package/dist/kempo/api/fragments/file/GET.js +1 -1
  36. package/dist/kempo/api/fragments/file/GET.js.map +1 -1
  37. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  38. package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
  39. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  40. package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
  41. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  42. package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
  43. package/dist/kempo/api/globals/DELETE.js +1 -1
  44. package/dist/kempo/api/globals/DELETE.js.map +1 -1
  45. package/dist/kempo/api/globals/GET.js +1 -1
  46. package/dist/kempo/api/globals/GET.js.map +1 -1
  47. package/dist/kempo/api/globals/POST.js +1 -1
  48. package/dist/kempo/api/globals/POST.js.map +1 -1
  49. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  50. package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
  51. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  52. package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
  53. package/dist/kempo/api/globals/entry/GET.js +1 -1
  54. package/dist/kempo/api/globals/entry/GET.js.map +1 -1
  55. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  56. package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
  57. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  58. package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
  59. package/dist/kempo/api/menus/DELETE.js +2 -0
  60. package/dist/kempo/api/menus/DELETE.js.map +1 -0
  61. package/dist/kempo/api/menus/GET.js +2 -0
  62. package/dist/kempo/api/menus/GET.js.map +1 -0
  63. package/dist/kempo/api/menus/POST.js +2 -0
  64. package/dist/kempo/api/menus/POST.js.map +1 -0
  65. package/dist/kempo/api/menus/adopt/PUT.js +2 -0
  66. package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
  67. package/dist/kempo/api/menus/items/DELETE.js +2 -0
  68. package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
  69. package/dist/kempo/api/menus/items/POST.js +2 -0
  70. package/dist/kempo/api/menus/items/POST.js.map +1 -0
  71. package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
  72. package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
  73. package/dist/kempo/api/menus/items/order/PUT.js +2 -0
  74. package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
  75. package/dist/kempo/api/menus/location/PUT.js +2 -0
  76. package/dist/kempo/api/menus/location/PUT.js.map +1 -0
  77. package/dist/kempo/api/pages/DELETE.js +1 -1
  78. package/dist/kempo/api/pages/DELETE.js.map +1 -1
  79. package/dist/kempo/api/pages/GET.js +1 -1
  80. package/dist/kempo/api/pages/GET.js.map +1 -1
  81. package/dist/kempo/api/pages/POST.js +1 -1
  82. package/dist/kempo/api/pages/POST.js.map +1 -1
  83. package/dist/kempo/api/pages/directories/GET.js +1 -1
  84. package/dist/kempo/api/pages/directories/GET.js.map +1 -1
  85. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  86. package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
  87. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  88. package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
  89. package/dist/kempo/api/pages/file/GET.js +1 -1
  90. package/dist/kempo/api/pages/file/GET.js.map +1 -1
  91. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  92. package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
  93. package/dist/kempo/api/pages/file/PUT.js +1 -1
  94. package/dist/kempo/api/pages/file/PUT.js.map +1 -1
  95. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  96. package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
  97. package/dist/kempo/api/pages/rescan/POST.js +1 -1
  98. package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
  99. package/dist/kempo/api/pages/search/GET.js +1 -1
  100. package/dist/kempo/api/pages/search/GET.js.map +1 -1
  101. package/dist/kempo/api/pages/templates/GET.js +1 -1
  102. package/dist/kempo/api/pages/templates/GET.js.map +1 -1
  103. package/dist/kempo/api/templates/DELETE.js +1 -1
  104. package/dist/kempo/api/templates/DELETE.js.map +1 -1
  105. package/dist/kempo/api/templates/GET.js +1 -1
  106. package/dist/kempo/api/templates/GET.js.map +1 -1
  107. package/dist/kempo/api/templates/POST.js +1 -1
  108. package/dist/kempo/api/templates/POST.js.map +1 -1
  109. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  110. package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
  111. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  112. package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
  113. package/dist/kempo/api/templates/file/GET.js +1 -1
  114. package/dist/kempo/api/templates/file/GET.js.map +1 -1
  115. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  116. package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
  117. package/dist/kempo/api/templates/file/PUT.js +1 -1
  118. package/dist/kempo/api/templates/file/PUT.js.map +1 -1
  119. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  120. package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
  121. package/dist/kempo/components/DashboardWidget.js +4 -4
  122. package/dist/kempo/components/DashboardWidget.js.map +1 -1
  123. package/dist/kempo/icons/campaign.svg +1 -0
  124. package/dist/kempo/icons/fields.svg +1 -0
  125. package/dist/kempo/icons/open_in_new.svg +1 -0
  126. package/dist/kempo/sdk.js +1 -1
  127. package/dist/kempo/sdk.js.map +1 -1
  128. package/middleware/kempo.js +48 -12
  129. package/package.json +2 -2
  130. package/server/db/schema.js +24 -0
  131. package/server/sdk.js +21 -0
  132. package/server/utils/auth/requestPasswordReset.js +9 -3
  133. package/server/utils/auth/verifyEmail.js +10 -1
  134. package/server/utils/dashboard/coreWidgets.js +1 -0
  135. package/server/utils/dashboard/helpers.js +6 -0
  136. package/server/utils/email/renderEmailTemplate.js +63 -0
  137. package/server/utils/email/sendEmailFromTemplate.js +9 -27
  138. package/server/utils/extensions/aliases.js +71 -0
  139. package/server/utils/extensions/checkClaims.js +32 -0
  140. package/server/utils/extensions/claims.js +54 -0
  141. package/server/utils/extensions/createTablesFromSchema.js +13 -20
  142. package/server/utils/extensions/enableExtensionTree.js +8 -0
  143. package/server/utils/extensions/{getMarketplaceClient.js → getSiteMarketplaceClient.js} +1 -1
  144. package/server/utils/extensions/installExtension.js +32 -0
  145. package/server/utils/extensions/installFromCatalog.js +1 -1
  146. package/server/utils/extensions/installPackage.js +1 -1
  147. package/server/utils/extensions/{catalogClient.js → marketplaceClient.js} +4 -1
  148. package/server/utils/extensions/readKempoConfig.js +2 -2
  149. package/server/utils/extensions/scopeCache.js +14 -1
  150. package/server/utils/extensions/scopes.js +72 -0
  151. package/server/utils/extensions/uninstallExtensionTree.js +2 -1
  152. package/server/utils/extensions/updateExtension.js +35 -0
  153. package/server/utils/global-content/createGlobalContent.js +8 -18
  154. package/server/utils/global-content/deleteGlobalContent.js +1 -16
  155. package/server/utils/global-content/disableGlobalContent.js +1 -16
  156. package/server/utils/global-content/enableGlobalContent.js +1 -16
  157. package/server/utils/global-content/helpers.js +40 -6
  158. package/server/utils/global-content/listGlobalContent.js +2 -2
  159. package/server/utils/global-content/setGlobalContentLocked.js +1 -16
  160. package/server/utils/global-content/updateGlobalContent.js +1 -16
  161. package/server/utils/menus/adoptMenuItem.js +58 -0
  162. package/server/utils/menus/assignMenuLocation.js +47 -0
  163. package/server/utils/menus/deleteMenu.js +27 -0
  164. package/server/utils/menus/deleteMenuItems.js +27 -0
  165. package/server/utils/menus/findMenuLocations.js +41 -0
  166. package/server/utils/menus/findNavItems.js +74 -0
  167. package/server/utils/menus/findPages.js +73 -0
  168. package/server/utils/menus/listMenus.js +103 -0
  169. package/server/utils/menus/markup.js +55 -0
  170. package/server/utils/menus/reorderMenuItems.js +36 -0
  171. package/server/utils/menus/saveMenu.js +54 -0
  172. package/server/utils/menus/saveMenuItem.js +122 -0
  173. package/server/utils/menus/setMenuItemEnabled.js +31 -0
  174. package/server/utils/menus/store.js +90 -0
  175. package/server/utils/scheduler/helpers.js +36 -0
  176. package/server/utils/scheduler/listJobs.js +11 -0
  177. package/server/utils/scheduler/registerJobs.js +37 -0
  178. package/server/utils/scheduler/runJobs.js +90 -0
  179. package/server/utils/scheduler/scheduler.js +37 -0
  180. package/server/utils/scheduler/unregisterJobs.js +16 -0
  181. package/server/utils/settings/ensureEncryptionKey.js +132 -0
  182. package/server/utils/settings/secretCrypto.js +5 -1
  183. package/dist/kempo/icons/create_new_folder.svg +0 -1
  184. package/dist/kempo/icons/credit_card.svg +0 -1
  185. package/dist/kempo/icons/login.svg +0 -1
  186. package/dist/kempo/icons/payments.svg +0 -1
  187. package/dist/kempo/icons/toll.svg +0 -1
  188. package/dist/kempo/icons/wallet.svg +0 -1
@@ -5,11 +5,16 @@ 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 { LEXICAL_BASE, bundleFileName as lexicalBundleFileName } from '../server/utils/lexical/packages.js';
8
- import { getEnabledExtensions } from '../server/utils/extensions/scopeCache.js';
8
+ import { getEnabledExtensions, onExtensionsChanged } from '../server/utils/extensions/scopeCache.js';
9
+ import { resyncMenus } from '../server/utils/menus/store.js';
9
10
  import readKempoConfig from '../server/utils/extensions/readKempoConfig.js';
10
11
  import { extensionAdminDirs, extensionPublicDirs } from '../server/utils/extensions/contentDirs.js';
11
12
  import triggerHook from '../server/utils/hooks/triggerHook.js';
12
13
  import { ADMIN_GLOBALS_DIR } from '../server/utils/admin-global-content/helpers.js';
14
+ import { startScheduler } from '../server/utils/scheduler/scheduler.js';
15
+ import { ensureEncryptionKey } from '../server/utils/settings/ensureEncryptionKey.js';
16
+ import { resolveAlias } from '../server/utils/extensions/aliases.js';
17
+ import { normalizeScopes } from '../server/utils/extensions/scopes.js';
13
18
 
14
19
  const MIME_TYPES = {
15
20
  '.js': 'application/javascript',
@@ -177,10 +182,30 @@ const serveDir = async (dirPath, method, request, response, resolveDir, rootDir,
177
182
  };
178
183
 
179
184
  export default config => {
185
+ startScheduler();
186
+
187
+ /*
188
+ The key that encrypts stored credentials exists before any request needs it, generated into .env the
189
+ first time when the site has none. Opt out with `autoEncryptionKey: false` in the server config or
190
+ KEMPO_AUTO_ENCRYPTION_KEY=off, and never in the in-memory test database.
191
+ */
192
+ const encryptionKeyReady = config.autoEncryptionKey === false || process.env.KEMPO_AUTO_ENCRYPTION_KEY === 'off' || process.env.KEMPO_DB === 'pglite'
193
+ ? Promise.resolve()
194
+ : ensureEncryptionKey().then(([error]) => {
195
+ if(error) console.error(`[kempo] ${error.msg}`);
196
+ });
180
197
  const PROJECT_PUBLIC = config.rootPath || join(process.cwd(), 'public');
181
198
  const ADMIN_ROOT = resolveAdminRoot(config.adminRoot);
182
199
 
200
+ /*
201
+ A menu holds a copy of what an extension supplied, so it is brought up to date whenever extensions are
202
+ installed, enabled, disabled or removed: what an extension supplied leaves the site with it. Not at
203
+ start, when the database may not be answering yet and every extension would look absent.
204
+ */
205
+ onExtensionsChanged(() => resyncMenus(PROJECT_PUBLIC));
206
+
183
207
  return async (request, response, next) => {
208
+ await encryptionKeyReady;
184
209
  const { path } = request;
185
210
  const url = request.url.split('?')[0];
186
211
 
@@ -394,23 +419,34 @@ export default config => {
394
419
  */
395
420
  const publicContentDirs = await extensionPublicDirs();
396
421
 
422
+ const loaded = [];
397
423
  for(const ext of extensions){
398
424
  const extRoot = join(NODE_MODULES, ext.name);
399
- let kempo;
400
425
  try {
401
- ({ kempo } = await readKempoConfig(extRoot));
402
- } catch {
403
- continue;
404
- }
426
+ loaded.push({ extRoot, kempo: (await readKempoConfig(extRoot)).kempo });
427
+ } catch { /* not readable: nothing to route */ }
428
+ }
405
429
 
406
- const scope = kempo['public-scope'];
407
- if(!scope) continue;
430
+ /*
431
+ An extension may answer on a short address it declares in `aliases` (`/cart` for `/commerce/cart`);
432
+ the routing below then treats the request as if it had arrived at the real address. A page the
433
+ site has of its own at that address always wins (see server/utils/extensions/aliases.js).
434
+ */
435
+ const routedUrl = await resolveAlias(url, loaded, PROJECT_PUBLIC);
408
436
 
409
- const scopePrefix = `/${scope}`;
410
- if(url !== scopePrefix && !url.startsWith(`${scopePrefix}/`)) continue;
437
+ for(const { extRoot, kempo } of loaded){
438
+ /*
439
+ An extension may claim several scopes, each serving a folder of its package ("public-scope" serves
440
+ public/, "public-scopes" names others). Installing or enabling one that claims an address another
441
+ enabled extension has is refused, so at most one extension matches any request.
442
+ */
443
+ const [, scopes] = normalizeScopes(kempo);
444
+ const claimed = scopes.find(({ scope }) => routedUrl === `/${scope}` || routedUrl.startsWith(`/${scope}/`));
445
+ if(!claimed) continue;
411
446
 
412
- const publicDir = join(extRoot, 'public');
413
- const subPath = url.slice(scopePrefix.length) || '/';
447
+ const scopePrefix = `/${claimed.scope}`;
448
+ const publicDir = join(extRoot, claimed.dir);
449
+ const subPath = routedUrl.slice(scopePrefix.length) || '/';
414
450
  const method = request.method?.toUpperCase() || 'GET';
415
451
  const resolveDir = buildResolveDir(PROJECT_PUBLIC, url);
416
452
  const segments = subPath.replace(/^\//, '').split('/').filter(Boolean);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.6.1",
3
+ "version": "4.7.0",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -60,7 +60,7 @@
60
60
  "peerDependencies": {
61
61
  "kempo-css": ">=2.2.5",
62
62
  "kempo-server": ">=3.4.0",
63
- "kempo-ui": ">=0.5.1"
63
+ "kempo-ui": ">=0.6.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@electric-sql/pglite": "^0.5.9",
@@ -115,6 +115,29 @@ export const extension = pgTable('extension', {
115
115
  updatedAt: timestamp('updatedAt').notNull(),
116
116
  });
117
117
 
118
+ /*
119
+ Scheduled jobs: recurring work an extension declares in its kempo-config.json `jobs`. A row per job
120
+ is the schedule and the lease: `nextRunAt` is when it is next due and `lockedUntil` is held while a
121
+ process runs it, so several server processes sharing one database run each job once per interval.
122
+ `lastStatus` is 'ok' or 'error'.
123
+ */
124
+ export const scheduledJob = pgTable('scheduledJob', {
125
+ id: text('id').primaryKey(),
126
+ owner: text('owner').notNull(),
127
+ name: text('name').notNull(),
128
+ everyMs: integer('everyMs').notNull(),
129
+ callback: text('callback').notNull(),
130
+ nextRunAt: timestamp('nextRunAt').notNull(),
131
+ lockedUntil: timestamp('lockedUntil'),
132
+ lastRunAt: timestamp('lastRunAt'),
133
+ lastStatus: text('lastStatus'),
134
+ lastError: text('lastError'),
135
+ createdAt: timestamp('createdAt').notNull(),
136
+ }, table => [
137
+ index('scheduledJob_owner_idx').on(table.owner),
138
+ index('scheduledJob_next_idx').on(table.nextRunAt),
139
+ ]);
140
+
118
141
  /*
119
142
  Realtime System
120
143
  */
@@ -203,6 +226,7 @@ export const dashboardWidget = pgTable('dashboardWidget', {
203
226
  title: text('title').notNull(),
204
227
  description: text('description'),
205
228
  icon: text('icon'),
229
+ iconSrc: text('iconSrc'),
206
230
  element: text('element').notNull(),
207
231
  module: text('module').notNull(),
208
232
  defaultWidth: integer('defaultWidth').notNull().default(2),
package/server/sdk.js CHANGED
@@ -144,6 +144,16 @@ export { default as enableGlobalContent } from './utils/global-content/enableGlo
144
144
  export { default as getGlobalContent } from './utils/global-content/getGlobalContent.js';
145
145
  export { default as listGlobalContent } from './utils/global-content/listGlobalContent.js';
146
146
  export { default as setGlobalContentLocked } from './utils/global-content/setGlobalContentLocked.js';
147
+ export { default as listMenus } from './utils/menus/listMenus.js';
148
+ export { default as findNavItems } from './utils/menus/findNavItems.js';
149
+ export { default as saveMenu } from './utils/menus/saveMenu.js';
150
+ export { default as deleteMenu } from './utils/menus/deleteMenu.js';
151
+ export { default as assignMenuLocation } from './utils/menus/assignMenuLocation.js';
152
+ export { default as saveMenuItem } from './utils/menus/saveMenuItem.js';
153
+ export { default as deleteMenuItems } from './utils/menus/deleteMenuItems.js';
154
+ export { default as setMenuItemEnabled } from './utils/menus/setMenuItemEnabled.js';
155
+ export { default as reorderMenuItems } from './utils/menus/reorderMenuItems.js';
156
+ export { default as adoptMenuItem } from './utils/menus/adoptMenuItem.js';
147
157
  export { default as updateGlobalContent } from './utils/global-content/updateGlobalContent.js';
148
158
 
149
159
  /*
@@ -159,12 +169,23 @@ export { default as getAdminGlobalContent } from './utils/admin-global-content/g
159
169
  export { default as listAdminGlobalContent } from './utils/admin-global-content/listAdminGlobalContent.js';
160
170
  export { default as updateAdminGlobalContent } from './utils/admin-global-content/updateAdminGlobalContent.js';
161
171
 
172
+ /*
173
+ Scheduled Jobs
174
+ */
175
+
176
+ export { default as listJobs } from './utils/scheduler/listJobs.js';
177
+ export { default as registerJobs } from './utils/scheduler/registerJobs.js';
178
+ export { default as unregisterJobs } from './utils/scheduler/unregisterJobs.js';
179
+ export { runDueJobs, runJob } from './utils/scheduler/runJobs.js';
180
+ export { startScheduler, stopScheduler } from './utils/scheduler/scheduler.js';
181
+
162
182
  /*
163
183
  Email
164
184
  */
165
185
 
166
186
  export { default as sendEmail } from './utils/email/sendEmail.js';
167
187
  export { default as sendEmailFromTemplate } from './utils/email/sendEmailFromTemplate.js';
188
+ export { default as renderEmailTemplate } from './utils/email/renderEmailTemplate.js';
168
189
 
169
190
  /*
170
191
  Filesystem Utilities
@@ -7,7 +7,13 @@ import getSetting from '../settings/getSetting.js';
7
7
 
8
8
  const generateToken = () => crypto.randomBytes(32).toString('hex');
9
9
 
10
- export default async ({ email }) => {
10
+ /*
11
+ `subject` and `template` let a caller send its own wording with the same link, for example an
12
+ extension that creates an account for someone at checkout and wants the email to say "set your
13
+ password". `template` is a name for sendEmailFromTemplate, including an extension's "owner/name".
14
+ The template receives `name` and `resetLink`.
15
+ */
16
+ export default async ({ email, subject = 'Reset Your Password', template = 'reset-password' }) => {
11
17
  if(!email){
12
18
  return [{ code: 400, msg: 'Email is required' }, null];
13
19
  }
@@ -42,8 +48,8 @@ export default async ({ email }) => {
42
48
 
43
49
  const [emailError] = await sendEmailFromTemplate({
44
50
  to: email,
45
- subject: 'Reset Your Password',
46
- template: 'reset-password',
51
+ subject,
52
+ template,
47
53
  data: {
48
54
  name,
49
55
  resetLink,
@@ -1,6 +1,7 @@
1
1
  import db from '../../db/index.js';
2
2
  import { verificationToken, user } from '../../db/schema.js';
3
3
  import { eq, and, gt } from 'drizzle-orm';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  export default async ({ token }) => {
6
7
  if(!token){
@@ -36,7 +37,15 @@ export default async ({ token }) => {
36
37
  .delete(verificationToken)
37
38
  .where(eq(verificationToken.token, token));
38
39
 
39
- return [null, {
40
+ /*
41
+ Someone has just proved they own this address, which is what an extension needs before it hands
42
+ them something that was only ever tied to the address (guest orders, say). Fires
43
+ `user:email_verified` with `{ userId, email }`.
44
+ */
45
+ const [verified] = await db.select({ email: user.email }).from(user).where(eq(user.id, tokenRecord.userId)).limit(1);
46
+ await triggerHook('user:email_verified', { userId: tokenRecord.userId, email: verified?.email ?? null });
47
+
48
+ return [null, {
40
49
  success: true,
41
50
  userId: tokenRecord.userId
42
51
  }];
@@ -4,6 +4,7 @@ const core = widget => ({
4
4
  owner: CORE_OWNER,
5
5
  description: null,
6
6
  icon: null,
7
+ iconSrc: null,
7
8
  defaultWidth: 2,
8
9
  defaultHeight: 1,
9
10
  minWidth: 1,
@@ -24,6 +24,7 @@ export const normalizeWidget = ({
24
24
  title,
25
25
  description = null,
26
26
  icon = null,
27
+ iconSrc = null,
27
28
  element,
28
29
  module,
29
30
  defaultWidth = 2,
@@ -57,6 +58,10 @@ export const normalizeWidget = ({
57
58
  return [{ code: 400, msg: 'Icon must be an icon name' }, null];
58
59
  }
59
60
 
61
+ if(iconSrc !== null && !(isLocalPath(iconSrc) && iconSrc.length <= 200)){
62
+ return [{ code: 400, msg: 'iconSrc must be the path to an icon file on this site, beginning with a single "/"' }, null];
63
+ }
64
+
60
65
  if(typeof element !== 'string' || !ELEMENT_PATTERN.test(element)){
61
66
  return [{ code: 400, msg: 'Element must be a custom element tag name such as "my-ext-widget"' }, null];
62
67
  }
@@ -128,6 +133,7 @@ export const normalizeWidget = ({
128
133
  title: title.trim(),
129
134
  description,
130
135
  icon,
136
+ iconSrc,
131
137
  element,
132
138
  module,
133
139
  defaultWidth,
@@ -0,0 +1,63 @@
1
+ import { existsSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ import { renderPage, renderExternalPage } from 'kempo-server/templating';
5
+ import shake from 'kempo-css/shake';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ /*
10
+ Renders an email template to HTML, without sending it.
11
+
12
+ A plain name ("verify-email") is looked up in the site's `templates/emails`, then in kempo's own.
13
+
14
+ A name with an owner ("my-extension/order-paid") is an extension's template. The site may override
15
+ it at `templates/emails/my-extension/order-paid.page.html`; otherwise it is the extension's own
16
+ `emails/order-paid.page.html`. Either way the layout (`<page template="email">`) is the site's
17
+ `templates/emails/email.template.html` when it has one, and kempo's otherwise, so every email a site
18
+ sends looks the same.
19
+
20
+ Returns [error, html].
21
+ */
22
+ export default async ({ template, data = {} } = {}) => {
23
+ if(!template){
24
+ return [{ code: 400, msg: 'Template name is required' }, null];
25
+ }
26
+ if(!/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/.test(template)){
27
+ return [{ code: 400, msg: `Invalid email template name: ${template}` }, null];
28
+ }
29
+
30
+ const consumerDir = join(process.cwd(), 'templates/emails');
31
+ const moduleDir = join(__dirname, '../../../app-templates/emails');
32
+ const [owner, name] = template.includes('/') ? template.split('/') : [null, template];
33
+
34
+ let pagePath;
35
+ let rootDir;
36
+ if(owner){
37
+ const override = join(consumerDir, owner, `${name}.page.html`);
38
+ pagePath = existsSync(override) ? override : join(process.cwd(), 'node_modules', owner, 'emails', `${name}.page.html`);
39
+ rootDir = existsSync(join(consumerDir, 'email.template.html')) ? consumerDir : moduleDir;
40
+ } else {
41
+ rootDir = existsSync(join(consumerDir, `${name}.page.html`)) ? consumerDir : moduleDir;
42
+ pagePath = join(rootDir, `${name}.page.html`);
43
+ }
44
+
45
+ if(!existsSync(pagePath)){
46
+ return [{ code: 404, msg: `Email template not found: ${template}` }, null];
47
+ }
48
+
49
+ const themePath = join(process.cwd(), 'public/styles.css');
50
+ const themeOpts = existsSync(themePath) ? { theme: themePath } : {};
51
+
52
+ try {
53
+ let html = owner
54
+ ? await renderExternalPage(pagePath, rootDir, rootDir, {}, data)
55
+ : await renderPage(pagePath, rootDir, {}, data);
56
+ const css = shake(html, { ...themeOpts, colorMode: 'light' });
57
+ html = html.replace('</head>', `<style>${css}</style></head>`);
58
+ return [null, html];
59
+ } catch(error){
60
+ console.error('Email render error:', error);
61
+ return [{ code: 500, msg: 'Failed to render the email template' }, null];
62
+ }
63
+ };
@@ -1,13 +1,10 @@
1
- import { existsSync } from 'fs';
2
- import { fileURLToPath } from 'url';
3
- import { dirname, join } from 'path';
4
- import { renderPage } from 'kempo-server/templating';
5
- import shake from 'kempo-css/shake';
1
+ import renderEmailTemplate from './renderEmailTemplate.js';
6
2
  import sendEmail from './sendEmail.js';
7
3
 
8
- const __filename = fileURLToPath(import.meta.url);
9
- const __dirname = dirname(__filename);
10
-
4
+ /*
5
+ `template` is a plain name for kempo's and the site's templates, or "owner/name" for an extension's;
6
+ see renderEmailTemplate.js.
7
+ */
11
8
  export default async ({ to, subject, template, data = {} }) => {
12
9
  if(!to){
13
10
  return [{ code: 400, msg: 'Recipient email is required' }, null];
@@ -21,25 +18,10 @@ export default async ({ to, subject, template, data = {} }) => {
21
18
  return [{ code: 400, msg: 'Template name is required' }, null];
22
19
  }
23
20
 
24
- const consumerDir = join(process.cwd(), 'templates/emails');
25
- const moduleDir = join(__dirname, '../../../app-templates/emails');
26
- const rootDir = existsSync(join(consumerDir, `${template}.page.html`)) ? consumerDir : moduleDir;
27
- const pagePath = join(rootDir, `${template}.page.html`);
28
-
29
- if(!existsSync(pagePath)){
30
- return [{ code: 404, msg: `Email template not found: ${template}` }, null];
21
+ const [error, html] = await renderEmailTemplate({ template, data });
22
+ if(error){
23
+ return [error, null];
31
24
  }
32
25
 
33
- const themePath = join(process.cwd(), 'public/styles.css');
34
- const themeOpts = existsSync(themePath) ? { theme: themePath } : {};
35
-
36
- try {
37
- let html = await renderPage(pagePath, rootDir, {}, data);
38
- const css = shake(html, { ...themeOpts, colorMode: 'light' });
39
- html = html.replace('</head>', `<style>${css}</style></head>`);
40
- return await sendEmail({ to, subject, html });
41
- } catch(error){
42
- console.error('Email render error:', error);
43
- return [{ code: 500, msg: 'Failed to send email from template' }, null];
44
- }
26
+ return await sendEmail({ to, subject, html });
45
27
  };
@@ -0,0 +1,71 @@
1
+ import { join } from 'path';
2
+ import { stat } from 'fs/promises';
3
+ import { scopeNames } from './scopes.js';
4
+
5
+ /*
6
+ Aliases let an extension answer on a short, familiar address instead of only under its public scope:
7
+ an extension with the scope `commerce` can declare `"aliases": { "/cart": "/commerce/cart" }` and its
8
+ cart page is then reachable at /cart/ too. The address stays as typed (it is a rewrite, not a redirect).
9
+
10
+ An alias must point inside one of the extension's own scopes, so an extension can only ever give a
11
+ second address to its own pages. It never shadows a page the site has of its own at that address, and
12
+ it cannot take over anything under /admin or /api. The alias address is a claim, like a scope, and no
13
+ other enabled extension may claim it (see claims.js).
14
+ */
15
+
16
+ const RESERVED = /^\/(admin|api|kempo|kempo-css|kempo-ui)(\/|$)/;
17
+
18
+ const trim = value => String(value).replace(/\/+$/, '');
19
+
20
+ /*
21
+ Checks the `aliases` an extension declares: { "/from": "/scope/to" }. `scope` is the extension's
22
+ scope, or the list of its scopes. Returns [error, aliases].
23
+ */
24
+ export const normalizeAliases = (aliases, scope) => {
25
+ const scopes = [scope].flat().filter(Boolean);
26
+ if(aliases === undefined || aliases === null) return [null, {}];
27
+ if(typeof aliases !== 'object' || Array.isArray(aliases)) return [{ code: 400, msg: 'aliases must be an object of address to address' }, null];
28
+
29
+ const result = {};
30
+ for(const [from, to] of Object.entries(aliases)){
31
+ const source = trim(from);
32
+ const target = trim(to);
33
+ if(!source.startsWith('/') || source === '' || RESERVED.test(source)) return [{ code: 400, msg: `Alias "${from}" must be an address such as /cart, outside /admin and /api` }, null];
34
+ if(!scopes.some(name => target === `/${name}` || target.startsWith(`/${name}/`))) return [{ code: 400, msg: `Alias "${from}" must point inside the extension's own scope (/${scopes.join(', /') || '…'}/…)` }, null];
35
+ result[source] = target;
36
+ }
37
+ return [null, result];
38
+ };
39
+
40
+ /* True when the site itself has a page, directory or file at this address, which always wins over an alias. */
41
+ const siteHasPath = async (publicDir, address) => {
42
+ const base = join(publicDir, ...address.split('/').filter(Boolean));
43
+ const candidates = [base, `${base}.page.html`, `${base}.html`, join(base, 'index.page.html'), join(base, 'index.html'), join(base, 'index.js'), join(base, 'GET.js')];
44
+ for(const candidate of candidates){
45
+ try {
46
+ const info = await stat(candidate);
47
+ if(candidate === base ? info.isFile() : true) return true;
48
+ } catch { /* not there */ }
49
+ }
50
+ return false;
51
+ };
52
+
53
+ /*
54
+ The address to serve for `url`: the alias target when an enabled extension declares one for it and
55
+ the site has no page there, otherwise `url` unchanged. `loaded` is [{ kempo }] for the enabled extensions.
56
+ */
57
+ export const resolveAlias = async (url, loaded, publicDir) => {
58
+ const clean = trim(url) || '/';
59
+ for(const { kempo } of loaded){
60
+ const scopes = scopeNames(kempo);
61
+ if(!scopes.length || !kempo.aliases) continue;
62
+ const [error, aliases] = normalizeAliases(kempo.aliases, scopes);
63
+ if(error) continue;
64
+ for(const [from, to] of Object.entries(aliases)){
65
+ if(clean !== from && !clean.startsWith(`${from}/`)) continue;
66
+ if(await siteHasPath(publicDir, from)) return url;
67
+ return to + url.slice(from.length);
68
+ }
69
+ }
70
+ return url;
71
+ };
@@ -0,0 +1,32 @@
1
+ import { join } from 'path';
2
+ import readKempoConfig from './readKempoConfig.js';
3
+ import { getEnabledExtensions } from './scopeCache.js';
4
+ import { findClaimConflicts, describeConflict } from './claims.js';
5
+
6
+ const configOf = async name => {
7
+ try {
8
+ return { name, kempo: (await readKempoConfig(join(process.cwd(), 'node_modules', name))).kempo };
9
+ } catch {
10
+ return null;
11
+ }
12
+ };
13
+
14
+ /*
15
+ Refuses to put an extension on the site when another one that is on already uses an address it
16
+ claims. "names" are the extensions about to be switched on (installed, or enabled together with what
17
+ they require) and "verb" is what is being done ("install", "enable" or "update"). Returns [error] with
18
+ a 409 and the whole list as "conflicts", or [null].
19
+
20
+ An extension whose package cannot be read is skipped, as it cannot be routed to anyway. "kempo" gives
21
+ the config of a name to check instead of reading it, for one that has just changed.
22
+ */
23
+ export default async ({ names, verb, kempo = {} }) => {
24
+ const candidates = (await Promise.all(names.map(async name => kempo[name] ? { name, kempo: kempo[name] } : configOf(name)))).filter(Boolean);
25
+ const enabled = (await getEnabledExtensions()).map(row => row.name).filter(name => !names.includes(name));
26
+ const others = (await Promise.all(enabled.map(configOf))).filter(Boolean);
27
+
28
+ const conflicts = findClaimConflicts(candidates, others);
29
+ if(!conflicts.length) return [null];
30
+
31
+ return [{ code: 409, msg: describeConflict(conflicts[0], verb), conflicts }];
32
+ };
@@ -0,0 +1,54 @@
1
+ import { scopeNames } from './scopes.js';
2
+ import { normalizeAliases } from './aliases.js';
3
+
4
+ /*
5
+ An address an extension answers on is a *claim*, and the router can give an address to only one
6
+ extension. Whichever one it reaches first gets every request for it, and the other's pages, scripts
7
+ and API routes under it answer 404 without any error, so two extensions may not claim the same
8
+ address or one inside the other.
9
+
10
+ Every claim is a path: each of the extension's scopes (/products) and each of its alias addresses
11
+ (/cart). An alias that is invalid is left out here, since it is refused on its own.
12
+ */
13
+ export const claimsOf = kempo => {
14
+ const names = scopeNames(kempo);
15
+ const [, aliases] = normalizeAliases(kempo?.aliases, names);
16
+ return [...names.map(scope => `/${scope}`), ...Object.keys(aliases ?? {})];
17
+ };
18
+
19
+ /* Two claims collide when they are the same address, or one is inside the other. */
20
+ export const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
21
+
22
+ /*
23
+ The collisions between extensions. "candidates" is what is about to be installed or switched on and
24
+ "others" what is on already, both as [{ name, kempo }]. Candidates are checked against each other as
25
+ well. Returns [{ name, claim, other, otherClaim }], one per colliding pair of claims. An extension
26
+ never collides with itself, so one that claims a scope and an alias in another of its own is fine.
27
+ */
28
+ export const findClaimConflicts = (candidates, others) => {
29
+ const withClaims = list => list.map(({ name, kempo }) => ({ name, claims: claimsOf(kempo) }));
30
+ const mine = withClaims(candidates);
31
+ const theirs = withClaims(others);
32
+ const conflicts = [];
33
+
34
+ const compare = (left, right) => {
35
+ for(const claim of left.claims){
36
+ for(const otherClaim of right.claims){
37
+ if(overlaps(claim, otherClaim)) conflicts.push({ name: left.name, claim, other: right.name, otherClaim });
38
+ }
39
+ }
40
+ };
41
+
42
+ mine.forEach((left, index) => {
43
+ theirs.filter(right => right.name !== left.name).forEach(right => compare(left, right));
44
+ mine.slice(index + 1).forEach(right => compare(left, right));
45
+ });
46
+
47
+ return conflicts;
48
+ };
49
+
50
+ /* The sentence a person is shown, naming who holds the address and what to do about it. */
51
+ export const describeConflict = ({ name, claim, other, otherClaim }, verb) => {
52
+ const what = claim === otherClaim ? otherClaim : `${otherClaim}, which overlaps ${claim}`;
53
+ return `Cannot ${verb} ${name}: ${other} already uses ${what}. Disable or uninstall ${other} first.`;
54
+ };
@@ -2,24 +2,18 @@ import { getTableConfig } from 'drizzle-orm/pg-core';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import db from '../../db/index.js';
4
4
 
5
- const TYPE_MAP = {
6
- PgText: 'text',
7
- PgVarchar: 'varchar',
8
- PgChar: 'char',
9
- PgBoolean: 'boolean',
10
- PgInteger: 'integer',
11
- PgBigInt53: 'bigint',
12
- PgSerial: 'serial',
13
- PgSmallInt: 'smallint',
14
- PgDoublePrecision: 'double precision',
15
- PgReal: 'real',
16
- PgNumeric: 'numeric',
17
- PgTimestamp: 'timestamp',
18
- PgDate: 'date',
19
- PgTime: 'time',
20
- PgJsonb: 'jsonb',
21
- PgJson: 'json',
22
- PgUUID: 'uuid',
5
+ /*
6
+ A column's SQL type is whatever Drizzle says it is (`bigserial`, `varchar(255)`, `timestamp`, `text[]`), never a
7
+ guess. A hand-kept map of types once sent `bigserial` to `text` with no sequence, which made a table that could
8
+ not take an insert. An enum has a type of its own that does not exist here yet, so it is stored as text.
9
+ */
10
+ const sqlTypeOf = column => {
11
+ if(column.columnType === 'PgEnumColumn') return 'text';
12
+ try {
13
+ return column.getSQLType();
14
+ } catch {
15
+ return 'text';
16
+ }
23
17
  };
24
18
 
25
19
  const serializeDefault = (value) => {
@@ -33,8 +27,7 @@ const tableToSql = (table) => {
33
27
  const config = getTableConfig(table);
34
28
  const primaryKeys = config.columns.filter(c => c.primary).map(c => c.name);
35
29
  const cols = config.columns.map(c => {
36
- const type = TYPE_MAP[c.columnType] || 'text';
37
- let def = `"${c.name}" ${type}`;
30
+ let def = `"${c.name}" ${sqlTypeOf(c)}`;
38
31
  if(c.notNull) def += ' NOT NULL';
39
32
  if(c.hasDefault && c.default !== undefined) def += ` DEFAULT ${serializeDefault(c.default)}`;
40
33
  if(c.primary && primaryKeys.length === 1) def += ' PRIMARY KEY';
@@ -3,6 +3,7 @@ import { extension } from '../../db/schema.js';
3
3
  import { eq, inArray } from 'drizzle-orm';
4
4
  import { invalidateScopeCache } from './scopeCache.js';
5
5
  import getRequirementTree from './getRequirementTree.js';
6
+ import checkClaims from './checkClaims.js';
6
7
 
7
8
  /*
8
9
  Switches an extension on. It cannot run without what it requires, so any of that which is installed
@@ -40,6 +41,13 @@ export default async ({ name, cascade = false }) => {
40
41
  }
41
42
 
42
43
  const names = [name, ...disabled.map(requirement => requirement.name)];
44
+
45
+ /* Switching on what claims an address another enabled extension already has would silently break one of them. */
46
+ const [claimsError] = await checkClaims({ names, verb: 'enable' });
47
+ if(claimsError){
48
+ return [claimsError, null];
49
+ }
50
+
43
51
  await db.update(extension)
44
52
  .set({ enabled: true, updatedAt: new Date() })
45
53
  .where(inArray(extension.name, names));
@@ -1,5 +1,5 @@
1
1
  import getSetting from '../settings/getSetting.js';
2
- import { createCatalogClient, DEFAULT_CATALOG_URL } from './catalogClient.js';
2
+ import { createCatalogClient, DEFAULT_CATALOG_URL } from './marketplaceClient.js';
3
3
 
4
4
  const clients = new Map();
5
5