kempo 4.7.0 → 4.8.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 (82) hide show
  1. package/README.md +3 -1
  2. package/bin/cli.js +15 -33
  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/settings/AdminSettings.js +7 -0
  8. package/dist/admin/components/settings/AdminSettings.js.map +1 -0
  9. package/dist/admin/components/settings/BooleanSetting.js +2 -0
  10. package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
  11. package/dist/admin/components/settings/JsonSetting.js +2 -0
  12. package/dist/admin/components/settings/JsonSetting.js.map +1 -0
  13. package/dist/admin/components/settings/NumberSetting.js +2 -0
  14. package/dist/admin/components/settings/NumberSetting.js.map +1 -0
  15. package/dist/admin/components/settings/SecretSetting.js +2 -0
  16. package/dist/admin/components/settings/SecretSetting.js.map +1 -0
  17. package/dist/admin/components/settings/Setting.js +2 -0
  18. package/dist/admin/components/settings/Setting.js.map +1 -0
  19. package/dist/admin/components/settings/SettingEditor.js +2 -0
  20. package/dist/admin/components/settings/SettingEditor.js.map +1 -0
  21. package/dist/admin/components/settings/SettingsPanel.js +24 -0
  22. package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
  23. package/dist/admin/components/settings/StringSetting.js +2 -0
  24. package/dist/admin/components/settings/StringSetting.js.map +1 -0
  25. package/dist/admin/components/settings/humanize.js +2 -0
  26. package/dist/admin/components/settings/humanize.js.map +1 -0
  27. package/dist/admin/emails/index.page.html +14 -0
  28. package/dist/admin/icons/mail.svg +1 -0
  29. package/dist/admin/nav.fragment.html +6 -0
  30. package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
  31. package/dist/admin/settings/index.page.html +6 -289
  32. package/dist/kempo/api/email-templates/GET.js +2 -0
  33. package/dist/kempo/api/email-templates/GET.js.map +1 -0
  34. package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
  35. package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
  36. package/dist/kempo/api/email-templates/file/GET.js +2 -0
  37. package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
  38. package/dist/kempo/api/email-templates/file/PUT.js +2 -0
  39. package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
  40. package/dist/kempo/api/email-templates/preview/GET.js +2 -0
  41. package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
  42. package/dist/kempo/api/emails/GET.js +2 -0
  43. package/dist/kempo/api/emails/GET.js.map +1 -0
  44. package/dist/kempo/api/emails/[id]/GET.js +2 -0
  45. package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
  46. package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
  47. package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
  48. package/dist/kempo/api/emails/test/POST.js +2 -0
  49. package/dist/kempo/api/emails/test/POST.js.map +1 -0
  50. package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
  51. package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
  52. package/dist/kempo/sdk.js +1 -1
  53. package/dist/kempo/sdk.js.map +1 -1
  54. package/middleware/kempo.js +21 -0
  55. package/package.json +2 -3
  56. package/server/db/schema.js +32 -0
  57. package/server/sdk.js +5 -0
  58. package/server/utils/email/defaultSender.js +2 -0
  59. package/server/utils/email/dispatchEmail.js +56 -0
  60. package/server/utils/email/emailSettings.js +21 -0
  61. package/server/utils/email/emailTemplatePaths.js +36 -0
  62. package/server/utils/email/getEmail.js +14 -0
  63. package/server/utils/email/getSenderEmail.js +11 -0
  64. package/server/utils/email/getSenderFrom.js +18 -0
  65. package/server/utils/email/isValidSender.js +4 -0
  66. package/server/utils/email/listEmailTemplates.js +23 -0
  67. package/server/utils/email/listEmails.js +35 -0
  68. package/server/utils/email/maintainEmails.js +46 -0
  69. package/server/utils/email/pruneEmails.js +29 -0
  70. package/server/utils/email/readEmailTemplate.js +28 -0
  71. package/server/utils/email/resetEmailTemplate.js +15 -0
  72. package/server/utils/email/retryEmail.js +21 -0
  73. package/server/utils/email/saveEmailTemplate.js +38 -0
  74. package/server/utils/email/sendEmail.js +22 -32
  75. package/server/utils/email/sendEmailFromTemplate.js +2 -2
  76. package/server/utils/hooks/triggerHook.js +17 -4
  77. package/server/utils/settings/listSettings.js +10 -5
  78. package/server/utils/settings/setSetting.js +4 -0
  79. package/server/utils/settings/settingRules.js +21 -0
  80. package/server/utils/settings/systemSettings.js +40 -0
  81. package/server/utils/setup/seedDatabase.js +5 -60
  82. package/server/utils/setup/systemPermissions.js +75 -0
@@ -0,0 +1,21 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, eq, inArray } from 'drizzle-orm';
4
+ import dispatchEmail from './dispatchEmail.js';
5
+
6
+ /*
7
+ Tries an email that did not go again, as if it were new: it gets the full number of tries once more. Only a `queued` or `failed`
8
+ email can be tried again (one that is `sent` or being sent is left alone). Resolves like sendEmail.
9
+ */
10
+ export default async id => {
11
+ try {
12
+ const [row] = await db.update(email)
13
+ .set({ status: 'queued', attempts: 0, lastError: null, updatedAt: new Date() })
14
+ .where(and(eq(email.id, String(id ?? '')), inArray(email.status, ['queued', 'failed'])))
15
+ .returning({ id: email.id });
16
+ if(!row) return [{ code: 409, msg: 'Only an email that is waiting or failed can be tried again' }, null];
17
+ } catch {
18
+ return [{ code: 500, msg: 'Failed to retry the email' }, null];
19
+ }
20
+ return dispatchEmail(String(id));
21
+ };
@@ -0,0 +1,38 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'fs/promises';
2
+ import { dirname } from 'path';
3
+ import { locateTemplate } from './emailTemplatePaths.js';
4
+ import renderEmailTemplate from './renderEmailTemplate.js';
5
+
6
+ const MAX_SOURCE = 100000;
7
+
8
+ /*
9
+ The site's own version of an email template, written to `templates/emails/` so it is used from the next email on (the packaged
10
+ one is never touched). Only a template that exists can be saved, so this cannot make up new names. The new version is rendered
11
+ once before it is kept, and the old one is put back when it does not render, so a typo cannot stop the site sending that email.
12
+ Resolves to [error, { template }].
13
+ */
14
+ export default async ({ template, source } = {}) => {
15
+ const located = locateTemplate(template);
16
+ if(!located) return [{ code: 400, msg: 'Invalid email template name' }, null];
17
+ if(typeof source !== 'string' || !source.trim()) return [{ code: 400, msg: 'The template cannot be empty' }, null];
18
+ if(source.length > MAX_SOURCE) return [{ code: 400, msg: `The template can be at most ${MAX_SOURCE} characters` }, null];
19
+ if(source.includes('\u0000')) return [{ code: 400, msg: 'The template cannot contain null characters' }, null];
20
+
21
+ const [site, packaged] = await Promise.all([readFile(located.sitePath, 'utf8').catch(() => null), readFile(located.defaultPath, 'utf8').catch(() => null)]);
22
+ if(site === null && packaged === null) return [{ code: 404, msg: `Email template not found: ${template}` }, null];
23
+
24
+ try {
25
+ await mkdir(dirname(located.sitePath), { recursive: true });
26
+ await writeFile(located.sitePath, source);
27
+ } catch {
28
+ return [{ code: 500, msg: 'The template could not be saved' }, null];
29
+ }
30
+
31
+ const [renderError] = await renderEmailTemplate({ template, data: {} });
32
+ if(renderError){
33
+ if(site === null) await rm(located.sitePath, { force: true });
34
+ else await writeFile(located.sitePath, site);
35
+ return [{ code: 400, msg: `The template does not render, so it was not saved: ${renderError.msg}` }, null];
36
+ }
37
+ return [null, { template }];
38
+ };
@@ -1,48 +1,38 @@
1
- import { Resend } from 'resend';
1
+ import crypto from 'crypto';
2
+ import db from '../../db/index.js';
3
+ import { email } from '../../db/schema.js';
4
+ import getSenderFrom from './getSenderFrom.js';
5
+ import dispatchEmail from './dispatchEmail.js';
2
6
 
3
7
  /*
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));
10
-
11
- export default async ({ to, subject, html }) => {
12
- if(!process.env.RESEND_API_KEY){
13
- return [{ code: 500, msg: 'RESEND_API_KEY not configured' }, null];
14
- }
8
+ Asks for an email to be sent. Kempo does not send email itself: this saves the email in the log (the `email` table) and hands it to
9
+ whichever email extension is installed (kempo-resend, kempo-smtp...) through the `email:send` hook; see dispatchEmail.js.
15
10
 
11
+ `template` is the name of the template the email was made from, if any, and is its type in the log. It resolves to
12
+ `[null, { id, status: 'sent' }]` when a sender sent it, or `[{ code, msg, emailId }, null]` when none did: 503 when there is no email
13
+ extension installed, 502 when one tried and failed. Either way the email is in the log, and a failed one is tried again (see the
14
+ `email_max_retries` setting).
15
+ */
16
+ export default async ({ to, subject, html, template = null } = {}) => {
16
17
  if(!to){
17
18
  return [{ code: 400, msg: 'Recipient email is required' }, null];
18
19
  }
19
-
20
+
20
21
  if(!subject){
21
22
  return [{ code: 400, msg: 'Email subject is required' }, null];
22
23
  }
23
-
24
+
24
25
  if(!html){
25
26
  return [{ code: 400, msg: 'Email content is required' }, null];
26
27
  }
27
28
 
28
- const from = process.env.SMTP_FROM || 'onboarding@resend.dev';
29
-
29
+ const id = crypto.randomUUID();
30
+ const now = new Date();
30
31
  try {
31
- const { data, error } = await client().emails.send({
32
- from,
33
- to,
34
- subject,
35
- html,
36
- });
37
-
38
- if(error){
39
- console.error('Email send error:', error);
40
- return [{ code: 500, msg: error.message || 'Failed to send email' }, null];
41
- }
42
-
43
- return [null, { success: true, messageId: data.id }];
44
- } catch(error){
45
- console.error('Email send error:', error);
46
- return [{ code: 500, msg: 'Failed to send email' }, null];
32
+ await db.insert(email).values({ id, type: template || 'other', recipient: String(to), sender: await getSenderFrom(), subject: String(subject), html: String(html), status: 'queued', attempts: 0, createdAt: now, updatedAt: now });
33
+ } catch(error) {
34
+ return [{ code: 500, msg: 'The email could not be saved to send' }, null];
47
35
  }
36
+
37
+ return dispatchEmail(id);
48
38
  };
@@ -3,7 +3,7 @@ import sendEmail from './sendEmail.js';
3
3
 
4
4
  /*
5
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.
6
+ see renderEmailTemplate.js. It is also the email's type in the log, which the retention settings go by.
7
7
  */
8
8
  export default async ({ to, subject, template, data = {} }) => {
9
9
  if(!to){
@@ -23,5 +23,5 @@ export default async ({ to, subject, template, data = {} }) => {
23
23
  return [error, null];
24
24
  }
25
25
 
26
- return await sendEmail({ to, subject, html });
26
+ return await sendEmail({ to, subject, html, template });
27
27
  };
@@ -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,40 @@
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
+ ];
24
+
25
+ /* Resolves to [error, { added: [names] }]. */
26
+ export const ensureSystemSettings = async () => {
27
+ try {
28
+ const added = [];
29
+ for(const entry of SYSTEM_SETTINGS){
30
+ const [existing] = await db.select({ name: setting.name }).from(setting).where(eq(setting.name, `${entry.owner}:${entry.name}`)).limit(1);
31
+ if(existing) continue;
32
+ const [error] = await setSetting(entry.owner, entry.name, entry.value, entry.type, entry.isPublic, entry.description);
33
+ if(error) return [error, null];
34
+ added.push(entry.name);
35
+ }
36
+ return [null, { added }];
37
+ } catch {
38
+ return [{ code: 500, msg: 'The system settings could not be checked' }, null];
39
+ }
40
+ };
@@ -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'
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,75 @@
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:admin:access', description: 'Access admin panel' },
59
+ ];
60
+
61
+ /* Resolves to [error, { added: [names] }]. */
62
+ export const ensureSystemPermissions = async () => {
63
+ try {
64
+ const added = [];
65
+ for(const perm of SYSTEM_PERMISSIONS){
66
+ const [existing] = await db.select({ name: permission.name }).from(permission).where(eq(permission.name, perm.name)).limit(1);
67
+ if(existing) continue;
68
+ await db.insert(permission).values({ name: perm.name, description: perm.description, owner: 'system', createdAt: new Date() });
69
+ added.push(perm.name);
70
+ }
71
+ return [null, { added }];
72
+ } catch {
73
+ return [{ code: 500, msg: 'The system permissions could not be checked' }, null];
74
+ }
75
+ };