kempo 4.7.0 → 4.9.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 (132) hide show
  1. package/README.md +10 -4
  2. package/bin/cli.js +38 -34
  3. package/dist/admin/components/EmailTemplates.js +25 -0
  4. package/dist/admin/components/EmailTemplates.js.map +1 -0
  5. package/dist/admin/components/Emails.js +49 -0
  6. package/dist/admin/components/Emails.js.map +1 -0
  7. package/dist/admin/components/Updates.js +117 -0
  8. package/dist/admin/components/Updates.js.map +1 -0
  9. package/dist/admin/components/settings/AdminSettings.js +7 -0
  10. package/dist/admin/components/settings/AdminSettings.js.map +1 -0
  11. package/dist/admin/components/settings/BooleanSetting.js +2 -0
  12. package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
  13. package/dist/admin/components/settings/JsonSetting.js +2 -0
  14. package/dist/admin/components/settings/JsonSetting.js.map +1 -0
  15. package/dist/admin/components/settings/NumberSetting.js +2 -0
  16. package/dist/admin/components/settings/NumberSetting.js.map +1 -0
  17. package/dist/admin/components/settings/SecretSetting.js +2 -0
  18. package/dist/admin/components/settings/SecretSetting.js.map +1 -0
  19. package/dist/admin/components/settings/Setting.js +2 -0
  20. package/dist/admin/components/settings/Setting.js.map +1 -0
  21. package/dist/admin/components/settings/SettingEditor.js +2 -0
  22. package/dist/admin/components/settings/SettingEditor.js.map +1 -0
  23. package/dist/admin/components/settings/SettingsPanel.js +24 -0
  24. package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
  25. package/dist/admin/components/settings/StringSetting.js +2 -0
  26. package/dist/admin/components/settings/StringSetting.js.map +1 -0
  27. package/dist/admin/components/settings/humanize.js +2 -0
  28. package/dist/admin/components/settings/humanize.js.map +1 -0
  29. package/dist/admin/emails/index.page.html +14 -0
  30. package/dist/admin/icons/mail.svg +1 -0
  31. package/dist/admin/nav.fragment.html +12 -0
  32. package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
  33. package/dist/admin/settings/index.page.html +6 -289
  34. package/dist/admin/updates/index.page.html +14 -0
  35. package/dist/kempo/api/email-templates/GET.js +2 -0
  36. package/dist/kempo/api/email-templates/GET.js.map +1 -0
  37. package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
  38. package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
  39. package/dist/kempo/api/email-templates/file/GET.js +2 -0
  40. package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
  41. package/dist/kempo/api/email-templates/file/PUT.js +2 -0
  42. package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
  43. package/dist/kempo/api/email-templates/preview/GET.js +2 -0
  44. package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
  45. package/dist/kempo/api/emails/GET.js +2 -0
  46. package/dist/kempo/api/emails/GET.js.map +1 -0
  47. package/dist/kempo/api/emails/[id]/GET.js +2 -0
  48. package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
  49. package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
  50. package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
  51. package/dist/kempo/api/emails/test/POST.js +2 -0
  52. package/dist/kempo/api/emails/test/POST.js.map +1 -0
  53. package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
  54. package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
  55. package/dist/kempo/api/updates/GET.js +2 -0
  56. package/dist/kempo/api/updates/GET.js.map +1 -0
  57. package/dist/kempo/api/updates/apply/POST.js +2 -0
  58. package/dist/kempo/api/updates/apply/POST.js.map +1 -0
  59. package/dist/kempo/api/updates/check/POST.js +2 -0
  60. package/dist/kempo/api/updates/check/POST.js.map +1 -0
  61. package/dist/kempo/api/updates/discard/POST.js +2 -0
  62. package/dist/kempo/api/updates/discard/POST.js.map +1 -0
  63. package/dist/kempo/api/updates/prepare/POST.js +2 -0
  64. package/dist/kempo/api/updates/prepare/POST.js.map +1 -0
  65. package/dist/kempo/api/updates/rollback/POST.js +2 -0
  66. package/dist/kempo/api/updates/rollback/POST.js.map +1 -0
  67. package/dist/kempo/fetch.js +1 -1
  68. package/dist/kempo/fetch.js.map +1 -1
  69. package/dist/kempo/sdk.js +1 -1
  70. package/dist/kempo/sdk.js.map +1 -1
  71. package/docker/.dockerignore +9 -0
  72. package/docker/Dockerfile +25 -0
  73. package/docker/docker-compose.prod.yml +39 -0
  74. package/docker/entrypoint.mjs +96 -0
  75. package/middleware/kempo.js +71 -0
  76. package/package.json +4 -3
  77. package/server/db/schema.js +44 -0
  78. package/server/db/upgrades/README.md +20 -0
  79. package/server/sdk.js +8 -0
  80. package/server/utils/email/defaultSender.js +2 -0
  81. package/server/utils/email/dispatchEmail.js +56 -0
  82. package/server/utils/email/emailSettings.js +21 -0
  83. package/server/utils/email/emailTemplatePaths.js +36 -0
  84. package/server/utils/email/getEmail.js +14 -0
  85. package/server/utils/email/getSenderEmail.js +11 -0
  86. package/server/utils/email/getSenderFrom.js +18 -0
  87. package/server/utils/email/isValidSender.js +4 -0
  88. package/server/utils/email/listEmailTemplates.js +23 -0
  89. package/server/utils/email/listEmails.js +35 -0
  90. package/server/utils/email/maintainEmails.js +46 -0
  91. package/server/utils/email/pruneEmails.js +29 -0
  92. package/server/utils/email/readEmailTemplate.js +28 -0
  93. package/server/utils/email/resetEmailTemplate.js +15 -0
  94. package/server/utils/email/retryEmail.js +21 -0
  95. package/server/utils/email/saveEmailTemplate.js +38 -0
  96. package/server/utils/email/sendEmail.js +22 -32
  97. package/server/utils/email/sendEmailFromTemplate.js +2 -2
  98. package/server/utils/extensions/checkExtensionUpdate.js +9 -36
  99. package/server/utils/extensions/reconcileExtensions.js +90 -0
  100. package/server/utils/extensions/updateExtension.js +14 -153
  101. package/server/utils/extensions/upgradeExtension.js +117 -0
  102. package/server/utils/extensions/writeSiteFile.js +66 -0
  103. package/server/utils/hooks/triggerHook.js +17 -4
  104. package/server/utils/settings/listSettings.js +10 -5
  105. package/server/utils/settings/setSetting.js +4 -0
  106. package/server/utils/settings/settingRules.js +21 -0
  107. package/server/utils/settings/systemSettings.js +41 -0
  108. package/server/utils/setup/runUpgrades.js +70 -0
  109. package/server/utils/setup/seedDatabase.js +5 -60
  110. package/server/utils/setup/seedIfNew.js +21 -0
  111. package/server/utils/setup/syncCoreSchema.js +130 -0
  112. package/server/utils/setup/systemPermissions.js +77 -0
  113. package/server/utils/updates/applyStagedUpdate.js +95 -0
  114. package/server/utils/updates/applyUpdate.js +76 -0
  115. package/server/utils/updates/backupDatabase.js +35 -0
  116. package/server/utils/updates/discardUpdate.js +11 -0
  117. package/server/utils/updates/extensionTargets.js +101 -0
  118. package/server/utils/updates/fetchManifest.js +26 -0
  119. package/server/utils/updates/getExtensionUpdates.js +70 -0
  120. package/server/utils/updates/getInstallInfo.js +25 -0
  121. package/server/utils/updates/getUpdateStatus.js +57 -0
  122. package/server/utils/updates/helpers.js +30 -0
  123. package/server/utils/updates/notifySchemaAttention.js +35 -0
  124. package/server/utils/updates/npm.js +41 -0
  125. package/server/utils/updates/prepareUpdate.js +109 -0
  126. package/server/utils/updates/releaseStatus.js +23 -0
  127. package/server/utils/updates/restart.js +64 -0
  128. package/server/utils/updates/rollbackUpdate.js +56 -0
  129. package/server/utils/updates/runUpdateCheck.js +52 -0
  130. package/server/utils/updates/runtime.js +15 -0
  131. package/server/utils/updates/siteLayout.js +200 -0
  132. package/server/utils/updates/starterFiles.js +118 -0
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { dirname, join, normalize, sep } from 'path';
4
+
5
+ /*
6
+ For an extension that has to put a file into the site (a generated template, a starter page): writes it the way kempo's own
7
+ starter files are handled, so an update of the extension never overwrites what the owner changed.
8
+
9
+ not there, never written by this extension written
10
+ not there, but this extension wrote it before the owner deleted it, so it stays deleted
11
+ identical to what is being written nothing to do
12
+ still exactly what this extension last wrote never edited, so replaced
13
+ anything else edited by the owner (or anyone), so kept
14
+
15
+ The extension's fingerprints are in .kempo/shipped/<owner>.json. `path` is relative to the site and must be inside public/
16
+ or templates/. Most extensions need none of this, because their own public/ is served from the package; this is for the
17
+ few that generate something into the site. Resolves to [error, { status }] where status is "written", "current", "kept"
18
+ or "deleted".
19
+ */
20
+ const hashOf = content => createHash('sha256').update(content).digest('hex');
21
+
22
+ export default ({ owner, path, content, cwd = process.cwd() } = {}) => {
23
+ if(typeof owner !== 'string' || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(owner)){
24
+ return [{ code: 400, msg: 'owner must be the extension name' }, null];
25
+ }
26
+ const relative = typeof path === 'string' ? normalize(path).split(sep).join('/') : '';
27
+ if(!relative || relative.startsWith('..') || relative.startsWith('/') || !/^(public|templates)\//.test(relative)){
28
+ return [{ code: 400, msg: 'path must be a file inside public/ or templates/' }, null];
29
+ }
30
+ if(typeof content !== 'string' && !Buffer.isBuffer(content)){
31
+ return [{ code: 400, msg: 'content must be text or a buffer' }, null];
32
+ }
33
+
34
+ const recordFile = join(cwd, '.kempo', 'shipped', `${owner.replace('/', '__')}.json`);
35
+ let files = {};
36
+ try {
37
+ files = JSON.parse(readFileSync(recordFile, 'utf8')).files ?? {};
38
+ } catch {
39
+ files = {};
40
+ }
41
+ const save = () => {
42
+ mkdirSync(dirname(recordFile), { recursive: true });
43
+ writeFileSync(recordFile, JSON.stringify({ files }, null, 2));
44
+ };
45
+
46
+ const target = join(cwd, relative);
47
+ const wanted = hashOf(content);
48
+
49
+ if(!existsSync(target)){
50
+ if(files[relative]) return [null, { status: 'deleted' }];
51
+ } else {
52
+ const here = hashOf(readFileSync(target));
53
+ if(here === wanted){
54
+ files[relative] = wanted;
55
+ save();
56
+ return [null, { status: 'current' }];
57
+ }
58
+ if(files[relative] !== here) return [null, { status: 'kept' }];
59
+ }
60
+
61
+ mkdirSync(dirname(target), { recursive: true });
62
+ writeFileSync(target, content);
63
+ files[relative] = wanted;
64
+ save();
65
+ return [null, { status: 'written' }];
66
+ };
@@ -7,6 +7,13 @@ import { pathToFileURL } from 'url';
7
7
 
8
8
  const handlerCache = new Map();
9
9
 
10
+ /*
11
+ kempo-server's dev reload (`dev.reload`) counts how many times a server file has changed in
12
+ `globalThis.kempoDevGeneration`. A handler is loaded again when the count has moved on, so editing a hook file
13
+ in development needs no restart. In production it is never set, and a handler is loaded once.
14
+ */
15
+ const generation = () => globalThis.kempoDevGeneration ?? 0;
16
+
10
17
  export const clearHandlerCache = () => handlerCache.clear();
11
18
 
12
19
  const resolveHandler = (owner, callback) => {
@@ -33,11 +40,15 @@ const resolveHandler = (owner, callback) => {
33
40
  write a response mid-flight. That needs no special mode here — only that ordering be predictable,
34
41
  hence the explicit `orderBy` below, since without it row order is whatever the database returns.
35
42
 
43
+ **Claiming** (`stopWhen`) — a function given each handler's result. When it returns true the chain stops there and the handlers
44
+ after it do not run. This is how only one of several handlers does a job: an email sender returns `{ sent: true }` and the
45
+ other senders never see that email, while one that failed returns something else and the next one gets its turn.
46
+
36
47
  Note that "notification" governs error handling, not timing: handlers are still awaited in
37
48
  sequence, so a handler doing slow work holds up whatever triggered the event. Such a handler
38
49
  should start its work and return rather than awaiting it here.
39
50
  */
40
- export default async (event, data = {}, { bail = false } = {}) => {
51
+ export default async (event, data = {}, { bail = false, stopWhen = null } = {}) => {
41
52
  if(!event){
42
53
  return [{ code: 400, msg: 'Event is required' }, null];
43
54
  }
@@ -59,12 +70,13 @@ export default async (event, data = {}, { bail = false } = {}) => {
59
70
  const handlerPath = resolveHandler(h.owner, h.callback);
60
71
  if(!handlerPath) continue;
61
72
 
62
- let handler = handlerCache.get(handlerPath);
73
+ const cacheKey = `${handlerPath}:${generation()}`;
74
+ let handler = handlerCache.get(cacheKey);
63
75
  if(!handler){
64
76
  try {
65
- const mod = await import(pathToFileURL(handlerPath).href);
77
+ const mod = await import(pathToFileURL(handlerPath).href + (generation() ? `?dev=${generation()}` : ''));
66
78
  handler = mod.default || mod;
67
- handlerCache.set(handlerPath, handler);
79
+ handlerCache.set(cacheKey, handler);
68
80
  } catch(err) {
69
81
  /*
70
82
  Never fail silently: a hook that cannot load is indistinguishable from one that
@@ -80,6 +92,7 @@ export default async (event, data = {}, { bail = false } = {}) => {
80
92
  try {
81
93
  const result = await handler(data);
82
94
  results.push({ hookId: h.id, owner: h.owner, result });
95
+ if(stopWhen && stopWhen(result)) break;
83
96
  } catch(err) {
84
97
  if(bail) throw err;
85
98
  /* A notification handler's failure stops nothing, but it must not vanish: nobody would ever know the extension broke. */
@@ -1,23 +1,28 @@
1
1
  import db from '../../db/index.js';
2
2
  import { setting } from '../../db/schema.js';
3
- import { like, not, and, sql } from 'drizzle-orm';
3
+ import { like, not, and, asc, sql } from 'drizzle-orm';
4
4
  import { SECRET_MASK } from './secretCrypto.js';
5
5
 
6
+ /*
7
+ The settings of one owner, by name. `owner` is 'system', 'custom', 'extension' (every setting that is neither, whoever owns it),
8
+ or the name of one extension (`kempo-commerce`), which lists just its own. The owner is matched as a plain prefix, never as a
9
+ pattern, so a name with `_` or `%` in it cannot reach another owner's settings.
10
+ */
6
11
  export default async ({ owner, limit = 50, offset = 0 } = {}) => {
7
12
  try {
8
13
  let where;
9
- if(owner === 'system' || owner === 'custom'){
10
- where = like(setting.name, `${owner}:%`);
11
- } else if(owner === 'extension'){
14
+ if(owner === 'extension'){
12
15
  where = and(
13
16
  not(like(setting.name, 'system:%')),
14
17
  not(like(setting.name, 'custom:%'))
15
18
  );
19
+ } else if(owner){
20
+ where = sql`left(${setting.name}, ${owner.length + 1}) = ${`${owner}:`}`;
16
21
  }
17
22
  const q = db.select().from(setting);
18
23
  const cq = db.select({ count: sql`count(*)` }).from(setting);
19
24
  if(where){ q.where(where); cq.where(where); }
20
- const results = await q.limit(limit).offset(offset);
25
+ const results = await q.orderBy(asc(setting.name)).limit(limit).offset(offset);
21
26
  const [{ count }] = await cq;
22
27
 
23
28
  // Never send secret ciphertext to the admin UI, not even encrypted.
@@ -3,12 +3,16 @@ import { setting } from '../../db/schema.js';
3
3
  import { eq } from 'drizzle-orm';
4
4
  import { serializeValue } from './helpers.js';
5
5
  import { SECRET_MASK } from './secretCrypto.js';
6
+ import settingRule from './settingRules.js';
6
7
 
7
8
  export default async (owner, name, value, type = null, isPublic = false, description = null) => {
8
9
  if(!owner || !name){
9
10
  return [{ code: 400, msg: 'Both owner and name are required' }, null];
10
11
  }
11
12
 
13
+ const ruleError = settingRule(owner, name, value);
14
+ if(ruleError) return [ruleError, null];
15
+
12
16
  try {
13
17
  const fullName = `${owner}:${name}`;
14
18
  const detectedType = type || (typeof value === 'object' ? 'json' : typeof value);
@@ -0,0 +1,21 @@
1
+ import isValidSender from '../email/isValidSender.js';
2
+
3
+ /*
4
+ Rules for particular settings, checked whenever one is saved (the admin screen, the API and the SDK alike), because a bad value of
5
+ these breaks something site-wide. Resolves to an `{ code, msg }` error, or null when the value is fine.
6
+ */
7
+ const whole = (value, min, max) => Number.isInteger(Number(value)) && value !== '' && value !== null && Number(value) >= min && Number(value) <= max;
8
+
9
+ const RULES = {
10
+ 'system:email_max_retries': value => (whole(value, 0, 10) ? null : { code: 400, msg: 'Retries must be a whole number from 0 to 10' }),
11
+ 'system:email_retention_days': value => (whole(value, 0, 3650) ? null : { code: 400, msg: 'Retention must be a whole number of days from 0 to 3650' }),
12
+ 'system:email_retention_by_type': value => (value && typeof value === 'object' && !Array.isArray(value) && Object.values(value).every(days => whole(days, 0, 3650))
13
+ ? null
14
+ : { code: 400, msg: 'Give each kind of email a whole number of days from 0 to 3650, like { "verify-email": 7 }' }),
15
+ 'system:email_from': value => (value === '' || isValidSender(value) ? null : { code: 400, msg: 'The sender must look like name@example.com or Shop Name <name@example.com>' }),
16
+ };
17
+
18
+ export default (owner, name, value) => {
19
+ const rule = Object.hasOwn(RULES, `${owner}:${name}`) ? RULES[`${owner}:${name}`] : null;
20
+ return rule ? rule(value) : null;
21
+ };
@@ -0,0 +1,41 @@
1
+ import db from '../../db/index.js';
2
+ import { setting } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
+ import setSetting from './setSetting.js';
5
+
6
+ /*
7
+ The settings kempo itself owns, with their defaults. A new site gets them when it is set up (seedDatabase), and `ensureSystemSettings`
8
+ adds any that an older site is missing when the server starts, so a setting added in a later version shows on the settings screen
9
+ and can be edited like the rest. It only ever adds: a setting that exists is never touched, so nothing an admin chose is overwritten.
10
+ */
11
+ export const SYSTEM_SETTINGS = [
12
+ { owner: 'system', name: 'site_name', value: 'Kempo Site', type: 'string', isPublic: true, description: 'The name of the website' },
13
+ { owner: 'system', name: 'session_duration_days', value: 7, type: 'number', isPublic: false, description: 'Session duration in days' },
14
+ { owner: 'system', name: 'allow_registration', value: true, type: 'boolean', isPublic: true, description: 'Allow new users to register' },
15
+ { owner: 'system', name: 'require_email_verification', value: false, type: 'boolean', isPublic: false, description: 'Require email verification before account access' },
16
+ { owner: 'system', name: 'verification_url', value: 'http://localhost:3000/verify-email/{{token}}', type: 'string', isPublic: false, description: 'Email verification URL template' },
17
+ { owner: 'system', name: 'notification_retention_days', value: 90, type: 'number', isPublic: false, description: 'Days to keep notifications before they are deleted (0 keeps them forever)' },
18
+ { owner: 'system', name: 'password_reset_url', value: 'http://localhost:3000/reset-password/{{token}}', type: 'string', isPublic: false, description: 'Password reset URL template' },
19
+ { owner: 'system', name: 'email_max_retries', value: 1, type: 'number', isPublic: false, description: 'How many times kempo tries again to send an email that could not be sent, after the first try (0 never retries)' },
20
+ { owner: 'system', name: 'email_retention_days', value: 30, type: 'number', isPublic: false, description: 'Days to keep the log of sent emails before it is deleted (0 keeps it forever)' },
21
+ { owner: 'system', name: 'email_retention_by_type', value: {}, type: 'json', isPublic: false, description: 'Days to keep a particular kind of email, instead of the number above, as { "verify-email": 7, "kempo-commerce/order-processing": 365 } (0 keeps that kind forever)' },
22
+ { owner: 'system', name: 'email_from', value: '', type: 'string', isPublic: false, description: 'The address emails are sent from, as name@example.com or Shop Name <name@example.com>. It must be on a domain verified with the email provider. Empty uses EMAIL_FROM from the server environment' },
23
+ { owner: 'system', name: 'update_check_enabled', value: true, type: 'boolean', isPublic: false, description: 'Check npm every 12 hours for a newer kempo and tell the people who can install it' },
24
+ ];
25
+
26
+ /* Resolves to [error, { added: [names] }]. */
27
+ export const ensureSystemSettings = async () => {
28
+ try {
29
+ const added = [];
30
+ for(const entry of SYSTEM_SETTINGS){
31
+ const [existing] = await db.select({ name: setting.name }).from(setting).where(eq(setting.name, `${entry.owner}:${entry.name}`)).limit(1);
32
+ if(existing) continue;
33
+ const [error] = await setSetting(entry.owner, entry.name, entry.value, entry.type, entry.isPublic, entry.description);
34
+ if(error) return [error, null];
35
+ added.push(entry.name);
36
+ }
37
+ return [null, { added }];
38
+ } catch {
39
+ return [{ code: 500, msg: 'The system settings could not be checked' }, null];
40
+ }
41
+ };
@@ -0,0 +1,70 @@
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath, pathToFileURL } from 'url';
4
+ import { sql } from 'drizzle-orm';
5
+ import db from '../../db/index.js';
6
+ import { upgrade } from '../../db/schema.js';
7
+
8
+ /*
9
+ Kempo's own data migrations: the files in server/db/upgrades, which restructure or move data that the additive sync of
10
+ the tables (syncCoreSchema) cannot. See that folder's README for how to write one.
11
+
12
+ They run in file-name order, each once, in a transaction, when the server starts and after the tables are in step. A
13
+ database that has never had the `upgrade` table is taken to already hold what the shipped migrations make (a new site,
14
+ or a site on its first version with this): they are recorded as run without being run, under a row called 'baseline'.
15
+ */
16
+ export const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'db', 'upgrades');
17
+
18
+ const filesIn = dir => (existsSync(dir) ? readdirSync(dir).filter(name => name.endsWith('.js')).sort() : []);
19
+
20
+ const load = (dir, file) => import(`${pathToFileURL(join(dir, file)).href}?t=${Date.now()}`);
21
+
22
+ const appliedIds = async executor => new Set((await executor.select({ id: upgrade.id }).from(upgrade)).map(row => row.id));
23
+
24
+ /*
25
+ What would run, in order, as [{ id, description, breaking }]. `dir` is another version's folder, to look ahead at an
26
+ update before it is switched to. Nothing is run or recorded.
27
+ */
28
+ export const pendingUpgrades = async ({ dir = DEFAULT_DIR, executor = db } = {}) => {
29
+ const applied = await appliedIds(executor);
30
+ if(!applied.has('baseline')) return [];
31
+ const pending = [];
32
+ for(const file of filesIn(dir).filter(name => !applied.has(name))){
33
+ const { meta = {} } = await load(dir, file);
34
+ pending.push({ id: file, description: meta.description ?? '', breaking: Boolean(meta.breaking) });
35
+ }
36
+ return pending;
37
+ };
38
+
39
+ /*
40
+ Resolves to [error, { ran }]. An error is the first migration that failed, with `breaking` saying whether it was one the
41
+ previous version cannot run without; nothing after it runs, and what it did is rolled back.
42
+ */
43
+ export default async ({ dir = DEFAULT_DIR, executor = db } = {}) => {
44
+ try {
45
+ const applied = await appliedIds(executor);
46
+ if(!applied.has('baseline')){
47
+ await executor.transaction(async tx => {
48
+ for(const id of [...filesIn(dir), 'baseline']) await tx.insert(upgrade).values({ id, description: id === 'baseline' ? 'Existing migrations were already part of this database' : 'Part of the baseline' }).onConflictDoNothing();
49
+ });
50
+ return [null, { ran: [] }];
51
+ }
52
+
53
+ const ran = [];
54
+ for(const file of filesIn(dir).filter(name => !applied.has(name))){
55
+ const { meta = {}, up } = await load(dir, file);
56
+ try {
57
+ await executor.transaction(async tx => {
58
+ await up({ db: tx, sql });
59
+ await tx.insert(upgrade).values({ id: file, description: meta.description ?? null, breaking: Boolean(meta.breaking) });
60
+ });
61
+ ran.push(file);
62
+ } catch(error) {
63
+ return [{ code: 500, msg: `The data migration ${file} failed: ${String(error.cause?.message || error.message).slice(0, 300)}`, breaking: Boolean(meta.breaking), id: file }, { ran }];
64
+ }
65
+ }
66
+ return [null, { ran }];
67
+ } catch(error) {
68
+ return [{ code: 500, msg: `The data migrations could not be checked: ${String(error.cause?.message || error.message).slice(0, 300)}`, breaking: false }, null];
69
+ }
70
+ };
@@ -2,54 +2,7 @@ import crypto from 'crypto';
2
2
  import db from '../../db/index.js';
3
3
  import { permission, group, groupPermission, setting, createSettingProtectionTrigger, createGroupProtectionTrigger, createPermissionProtectionTrigger } from '../../db/schema.js';
4
4
  import { eq, and, sql } from 'drizzle-orm';
5
-
6
- const DEFAULT_PERMISSIONS = [
7
- { name: 'system:user:create', description: 'Create new users' },
8
- { name: 'system:user:read', description: 'View users' },
9
- { name: 'system:user:update', description: 'Edit users' },
10
- { name: 'system:user:delete', description: 'Delete users' },
11
-
12
- { name: 'system:group:create', description: 'Create new groups' },
13
- { name: 'system:group:read', description: 'View groups' },
14
- { name: 'system:group:update', description: 'Edit groups and manage membership' },
15
- { name: 'system:group:delete', description: 'Delete groups' },
16
-
17
- { name: 'system:permissions:read', description: 'View permissions for all users' },
18
- { name: 'system:permissions:manage', description: 'Add or remove permissions' },
19
-
20
- { name: 'system:settings:read', description: 'View private settings' },
21
- { name: 'system:settings:update', description: 'Update settings' },
22
- { name: 'system:custom-settings:manage', description: 'Create and delete custom settings' },
23
-
24
- { name: 'system:pages:read', description: 'View pages in admin' },
25
- { name: 'system:pages:create', description: 'Create new pages' },
26
- { name: 'system:pages:update', description: 'Edit pages' },
27
- { name: 'system:pages:delete', description: 'Delete pages' },
28
-
29
- { name: 'system:menus:create', description: 'Create new menus' },
30
- { name: 'system:menus:read', description: 'View menus in admin' },
31
- { name: 'system:menus:update', description: 'Edit menus and menu items' },
32
- { name: 'system:menus:delete', description: 'Delete menus' },
33
-
34
- { name: 'system:fragments:read', description: 'View fragments in admin' },
35
- { name: 'system:fragments:create', description: 'Create new fragments' },
36
- { name: 'system:fragments:update', description: 'Edit fragments' },
37
- { name: 'system:fragments:delete', description: 'Delete fragments' },
38
-
39
- { name: 'system:globals:read', description: 'View global content in admin' },
40
- { name: 'system:globals:create', description: 'Create new global content' },
41
- { name: 'system:globals:update', description: 'Edit global content' },
42
- { name: 'system:globals:delete', description: 'Delete global content' },
43
-
44
- { name: 'system:extensions:read', description: 'View installed extensions' },
45
- { name: 'system:extensions:install', description: 'Install extensions' },
46
- { name: 'system:extensions:uninstall', description: 'Uninstall extensions' },
47
- { name: 'system:extensions:manage', description: 'Enable and disable extensions' },
48
-
49
- { name: 'system:realtime:read', description: 'View realtime connections and channels' },
50
-
51
- { name: 'system:admin:access', description: 'Access admin panel' },
52
- ];
5
+ import { SYSTEM_PERMISSIONS } from './systemPermissions.js';
53
6
 
54
7
  const DEFAULT_GROUPS = {
55
8
  'system:Users': {
@@ -69,7 +22,7 @@ const DEFAULT_GROUPS = {
69
22
  'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
70
23
  'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
71
24
  'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
72
- 'system:realtime:read'
25
+ 'system:realtime:read', 'system:emails:read', 'system:emails:manage', 'system:updates:manage'
73
26
  ]
74
27
  }
75
28
  };
@@ -88,7 +41,7 @@ const runScript = text => db.$client?.exec ? db.$client.exec(text) : db.execute(
88
41
  export default async ({ log = console.log } = {}) => {
89
42
  log('=== Seeding Permissions ===\n');
90
43
 
91
- for(const perm of DEFAULT_PERMISSIONS) {
44
+ for(const perm of SYSTEM_PERMISSIONS) {
92
45
  const existing = await db.select()
93
46
  .from(permission)
94
47
  .where(eq(permission.name, perm.name))
@@ -167,18 +120,10 @@ export default async ({ log = console.log } = {}) => {
167
120
 
168
121
  log('\n=== Creating Default Settings ===\n');
169
122
 
170
- const DEFAULT_SETTINGS = [
171
- { owner: 'system', name: 'site_name', value: 'Kempo Site', type: 'string', isPublic: true, description: 'The name of the website' },
172
- { owner: 'system', name: 'session_duration_days', value: 7, type: 'number', isPublic: false, description: 'Session duration in days' },
173
- { owner: 'system', name: 'allow_registration', value: true, type: 'boolean', isPublic: true, description: 'Allow new users to register' },
174
- { owner: 'system', name: 'require_email_verification', value: false, type: 'boolean', isPublic: false, description: 'Require email verification before account access' },
175
- { owner: 'system', name: 'verification_url', value: 'http://localhost:3000/verify-email/{{token}}', type: 'string', isPublic: false, description: 'Email verification URL template' },
176
- { owner: 'system', name: 'notification_retention_days', value: 90, type: 'number', isPublic: false, description: 'Days to keep notifications before they are deleted (0 keeps them forever)' },
177
- { owner: 'system', name: 'password_reset_url', value: 'http://localhost:3000/reset-password/{{token}}', type: 'string', isPublic: false, description: 'Password reset URL template' },
178
- ];
123
+ const { SYSTEM_SETTINGS } = await import('../settings/systemSettings.js');
179
124
 
180
125
  const { default: setSetting } = await import('../settings/setSetting.js');
181
- for(const s of DEFAULT_SETTINGS){
126
+ for(const s of SYSTEM_SETTINGS){
182
127
  await setSetting(s.owner, s.name, s.value, s.type, s.isPublic, s.description);
183
128
  log(`✓ Created setting: ${s.owner}:${s.name} = ${s.value}`);
184
129
  }
@@ -0,0 +1,21 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import db from '../../db/index.js';
3
+ import { group } from '../../db/schema.js';
4
+ import seedDatabase from './seedDatabase.js';
5
+
6
+ /*
7
+ A database with no Administrators group is a new one (the group cannot be deleted), so kempo's groups, permissions and
8
+ settings are put in it, which is what `scripts/init-db.js` does by hand. It is what lets a new site start from an empty
9
+ database with no command typed: the tables come from syncCoreSchema, this fills them, and the /setup page makes the
10
+ first administrator. A database that has the group is never touched.
11
+ */
12
+ export default async () => {
13
+ try {
14
+ const [existing] = await db.select({ name: group.name }).from(group).where(eq(group.name, 'system:Administrators')).limit(1);
15
+ if(existing) return [null, { seeded: false }];
16
+ await seedDatabase({ log: () => {} });
17
+ return [null, { seeded: true }];
18
+ } catch(error) {
19
+ return [{ code: 500, msg: `A new database could not be set up: ${String(error.cause?.message || error.message).slice(0, 300)}` }, null];
20
+ }
21
+ };
@@ -0,0 +1,130 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import db from '../../db/index.js';
3
+ import * as coreSchema from '../../db/schema.js';
4
+
5
+ /*
6
+ Brings kempo's own tables in step with the installed version of kempo, so an update never needs a command typed.
7
+ It runs when the server starts, before the site serves anything.
8
+
9
+ It only ever adds. It looks at which of the schema's tables, columns, indexes and constraints the database already
10
+ has, and asks drizzle-kit's migration generator (the one `drizzle-kit push` is built on) for the difference between
11
+ "what the database has" and "what the schema says", so the new indexes and foreign keys come out exactly as a push
12
+ would make them. What the database has is the schema with every missing piece cut out, so the difference can only
13
+ be additions: it can never contain a DROP or a TRUNCATE, and a table that an extension or the site made is never
14
+ looked at. A column that kempo no longer has stays where it is. A change to something that already exists (a new
15
+ type, a NOT NULL) is not detected here: that is what a release's own migration step is for.
16
+
17
+ Each statement is applied on its own, so one the database refuses (a new unique index over rows that already break
18
+ it, a NOT NULL column with no default on a table that has rows) is reported without holding back the rest. Several
19
+ processes starting together queue on an advisory lock, and the one after the first finds nothing left to do.
20
+
21
+ (drizzle-kit's `pushSchema` is not used: it drops the parameters of its own introspection queries, which fails on any
22
+ table with a composite key and then ends the process, and for a type change it proposes TRUNCATE ... CASCADE.)
23
+ */
24
+ const LOCK_KEY = 'kempo:sync-core-schema';
25
+
26
+ const ADDITIVE = [
27
+ /^CREATE TABLE /i,
28
+ /^CREATE (UNIQUE )?INDEX /i,
29
+ /^CREATE TYPE /i,
30
+ /^ALTER TABLE .+ ADD COLUMN /i,
31
+ /^ALTER TABLE .+ ADD CONSTRAINT /i,
32
+ ];
33
+
34
+ /* A last check on what the generator produced: anything that is not an addition is never run. */
35
+ export const isAdditive = statement => ADDITIVE.some(pattern => pattern.test(statement.trim()));
36
+
37
+ /* One plain sentence for a statement, for the log and the Updates screen. */
38
+ export const describe = statement => {
39
+ const text = statement.trim();
40
+ const named = (pattern, make) => {
41
+ const match = text.match(pattern);
42
+ return match ? make(...match.slice(1)) : null;
43
+ };
44
+ return named(/^CREATE TABLE "([^"]+)"/i, table => `Created table ${table}`)
45
+ ?? named(/^ALTER TABLE "([^"]+)" ADD COLUMN "([^"]+)"/i, (table, column) => `Added column ${table}.${column}`)
46
+ ?? named(/^CREATE (?:UNIQUE )?INDEX "([^"]+)"/i, index => `Created index ${index}`)
47
+ ?? named(/^ALTER TABLE "([^"]+)" ADD CONSTRAINT "([^"]+)"/i, (table, constraint) => `Added ${constraint} to ${table}`)
48
+ ?? text.replace(/\s+/g, ' ').slice(0, 120);
49
+ };
50
+
51
+ const rowsOf = result => (Array.isArray(result) ? result : result.rows);
52
+
53
+ /* The names of what the database already has in the public schema. */
54
+ const readDatabase = async executor => {
55
+ const names = async (query, field) => new Set(rowsOf(await executor.execute(query)).map(row => row[field]));
56
+ const columns = rowsOf(await executor.execute(sql`select table_name, column_name from information_schema.columns where table_schema = 'public'`));
57
+ return {
58
+ tables: await names(sql`select table_name from information_schema.tables where table_schema = 'public'`, 'table_name'),
59
+ columns: new Set(columns.map(row => `${row.table_name}.${row.column_name}`)),
60
+ indexes: await names(sql`select indexname from pg_indexes where schemaname = 'public'`, 'indexname'),
61
+ constraints: await names(sql`select conname from pg_constraint join pg_namespace on pg_namespace.oid = pg_constraint.connamespace where nspname = 'public'`, 'conname'),
62
+ enums: await names(sql`select typname from pg_type join pg_namespace on pg_namespace.oid = pg_type.typnamespace where typtype = 'e' and nspname = 'public'`, 'typname'),
63
+ };
64
+ };
65
+
66
+ /* The schema snapshot with everything the database lacks cut out: what drizzle-kit would see if it had introspected the database. */
67
+ const withoutMissing = (snapshot, existing) => {
68
+ const prev = structuredClone(snapshot);
69
+ for(const key of Object.keys(prev.enums)){
70
+ if(!existing.enums.has(prev.enums[key].name)) delete prev.enums[key];
71
+ }
72
+ for(const [key, table] of Object.entries(prev.tables)){
73
+ if(!existing.tables.has(table.name)){
74
+ delete prev.tables[key];
75
+ continue;
76
+ }
77
+ for(const name of Object.keys(table.columns)){
78
+ if(!existing.columns.has(`${table.name}.${name}`)) delete table.columns[name];
79
+ }
80
+ for(const name of Object.keys(table.indexes)){
81
+ if(!existing.indexes.has(name)) delete table.indexes[name];
82
+ }
83
+ for(const group of [table.foreignKeys, table.uniqueConstraints, table.compositePrimaryKeys ?? {}]){
84
+ for(const id of Object.keys(group)){
85
+ if(!existing.constraints.has(group[id].name)) delete group[id];
86
+ }
87
+ }
88
+ }
89
+ return prev;
90
+ };
91
+
92
+ /* What the last run did, for the Updates screen. */
93
+ let lastRun = { ran: false, applied: [], failed: [] };
94
+
95
+ export const getSchemaSyncResult = () => lastRun;
96
+
97
+ /*
98
+ Resolves to [error, { applied, failed }]: `applied` is a list of sentences, `failed` is { change, reason } for what
99
+ the database refused. `schema` and `executor` are for tests and extensions: other tables, in another database.
100
+ */
101
+ export default async ({ schema = coreSchema, executor = db } = {}) => {
102
+ try {
103
+ const { generateDrizzleJson, generateMigration } = await import('drizzle-kit/api');
104
+ const wanted = generateDrizzleJson(schema);
105
+ const result = { applied: [], failed: [] };
106
+
107
+ await executor.transaction(async tx => {
108
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${LOCK_KEY}::text))`);
109
+ const statements = await generateMigration(withoutMissing(wanted, await readDatabase(tx)), wanted);
110
+
111
+ for(const statement of statements){
112
+ if(!isAdditive(statement)){
113
+ result.failed.push({ change: describe(statement), reason: 'Kempo only adds to its tables, so this was not run' });
114
+ continue;
115
+ }
116
+ try {
117
+ await tx.transaction(inner => inner.execute(sql.raw(statement)));
118
+ result.applied.push(describe(statement));
119
+ } catch(error) {
120
+ result.failed.push({ change: describe(statement), reason: String(error.cause?.message || error.message).slice(0, 300) });
121
+ }
122
+ }
123
+ });
124
+
125
+ if(schema === coreSchema) lastRun = { ran: true, ...result };
126
+ return [null, result];
127
+ } catch(error) {
128
+ return [{ code: 500, msg: `The database could not be checked against this version of kempo: ${String(error.cause?.message || error.message).slice(0, 300)}` }, null];
129
+ }
130
+ };
@@ -0,0 +1,77 @@
1
+ import db from '../../db/index.js';
2
+ import { permission } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
+
5
+ /*
6
+ The permissions kempo itself defines. A new site gets them when it is set up (seedDatabase), and `ensureSystemPermissions` adds any
7
+ that an older site is missing when the server starts, so a permission added in a later version exists there too. It only adds: one
8
+ that exists is never touched. The Administrators group has every permission without being given each one.
9
+ */
10
+ export const SYSTEM_PERMISSIONS = [
11
+ { name: 'system:user:create', description: 'Create new users' },
12
+ { name: 'system:user:read', description: 'View users' },
13
+ { name: 'system:user:update', description: 'Edit users' },
14
+ { name: 'system:user:delete', description: 'Delete users' },
15
+
16
+ { name: 'system:group:create', description: 'Create new groups' },
17
+ { name: 'system:group:read', description: 'View groups' },
18
+ { name: 'system:group:update', description: 'Edit groups and manage membership' },
19
+ { name: 'system:group:delete', description: 'Delete groups' },
20
+
21
+ { name: 'system:permissions:read', description: 'View permissions for all users' },
22
+ { name: 'system:permissions:manage', description: 'Add or remove permissions' },
23
+
24
+ { name: 'system:settings:read', description: 'View private settings' },
25
+ { name: 'system:settings:update', description: 'Update settings' },
26
+ { name: 'system:custom-settings:manage', description: 'Create and delete custom settings' },
27
+
28
+ { name: 'system:pages:read', description: 'View pages in admin' },
29
+ { name: 'system:pages:create', description: 'Create new pages' },
30
+ { name: 'system:pages:update', description: 'Edit pages' },
31
+ { name: 'system:pages:delete', description: 'Delete pages' },
32
+
33
+ { name: 'system:menus:create', description: 'Create new menus' },
34
+ { name: 'system:menus:read', description: 'View menus in admin' },
35
+ { name: 'system:menus:update', description: 'Edit menus and menu items' },
36
+ { name: 'system:menus:delete', description: 'Delete menus' },
37
+
38
+ { name: 'system:fragments:read', description: 'View fragments in admin' },
39
+ { name: 'system:fragments:create', description: 'Create new fragments' },
40
+ { name: 'system:fragments:update', description: 'Edit fragments' },
41
+ { name: 'system:fragments:delete', description: 'Delete fragments' },
42
+
43
+ { name: 'system:globals:read', description: 'View global content in admin' },
44
+ { name: 'system:globals:create', description: 'Create new global content' },
45
+ { name: 'system:globals:update', description: 'Edit global content' },
46
+ { name: 'system:globals:delete', description: 'Delete global content' },
47
+
48
+ { name: 'system:extensions:read', description: 'View installed extensions' },
49
+ { name: 'system:extensions:install', description: 'Install extensions' },
50
+ { name: 'system:extensions:uninstall', description: 'Uninstall extensions' },
51
+ { name: 'system:extensions:manage', description: 'Enable and disable extensions' },
52
+
53
+ { name: 'system:realtime:read', description: 'View realtime connections and channels' },
54
+
55
+ { name: 'system:emails:read', description: 'View the log of emails, including what they said' },
56
+ { name: 'system:emails:manage', description: 'Send a test email and try failed emails again' },
57
+
58
+ { name: 'system:updates:manage', description: 'See, check for and install kempo updates, which restarts the server' },
59
+
60
+ { name: 'system:admin:access', description: 'Access admin panel' },
61
+ ];
62
+
63
+ /* Resolves to [error, { added: [names] }]. */
64
+ export const ensureSystemPermissions = async () => {
65
+ try {
66
+ const added = [];
67
+ for(const perm of SYSTEM_PERMISSIONS){
68
+ const [existing] = await db.select({ name: permission.name }).from(permission).where(eq(permission.name, perm.name)).limit(1);
69
+ if(existing) continue;
70
+ await db.insert(permission).values({ name: perm.name, description: perm.description, owner: 'system', createdAt: new Date() });
71
+ added.push(perm.name);
72
+ }
73
+ return [null, { added }];
74
+ } catch {
75
+ return [{ code: 500, msg: 'The system permissions could not be checked' }, null];
76
+ }
77
+ };