kempo
v4.7.0
Published
A fullstack CMS framework for creating websites with user authentication
Readme
Kempo
A fullstack CMS framework for creating websites with user authentication, group-based permissions, and an admin panel. Built on kempo-server, kempo-css, and kempo-ui.
Quick Start
npx kempo initThis initializes the current directory with everything scaffolded:
| File / Directory | Purpose |
|---|---|
| package.json | Pre-configured with start script |
| .env | Environment variables template |
| .gitignore | Ignores node_modules and .env |
| docker-compose.yml | PostgreSQL database via Docker |
| public/ | Your website's pages, assets, and route handlers |
| public/.config.json | Server routing and middleware config |
| server/db/schema.js | Your app's database table definitions |
| drizzle.config.js | Drizzle ORM config (points to both your schema and kempo's) |
These files are yours to modify. Kempo will not overwrite them on future installs.
Setup the Database
Update the credentials in .env and docker-compose.yml if needed, then:
docker compose up -d
npx drizzle-kit push
node node_modules/kempo/scripts/init-db.jsStart the Server
npm startYour site is running at http://localhost:3000.
Create an Admin User
npx kempo init asks how to create the first admin:
- In the terminal - the wizard prompts for a name, email and password.
- In the browser - visit
/setupon your new site and fill in the form. The setup page and its API route are deleted frompublic/setup/as soon as the admin is created, and they refuse to run (and delete themselves) if an administrator already exists, so they can only be used once.
To promote an existing user instead, register through the UI at /register, then run:
node node_modules/kempo/scripts/make-admin.jsThis prompts for the user's email and adds them to the system:Administrators group. To remove admin access:
node node_modules/kempo/scripts/remove-admin.jsScaffolded Pages
Kempo scaffolds these pages into your public/ directory. All are fully functional and customizable.
| Route | Page |
|---|---|
| / | Home page |
| /login | Login form |
| /register | Registration form |
| /setup | First-run admin setup (deletes itself once an admin exists) |
| /forgot-password | Password recovery |
| /reset-password/[token] | Password reset (from email link) |
| /verify-email | Email verification notice |
| /verify-email/[token] | Email verification (from email link) |
| /account | User account page (requires login) |
| /admin | Admin dashboard (requires system:admin:access permission) |
| /admin/accounts/users | User management |
| /admin/accounts/users/create | Create user |
| /admin/accounts/users/[id] | Edit user |
| /admin/accounts/groups | Group management |
| /admin/accounts/permissions | Permission management |
| /admin/settings | Settings management |
Project Structure
After setup, your project looks like this:
my-site/
├── .env
├── drizzle.config.js # Drizzle config (your schema + kempo's)
├── package.json
├── public/ # Your website
│ ├── .config.json # Server routing & middleware config
│ ├── index.html # Home page
│ ├── styles.css # Your custom styles
│ ├── login/
│ ├── register/
│ ├── account/
│ ├── admin/
│ └── ...
├── server/
│ └── db/
│ └── schema.js # Your app's database tables
└── node_modules/
└── kempo/
├── dist/kempo/ # SDK, API endpoints, components
├── server/
│ ├── db/schema.js # Framework tables (user, session, group, etc.)
│ └── utils/ # Backend utilities
├── middleware/ # Auth middleware
└── templates/ # Nav and email templatesCustom Database Tables
Define your app's tables in server/db/schema.js. You can reference kempo's tables for foreign keys:
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { user } from 'kempo/server/db/schema.js';
export const post = pgTable('post', {
id: text('id').primaryKey(),
authorId: text('authorId').references(() => user.id),
title: text('title').notNull(),
content: text('content'),
createdAt: timestamp('createdAt').notNull(),
});Then push to the database:
npx drizzle-kit pushYour drizzle.config.js already points to both schema files, so both framework and app tables are managed together.
Querying the Database
Import the shared database instance from kempo:
import db from 'kempo/server/db/index.js';
import { post } from '../../db/schema.js';
export default async ({ authorId, title, content }) => {
const result = await db.insert(post).values({
id: crypto.randomUUID(),
authorId,
title,
content,
createdAt: new Date(),
}).returning();
return [null, result[0]];
};Framework Database Tables
Kempo provides these tables out of the box:
| Table | Purpose |
|---|---|
| user | User accounts (id, name, email, passwordHash, emailVerified, timestamps) |
| session | Login sessions (token, userId, expiresAt, ipAddress, userAgent) |
| verificationToken | Email verification and password reset tokens |
| group | Permission groups (e.g. system:Users, system:Administrators) |
| userGroup | User-to-group membership |
| permission | Named permissions (e.g. system:admin:access) |
| groupPermission | Group-to-permission assignments |
| setting | Key-value settings with type, visibility, and ownership |
| realtimeMessage | Messages on channels that persist, so a reconnecting client can replay what it missed |
| realtimeChannel | How far each realtime channel has been pruned, to tell a client it missed messages |
| notification | Notifications raised by core or extensions: title, message, level, actions, dedupe key |
| notificationRecipient | One row per person a notification was sent to, with their own read, handled and dismissed state |
| dashboardWidget | Widgets extensions add to the admin dashboard: element, module script, size limits, permission |
| scheduledJob | Recurring jobs extensions declare with jobs: interval, next run, and the lease that stops two processes running one |
Frontend SDK
Kempo provides a client-side SDK at /kempo/sdk.js for calling API endpoints.
<script type="module">
import { login, getSession, logout } from '/kempo/sdk.js';
const [error, result] = await login({ email: '[email protected]', password: 'secret' });
if(error){
console.error(error.msg);
}
</script>All SDK methods return an [error, data] tuple. On failure, error is { code, msg }. On success, error is null.
Auth
| Method | Parameters |
|---|---|
| register | { email, password, name } |
| login | { email, password } |
| logout | (none) |
| getSession | (none) |
| forgotPassword | { email } |
| resetPassword | { token, password, logoutAll? } |
| changePassword | { currentPassword, newPassword, logoutAll? } |
| verifyEmail | { token } |
Permissions
| Method | Parameters |
|---|---|
| currentUserHasPermission | { permission } |
| currentUserHasAllPermissions | { permissions } |
| currentUserHasSomePermissions | { permissions } |
| getAllCurrentUserPermissions | (none) |
| userHasPermission | { userid, permission } |
| userHasAllPermissions | { userid, permissions } |
| userHasSomePermissions | { userid, permissions } |
| getAllUserPermissions | { userid } |
Users
| Method | Parameters |
|---|---|
| getUsers | { limit?, offset? } |
| getUser | userid |
| createUser | { name, email, password, emailVerified? } |
| updateUser | { id, name?, email?, emailVerified?, createdAt? } |
| deleteUsers | ids (array) |
Sessions
| Method | Parameters |
|---|---|
| getUserSessions | userid |
| deleteUserSession | userid, sessionToken |
| deleteExpiredUserSessions | userid |
Groups
| Method | Parameters |
|---|---|
| listGroups | (none) |
| getUserGroups | userid |
| addUserToGroup | userid, groupName |
| removeUserFromGroup | userid, groupName |
Frontend Components
Kempo provides web components you can use in your pages:
<script type="module" src="/kempo/components/Permission.js"></script>
<kempo-permission has="system:admin:access">
<a href="/admin">Admin Panel</a>
</kempo-permission>| Component | Tag | Purpose |
|---|---|---|
| Permission.js | <kempo-permission> | Conditionally render content based on permissions |
| LogoutBtn.js | <kempo-logout-btn> | Logout button |
| UserName.js | <kempo-user-name> | Display current user's name |
| UserEmail.js | <kempo-user-email> | Display current user's email |
| UserEmailVerified.js | <kempo-user-email-verified> | Display email verification status |
| UserCreatedAt.js | <kempo-user-created-at> | Display account creation date |
Adding Routes
Create files in public/ following the kempo-server routing convention. File names map to HTTP methods:
public/
api/
posts/
GET.js → GET /api/posts
POST.js → POST /api/posts
[id]/
GET.js → GET /api/posts/:id
PATCH.js → PATCH /api/posts/:id
DELETE.js → DELETE /api/posts/:idRoute handlers receive (request, response):
import db from 'kempo/server/db/index.js';
import { post } from '../../../server/db/schema.js';
import { eq } from 'drizzle-orm';
export default async (request, response) => {
const posts = await db.select().from(post);
response.json({ posts });
};request.body is pre-parsed based on Content-Type. Query params are at request.query. Cookies are at request.cookies. Path params from [param] folders are at request.params.
Auth Middleware
The auth middleware protects /account and /admin routes by default:
/account/*— Requires a valid session. Redirects to/loginif not authenticated./admin/*— Requires thesystem:admin:accesspermission. Redirects to/accountif unauthorized.
These are configured in public/.config.json and can be customized.
Default Permissions
The init-db.js script seeds these permissions:
| Permission | Description |
|---|---|
| system:admin:access | Access admin panel |
| system:content:create | Create new content |
| system:content:update | Edit content |
| system:content:delete | Delete content |
| system:user:create | Create new users |
| system:user:read | View users |
| system:user:update | Edit users |
| system:user:delete | Delete users |
| system:group:create | Create new groups |
| system:group:read | View groups |
| system:group:update | Edit groups and manage membership |
| system:group:delete | Delete groups |
| system:permissions:read | View permissions for all users |
| system:permissions:manage | Add or remove permissions |
| system:settings:read | View private settings |
| system:settings:update | Update settings |
| system:custom-settings:manage | Create and delete custom settings |
| system:realtime:read | View realtime connections and channels |
Two default groups are created:
- system:Users — Assigned to all users, no permissions by default.
- system:Administrators — Full system access (all permissions above).
Default Settings
| Setting | Default | Type | Public | Description |
|---|---|---|---|---|
| site_name | Kempo Site | string | yes | The name of the website |
| session_duration_days | 7 | number | no | Session duration in days |
| allow_registration | true | boolean | yes | Allow new users to register |
| require_email_verification | false | boolean | no | Require email verification before account access |
| verification_url | http://localhost:3000/verify-email/{{token}} | string | no | Email verification URL template |
| password_reset_url | http://localhost:3000/reset-password/{{token}} | string | no | Password reset URL template |
Settings can be managed through the admin panel at /admin/settings or the SDK.
Realtime
Extensions can push live updates to browsers: declare a channel, publish to it from any server code, and every subscribed browser on any kempo process receives it. Channels can persist messages so a client that reconnects replays what it missed, and a browser client handles connecting and reconnecting. Browsers can also send to a channel, where the extension's own handler receives and answers it, and extensions observe connections through hooks and act on them through SDK functions, so interactive features (chat, collaboration, games) can be built as extensions without touching a socket. Fast traffic can use an in-memory channel that skips the database, and connections are limited and backpressured. It uses a Postgres LISTEN/NOTIFY bus, so it needs no extra service. See docs/realtime.md.
Admin Dashboard
The admin home page is a grid of widgets one to four blocks wide and one to three tall. Kempo provides widgets for pages, content, users, notifications and extensions; extensions add their own with widgets in kempo-config.json or registerDashboardWidget. Each person chooses which are shown, their order and size, kept in their browser's localStorage, and a widget only loads its script and data once it is shown. Existing sites need npx drizzle-kit push to add the dashboardWidget table (until then only Kempo's own widgets appear). See docs/dashboard.md.
Notifications
Core and extensions can notify users with createNotification: to specific users, everyone holding a permission, or a group. Each person has their own read state and history, a notification can carry buttons that link somewhere or call an API as the signed-in user, and a dedupeKey keeps a repeating event to one notification. The admin has a bell with an unread count and a Notifications page; a public site can use <k-notification-bell>. Existing sites need npx drizzle-kit push to add the two new tables. See docs/notifications.md.
Scheduled Jobs, Aliases and Email Templates for Extensions
Extensions can declare jobs in kempo-config.json and kempo runs them on a timer (several processes sharing a database run each once per interval), aliases to answer on a short address such as /cart, and ship their own email templates as owner/name. Existing sites need npx drizzle-kit push to add the scheduledJob table. See docs/extensions/creating-extensions.md.
Kempo uses Resend for sending emails. Set RESEND_API_KEY and EMAIL_FROM in your .env file.
Built-in email templates (in templates/emails/):
verify-email.html— Email verification linkreset-password.html— Password reset link
Styling
Kempo uses kempo-css for styling and kempo-ui for UI components. Both are served automatically via custom routes configured in public/.config.json.
<link rel="stylesheet" href="/kempo-css/kempo.min.css">
<script type="module" src="/kempo-ui/components/Import.js"></script>Refer to the kempo-css docs and kempo-ui docs for available utility classes and components.
The Rich Text Editor
The admin's WYSIWYG is kempo-ui's <k-html-editor>, which is built on Lexical. Kempo ships Lexical itself rather than letting the editor fetch it from a CDN, so the editor keeps working offline, behind a strict Content-Security-Policy, on an air-gapped install, and during a CDN outage — and no third party sees a request every time an administrator opens a page.
Nothing is required to set this up. npm run build bundles Lexical into dist/kempo/vendor/lexical/, kempo's middleware serves it, and window.kempo.lexicalUrl points the editor at it:
window.kempo = { ..., lexicalUrl: '/kempo/vendor/lexical' };To use a CDN instead — or a copy you host elsewhere — change that value, or set lexical-src on an individual editor. Removing it falls back to https://esm.sh.
Lexical cannot be served straight from node_modules: its published entry reads process.env.NODE_ENV, which does not exist in a browser, and its builds import bare specifiers that a browser cannot resolve. The build therefore bundles each package with esbuild, with code splitting so all nine share a single copy of Lexical's core — Lexical relies on class identity, and duplicate copies produce an editor that loads but stays empty.
Development
Running the framework
Kempo is developed against kempo-demo, a real consumer project that installs kempo, kempo-blog and the rest the way a user would. Running a server inside this repo instead was misleading: paths resolve differently when the framework runs from within node_modules/kempo than when it runs from its own directory, so problems appeared here that users never hit, and vice versa.
cd ../kempo-demo
npm install
npm run link:local # symlink the sibling checkouts into node_modules
npm start # http://localhost:9876After editing src/ here, rebuild so the demo picks it up — dist/ is what actually gets served:
npm run build:deps # from kempo-demo; builds kempo, kempo-server, kempo-ui, kempo-cssTo edit the admin portal without rebuilding on every change, point the middleware at the source instead of the build by adding adminRoot to the demo's public/.config.json:
{
"middleware": {
"custom": ["../node_modules/kempo/middleware/kempo.js"],
"adminRoot": "node_modules/kempo/src/admin"
}
}Running the tests
There are two tiers, and npm test needs nothing installed or running:
npm test # unit tier: no database service, nothing to start
npm run test:integration # integration tier: a real PostgresUnit tier. The suites that use the database run against PGlite, 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.
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:
docker compose up -d # postgres on 5434, separate from kempo-demo's on 5433
npx drizzle-kit push --force # apply the schema
DATABASE_URL=postgresql://kempo:kempo_test_password@localhost:5434/kempo_test npm run test:integration.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.
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.
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.
Building for Distribution
When preparing the framework for npm release, minify and optimize the source code:
npm run buildThis processes all files in src/ and outputs to dist/:
- JavaScript files are minified with terser, mangled, and source maps are generated
- HTML files are minified while preserving CMS front matter comments (important for page templating)
- JSON files are minified
- SVG icons and other assets are copied as-is
The build achieves ~15% size reduction and runs in ~0.6s.
Output Structure:
dist/
├── admin/ # Minified admin portal
├── kempo/ # Minified framework SDK, API routes, components
└── ...See spec/concepts/build.md for detailed build documentation.
Project Structure
Framework source:
kempo/
├── src/
│ ├── admin/ # Admin portal source (minified to dist/admin/)
│ └── kempo/ # Framework SDK & API source (minified to dist/kempo/)
├── server/ # Backend utilities (not minified)
├── middleware/ # Auth middleware
├── app-public/ # Consumer boilerplate (not minified)
├── app-templates/ # Email and page templates (not minified)
├── app-server/ # Server scaffolding (not minified)
└── dist/ # Built & minified output (generated by `npm run build`)Consumer project (created by npx kempo init):
my-site/
├── public/ # Copied from kempo's app-public/
├── server/db/schema.js # Copied from kempo's app-server/
├── templates/ # Copied from kempo's app-templates/
└── ...License
MIT
