kempo 4.5.4 → 4.5.5

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.
package/README.md CHANGED
@@ -430,17 +430,28 @@ To edit the admin portal without rebuilding on every change, point the middlewar
430
430
 
431
431
  ### Running the tests
432
432
 
433
- Most of the suite needs nothing, but the extension-lifecycle and HTTP-contract tests drive a real database and skip themselves when none is reachable. To run everything:
433
+ There are two tiers, and `npm test` needs nothing installed or running:
434
+
435
+ ```bash
436
+ npm test # unit tier: no database service, nothing to start
437
+ npm run test:integration # integration tier: a real Postgres
438
+ ```
439
+
440
+ **Unit tier.** The suites that use the database run against [PGlite](https://pglite.dev), real Postgres compiled to WebAssembly and running inside the test process (`KEMPO_DB=pglite`, set for you). It is not a mock: indexes, `ON CONFLICT`, jsonb, foreign keys and the system-protection triggers all behave as in production, and the schema is generated from the same Drizzle definitions. It starts in about a second and cannot be unreachable, so a database suite can never skip and read as a pass.
441
+
442
+ **Integration tier.** The suites that boot a second server process, drive the make-admin script as a child, or use `LISTEN`/`NOTIFY` across connections cannot share an in-memory database, so they need a real Postgres. They show up in the unit tier as `(integration tier)` entries. Here an unreachable database is an error, not a skip:
434
443
 
435
444
  ```bash
436
445
  docker compose up -d # postgres on 5434, separate from kempo-demo's on 5433
437
446
  npx drizzle-kit push --force # apply the schema
438
- npm test
447
+ DATABASE_URL=postgresql://kempo:kempo_test_password@localhost:5434/kempo_test npm run test:integration
439
448
  ```
440
449
 
441
- `.env.example` has the matching `DATABASE_URL`. The suite seeds its own groups and permissions, and cleans up the rows it creates.
450
+ `.env.example` has the matching `DATABASE_URL`. The suites seed their own groups and permissions (`server/utils/setup/seedDatabase.js`) and clean up the rows they create. CI runs the unit tier on every push with no service, the integration tier alongside it, and publishing is gated on both.
451
+
452
+ Both tiers run only the Node-side suite. Admin components that need a real DOM run in an actual browser via `npm run test:browser` — no database or running server required, since it exercises the real component/SDK code with only `window.fetch` intercepted for canned responses. It temporarily junctions `/kempo-ui`, `/kempo-css` and `/kempo` onto `node_modules`/`dist` at the project root so the admin bundle's absolute imports resolve against kempo-testing-framework's static test server, and removes them afterward regardless of pass/fail.
442
453
 
443
- `npm test` (`kempo-test -n`) only runs the Node-side suite. Admin components that need a real DOM run in an actual browser via `npm run test:browser` — no database or running server required, since it exercises the real component/SDK code with only `window.fetch` intercepted for canned responses. It temporarily junctions `/kempo-ui`, `/kempo-css` and `/kempo` onto `node_modules`/`dist` at the project root so the admin bundle's absolute imports resolve against kempo-testing-framework's static test server, and removes them afterward regardless of pass/fail.
454
+ A test that needs the database imports `./helpers/db.js` (`reachable`, `seedDatabase`, `integrationOnly`). Put a suite in the integration tier by exporting `integrationOnly('name')` when `unitTier` is true.
444
455
 
445
456
  ### Building for Distribution
446
457
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.5.4",
3
+ "version": "4.5.5",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -12,8 +12,9 @@
12
12
  "makeAdmin": "node ./scripts/make-admin.js",
13
13
  "removeAdmin": "node ./scripts/remove-admin.js",
14
14
  "test:email": "node ./scripts/test-email.js",
15
- "test": "kempo-test -n",
16
- "test:node": "kempo-test -n",
15
+ "test": "node scripts/run-tests.js unit",
16
+ "test:node": "node scripts/run-tests.js unit",
17
+ "test:integration": "node scripts/run-tests.js integration",
17
18
  "test:browser": "node scripts/run-browser-tests.js",
18
19
  "link:local": "npm install ../kempo-server ../kempo-ui --no-save",
19
20
  "db:reset": "node ./scripts/reset-db.js"
@@ -62,6 +63,7 @@
62
63
  "kempo-ui": ">=0.5.1"
63
64
  },
64
65
  "devDependencies": {
66
+ "@electric-sql/pglite": "^0.5.9",
65
67
  "esbuild": "^0.28.2",
66
68
  "html-minifier-terser": "^7.2.0",
67
69
  "kempo-testing-framework": "^1.4.6",
@@ -1,215 +1,4 @@
1
- import crypto from 'crypto';
2
- import db from '../server/db/index.js';
3
- import { permission, group, groupPermission, setting, createSettingProtectionTrigger, createGroupProtectionTrigger, createPermissionProtectionTrigger } from '../server/db/schema.js';
4
- import { eq, and, sql } from 'drizzle-orm';
1
+ import seedDatabase from '../server/utils/setup/seedDatabase.js';
5
2
 
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
- ];
53
-
54
- const DEFAULT_GROUPS = {
55
- 'system:Users': {
56
- description: 'Default group for all users',
57
- permissions: []
58
- },
59
- 'system:Administrators': {
60
- description: 'Full system access',
61
- permissions: [
62
- 'system:admin:access',
63
- 'system:user:create', 'system:user:read', 'system:user:update', 'system:user:delete',
64
- 'system:group:create', 'system:group:read', 'system:group:update', 'system:group:delete',
65
- 'system:permissions:list-own', 'system:permissions:check-others', 'system:permissions:list-others', 'system:permissions:manage',
66
- 'system:settings:read', 'system:settings:update', 'system:custom-settings:manage',
67
- 'system:pages:read', 'system:pages:create', 'system:pages:update', 'system:pages:delete',
68
- 'system:menus:create', 'system:menus:read', 'system:menus:update', 'system:menus:delete',
69
- 'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
70
- 'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
71
- 'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
72
- 'system:realtime:read'
73
- ]
74
- }
75
- };
76
-
77
- console.log('=== Seeding Permissions ===\n');
78
-
79
- for(const perm of DEFAULT_PERMISSIONS) {
80
- const existing = await db.select()
81
- .from(permission)
82
- .where(eq(permission.name, perm.name))
83
- .limit(1);
84
-
85
- if(existing.length === 0) {
86
- await db.insert(permission).values({
87
- name: perm.name,
88
- description: perm.description,
89
- owner: 'system',
90
- createdAt: new Date()
91
- });
92
- console.log(`✓ Created permission: ${perm.name}`);
93
- } else {
94
- console.log(`- Permission exists: ${perm.name}`);
95
- }
96
- }
97
-
98
- console.log('\n=== Creating Groups ===\n');
99
-
100
- const allPermissions = await db.select().from(permission);
101
- const permissionMap = Object.fromEntries(
102
- allPermissions.map(p => [p.name, p.name])
103
- );
104
-
105
- for(const [groupName, groupData] of Object.entries(DEFAULT_GROUPS)) {
106
- let groupRecord = await db.select()
107
- .from(group)
108
- .where(eq(group.name, groupName))
109
- .limit(1);
110
-
111
- if(groupRecord.length === 0) {
112
- groupRecord = await db.insert(group).values({
113
- name: groupName,
114
- description: groupData.description,
115
- owner: 'system',
116
- createdAt: new Date()
117
- }).returning();
118
- console.log(`✓ Created group: ${groupName}`);
119
- } else {
120
- console.log(`- Group exists: ${groupName}`);
121
- }
122
-
123
- if(groupName === 'system:Administrators') {
124
- console.log(` Skipping permission assignments (admin has all permissions)`);
125
- continue;
126
- }
127
-
128
- console.log(` Assigning permissions to ${groupName}:`);
129
-
130
- for(const permName of groupData.permissions) {
131
- if(!permissionMap[permName]) {
132
- console.log(` ⚠ Permission not found: ${permName}`);
133
- continue;
134
- }
135
-
136
- const existing = await db.select()
137
- .from(groupPermission)
138
- .where(and(
139
- eq(groupPermission.groupName, groupName),
140
- eq(groupPermission.permissionName, permName)
141
- ))
142
- .limit(1);
143
-
144
- if(existing.length === 0) {
145
- await db.insert(groupPermission).values({
146
- id: crypto.randomUUID(),
147
- groupName: groupName,
148
- permissionName: permName,
149
- createdAt: new Date()
150
- });
151
- console.log(` ✓ ${permName}`);
152
- }
153
- }
154
- }
155
-
156
- console.log('\n=== Creating Default Settings ===\n');
157
-
158
- const DEFAULT_SETTINGS = [
159
- { owner: 'system', name: 'site_name', value: 'Kempo Site', type: 'string', isPublic: true, description: 'The name of the website' },
160
- { owner: 'system', name: 'session_duration_days', value: 7, type: 'number', isPublic: false, description: 'Session duration in days' },
161
- { owner: 'system', name: 'allow_registration', value: true, type: 'boolean', isPublic: true, description: 'Allow new users to register' },
162
- { owner: 'system', name: 'require_email_verification', value: false, type: 'boolean', isPublic: false, description: 'Require email verification before account access' },
163
- { owner: 'system', name: 'verification_url', value: 'http://localhost:3000/verify-email/{{token}}', type: 'string', isPublic: false, description: 'Email verification URL template' },
164
- { 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)' },
165
- { owner: 'system', name: 'password_reset_url', value: 'http://localhost:3000/reset-password/{{token}}', type: 'string', isPublic: false, description: 'Password reset URL template' },
166
- ];
167
-
168
- const { default: setSetting } = await import('../server/utils/settings/setSetting.js');
169
- for(const s of DEFAULT_SETTINGS){
170
- await setSetting(s.owner, s.name, s.value, s.type, s.isPublic, s.description);
171
- console.log(`✓ Created setting: ${s.owner}:${s.name} = ${s.value}`);
172
- }
173
-
174
- console.log('\n=== Updating Existing Records ===\n');
175
-
176
- const updatedPerms = await db.update(permission)
177
- .set({ owner: 'system' })
178
- .where(sql`name LIKE 'system:%'`)
179
- .returning();
180
- console.log(`✓ Updated ${updatedPerms.length} permissions to owner='system'`);
181
-
182
- const updatedGroups = await db.update(group)
183
- .set({ owner: 'system' })
184
- .where(sql`name LIKE 'system:%'`)
185
- .returning();
186
- console.log(`✓ Updated ${updatedGroups.length} groups to owner='system'`);
187
-
188
- console.log('\nCreating database triggers to protect system resources...');
189
-
190
- try {
191
- await db.execute(sql.raw('DROP TRIGGER IF EXISTS protect_system_settings ON setting'));
192
- await db.execute(sql.raw(createSettingProtectionTrigger()));
193
- console.log('✓ System settings protection trigger created');
194
- } catch(e) {
195
- console.log('- Settings trigger already exists');
196
- }
197
-
198
- try {
199
- await db.execute(sql.raw('DROP TRIGGER IF EXISTS protect_system_groups ON "group"'));
200
- await db.execute(sql.raw(createGroupProtectionTrigger()));
201
- console.log('✓ System groups protection trigger created');
202
- } catch(e) {
203
- console.log('- Groups trigger already exists');
204
- }
205
-
206
- try {
207
- await db.execute(sql.raw('DROP TRIGGER IF EXISTS protect_system_permissions ON permission'));
208
- await db.execute(sql.raw(createPermissionProtectionTrigger()));
209
- console.log('✓ System permissions protection trigger created');
210
- } catch(e) {
211
- console.log('- Permissions trigger already exists');
212
- }
213
-
214
- console.log('\n=== Group & Permission Seeding Complete ===');
3
+ await seedDatabase();
215
4
  process.exit(0);
@@ -18,7 +18,43 @@ if(existsSync(appSchemaPath) && realpathSync(appSchemaPath) !== realpathSync(fra
18
18
  appSchema = await import(pathToFileURL(appSchemaPath).href);
19
19
  }
20
20
 
21
- const client = postgres(process.env.DATABASE_URL);
22
- const db = drizzle(client, { schema: { ...frameworkSchema, ...appSchema } });
21
+ const schema = { ...frameworkSchema, ...appSchema };
22
+
23
+ let db;
24
+
25
+ /*
26
+ KEMPO_DB=pglite swaps the server for an in-process, in-memory Postgres (PGlite) with the schema
27
+ applied, so the unit tier needs no service and cannot skip for want of one. It is real Postgres
28
+ (jsonb, partial indexes, ON CONFLICT, triggers), not a mock. Nothing outside tests sets it, and
29
+ PGlite is a dev dependency that is only imported on this branch.
30
+ */
31
+ if(process.env.KEMPO_DB === 'pglite'){
32
+ const [{ PGlite }, { drizzle: drizzlePglite }, { generateDrizzleJson, generateMigration }] = await Promise.all([
33
+ import('@electric-sql/pglite'),
34
+ import('drizzle-orm/pglite'),
35
+ import('drizzle-kit/api')
36
+ ]);
37
+ const client = new PGlite();
38
+ db = drizzlePglite(client, { schema });
39
+
40
+ /*
41
+ The database is empty, so the whole schema is the diff from nothing. This is drizzle-kit's own
42
+ generator, so indexes, unique constraints and foreign keys match `drizzle-kit push` exactly.
43
+ */
44
+ const statements = await generateMigration(generateDrizzleJson({}), generateDrizzleJson(schema));
45
+ await client.exec(statements.join('\n'));
46
+
47
+ /*
48
+ postgres-js answers db.execute() with an array of rows and PGlite with `{ rows }`. Make PGlite look
49
+ like postgres-js (keeping `.rows`) so code and tests that read a raw result work on either.
50
+ */
51
+ const execute = db.execute.bind(db);
52
+ db.execute = async query => {
53
+ const result = await execute(query);
54
+ return Object.assign([...result.rows], { rows: result.rows });
55
+ };
56
+ } else {
57
+ db = drizzle(postgres(process.env.DATABASE_URL), { schema });
58
+ }
23
59
 
24
60
  export default db;
@@ -0,0 +1,227 @@
1
+ import crypto from 'crypto';
2
+ import db from '../../db/index.js';
3
+ import { permission, group, groupPermission, setting, createSettingProtectionTrigger, createGroupProtectionTrigger, createPermissionProtectionTrigger } from '../../db/schema.js';
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
+ ];
53
+
54
+ const DEFAULT_GROUPS = {
55
+ 'system:Users': {
56
+ description: 'Default group for all users',
57
+ permissions: []
58
+ },
59
+ 'system:Administrators': {
60
+ description: 'Full system access',
61
+ permissions: [
62
+ 'system:admin:access',
63
+ 'system:user:create', 'system:user:read', 'system:user:update', 'system:user:delete',
64
+ 'system:group:create', 'system:group:read', 'system:group:update', 'system:group:delete',
65
+ 'system:permissions:list-own', 'system:permissions:check-others', 'system:permissions:list-others', 'system:permissions:manage',
66
+ 'system:settings:read', 'system:settings:update', 'system:custom-settings:manage',
67
+ 'system:pages:read', 'system:pages:create', 'system:pages:update', 'system:pages:delete',
68
+ 'system:menus:create', 'system:menus:read', 'system:menus:update', 'system:menus:delete',
69
+ 'system:fragments:read', 'system:fragments:create', 'system:fragments:update', 'system:fragments:delete',
70
+ 'system:globals:read', 'system:globals:create', 'system:globals:update', 'system:globals:delete',
71
+ 'system:extensions:read', 'system:extensions:install', 'system:extensions:uninstall', 'system:extensions:manage',
72
+ 'system:realtime:read'
73
+ ]
74
+ }
75
+ };
76
+
77
+ /*
78
+ Each trigger definition is a function plus a trigger in one string. node-postgres style drivers send
79
+ that as a simple query; PGlite's execute() prepares it and refuses multiple commands, so it uses exec().
80
+ */
81
+ const runScript = text => db.$client?.exec ? db.$client.exec(text) : db.execute(sql.raw(text));
82
+
83
+ /*
84
+ Seeds the system permissions, groups, settings and the triggers that protect them. Idempotent.
85
+ `scripts/init-db.js` runs it for a site; tests call it in-process so they can share a database
86
+ with the code under test (an in-memory PGlite cannot be reached from a child process).
87
+ */
88
+ export default async ({ log = console.log } = {}) => {
89
+ log('=== Seeding Permissions ===\n');
90
+
91
+ for(const perm of DEFAULT_PERMISSIONS) {
92
+ const existing = await db.select()
93
+ .from(permission)
94
+ .where(eq(permission.name, perm.name))
95
+ .limit(1);
96
+
97
+ if(existing.length === 0) {
98
+ await db.insert(permission).values({
99
+ name: perm.name,
100
+ description: perm.description,
101
+ owner: 'system',
102
+ createdAt: new Date()
103
+ });
104
+ log(`✓ Created permission: ${perm.name}`);
105
+ } else {
106
+ log(`- Permission exists: ${perm.name}`);
107
+ }
108
+ }
109
+
110
+ log('\n=== Creating Groups ===\n');
111
+
112
+ const allPermissions = await db.select().from(permission);
113
+ const permissionMap = Object.fromEntries(
114
+ allPermissions.map(p => [p.name, p.name])
115
+ );
116
+
117
+ for(const [groupName, groupData] of Object.entries(DEFAULT_GROUPS)) {
118
+ let groupRecord = await db.select()
119
+ .from(group)
120
+ .where(eq(group.name, groupName))
121
+ .limit(1);
122
+
123
+ if(groupRecord.length === 0) {
124
+ groupRecord = await db.insert(group).values({
125
+ name: groupName,
126
+ description: groupData.description,
127
+ owner: 'system',
128
+ createdAt: new Date()
129
+ }).returning();
130
+ log(`✓ Created group: ${groupName}`);
131
+ } else {
132
+ log(`- Group exists: ${groupName}`);
133
+ }
134
+
135
+ if(groupName === 'system:Administrators') {
136
+ log(` Skipping permission assignments (admin has all permissions)`);
137
+ continue;
138
+ }
139
+
140
+ log(` Assigning permissions to ${groupName}:`);
141
+
142
+ for(const permName of groupData.permissions) {
143
+ if(!permissionMap[permName]) {
144
+ log(` ⚠ Permission not found: ${permName}`);
145
+ continue;
146
+ }
147
+
148
+ const existing = await db.select()
149
+ .from(groupPermission)
150
+ .where(and(
151
+ eq(groupPermission.groupName, groupName),
152
+ eq(groupPermission.permissionName, permName)
153
+ ))
154
+ .limit(1);
155
+
156
+ if(existing.length === 0) {
157
+ await db.insert(groupPermission).values({
158
+ id: crypto.randomUUID(),
159
+ groupName: groupName,
160
+ permissionName: permName,
161
+ createdAt: new Date()
162
+ });
163
+ log(` ✓ ${permName}`);
164
+ }
165
+ }
166
+ }
167
+
168
+ log('\n=== Creating Default Settings ===\n');
169
+
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
+ ];
179
+
180
+ const { default: setSetting } = await import('../settings/setSetting.js');
181
+ for(const s of DEFAULT_SETTINGS){
182
+ await setSetting(s.owner, s.name, s.value, s.type, s.isPublic, s.description);
183
+ log(`✓ Created setting: ${s.owner}:${s.name} = ${s.value}`);
184
+ }
185
+
186
+ log('\n=== Updating Existing Records ===\n');
187
+
188
+ const updatedPerms = await db.update(permission)
189
+ .set({ owner: 'system' })
190
+ .where(sql`name LIKE 'system:%'`)
191
+ .returning();
192
+ log(`✓ Updated ${updatedPerms.length} permissions to owner='system'`);
193
+
194
+ const updatedGroups = await db.update(group)
195
+ .set({ owner: 'system' })
196
+ .where(sql`name LIKE 'system:%'`)
197
+ .returning();
198
+ log(`✓ Updated ${updatedGroups.length} groups to owner='system'`);
199
+
200
+ log('\nCreating database triggers to protect system resources...');
201
+
202
+ try {
203
+ await db.execute(sql.raw('DROP TRIGGER IF EXISTS protect_system_settings ON setting'));
204
+ await runScript(createSettingProtectionTrigger());
205
+ log('✓ System settings protection trigger created');
206
+ } catch(e) {
207
+ log('- Settings trigger already exists');
208
+ }
209
+
210
+ try {
211
+ await db.execute(sql.raw('DROP TRIGGER IF EXISTS protect_system_groups ON "group"'));
212
+ await runScript(createGroupProtectionTrigger());
213
+ log('✓ System groups protection trigger created');
214
+ } catch(e) {
215
+ log('- Groups trigger already exists');
216
+ }
217
+
218
+ try {
219
+ await db.execute(sql.raw('DROP TRIGGER IF EXISTS protect_system_permissions ON permission'));
220
+ await runScript(createPermissionProtectionTrigger());
221
+ log('✓ System permissions protection trigger created');
222
+ } catch(e) {
223
+ log('- Permissions trigger already exists');
224
+ }
225
+
226
+ log('\n=== Group & Permission Seeding Complete ===');
227
+ };