Module System
Introduction
Reepolee's module system lets you group routes under URL prefixes that are gated by user permissions. A typical use is an admin panel at /admin/ - only users with the admin module in their modules_tags field can see it. The modules themselves are stored in a database table, so adding or removing a module is a row insert, not a code change.
The system has three parts:
- A
modulesdatabase table that stores the available module codes. - A URL prefix resolution step that detects which module the current request belongs to.
- A middleware guard (
require_module_mw()) that checks the user's permissions before the handler runs.
Routes are organised using mount_prefix() from $lib/middleware, which scopes a route table under a prefix and optionally wraps each handler with module-guarding middleware.
The Modules Table
Modules are defined in the modules database table:
CREATE TABLE modules (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT DEFAULT '' NULL,
created_at DATETIME DEFAULT current_timestamp
);
Each row is one module. The code column is what you reference in middleware - it must be lowercase. The name column is the human-readable label.
The shipped seed data includes the default module (always accessible, no prefix) and any project-specific modules you add:
INSERT INTO modules (code, name) VALUES
('default', 'Default'),
('admin', 'Admin Panel'),
('system', 'System Settings');
The default module has special meaning - it's the catch-all for routes that aren't under any prefix. Every user implicitly has access to the default namespace.
Loading Modules at Startup
At server startup, lib/modules.ts loads module codes from the database:
import { load_modules } from "$lib/modules";
// In server.ts during initialisation
await load_modules();
load_modules() reads all non-default module codes from the modules table and caches them in memory for the lifetime of the process. If the database is unavailable, it logs an error and sets the cache to an empty array - effectively disabling all module-prefixed routes.
The cached prefixes are then available via get_available_prefixes():
import { get_available_prefixes } from "$lib/modules";
const prefixes = get_available_prefixes();
// ["admin", "system"]
This is used internally by create_ctx() to detect which module prefix the current request URL matches.
Defining Routes With Prefixes
mount_prefix() from $lib/middleware scopes a route table under a URL prefix and optionally wraps each handler with middlewares:
import { mount_prefix, require_module_mw } from "$lib/middleware";
import { admin_users_crud } from "$routes/admin/users";
export const routes = {
"/": home_page,
...mount_prefix(
"/admin",
{
...admin_users_crud,
},
require_module_mw("admin"),
),
};
The second argument to mount_prefix is a RouteTable - the same shape as the top-level routes export. The third argument (and beyond) are middlewares applied to every handler in the table.
require_module_mw("admin") does two things:
- Checks for a valid authenticated session. If none exists, redirects to
/login. - Checks that the user's
modules_tagsfield contains the required module code (e.g.,"admin"). If not, returns a403 Forbidden.
Users' module assignments are stored as a comma-separated string in the users.modules_tags column:
-- A user with admin and system access
UPDATE users SET modules_tags = 'admin,system' WHERE id = 1;
The field is parsed by splitting on commas and whitespace, so "admin, system" and "admin, system" both work.
The Route Builder Pattern
For larger applications with many module-routed resources, lib/route_builder.ts provides a declarative pattern:
import { build_routes, build_nav_routes } from "$lib/route_builder";
const user_routes: RouteDef[] = [
{
url: "/admin/users",
crud: admin_users_crud,
module: "admin",
nav_title_key: "users",
},
{
url: "/admin/partners",
crud: admin_partners_crud,
module: "admin",
nav_title_key: "partners",
},
{
url: "/api/public",
handler: api_handler,
module: null, // no auth required
},
];
export const routes = build_routes(user_routes);
export const nav_routes = build_nav_routes(user_routes.filter((r) => r.module !== null));
Each RouteDef can specify:
| Field | Purpose |
|---|---|
url | The URL path (e.g. /admin/users) |
handler | A single handler function |
methods | A method map ({ GET: ..., POST: ... }) |
resource | A pre-built route table to merge in |
crud | A pre-built CRUD route table (mounted via mount_prefix) |
module | The module code for middleware guarding (null = no guard) |
nav_title_key | Translation key for the navigation menu entry |
is_menu_entry | Whether to include this route in the navigation (default true) |
When crud is provided, build_routes() automatically wraps it with mount_prefix() using the URL's parent path as the prefix and the specified module's middleware. So /admin/users with crud: admin_users_crud and module: "admin" becomes a mount_prefix("/admin", admin_users_crud, require_module_mw("admin")) entry.
URL Prefix Detection at Request Time
When create_ctx() processes a request, it checks the URL path against the loaded module prefixes:
const ctx = await create_ctx(req, import.meta.dir);
// ctx.prefix → "admin" for /admin/users, null for /login
The ctx.prefix value is available in templates as props.prefix. This lets you conditionally render UI elements based on which module section the user is in:
{#if props.prefix === "admin" }
<nav class="admin-sidebar">...</nav>
{/if}
Generating Module Routes
The resource generator supports the --prefix flag for creating routes under a module:
bun generator/crud users --prefix admin
This writes files to routes/admin/users/ and updates routes/routes.ts with the correct mount_prefix and require_module_mw("admin") wrapper. See Generators for the full command reference.
Adding a New Module
Adding a new section to your application:
- Insert a row into the
modulestable:
INSERT INTO modules (code, name) VALUES ('analytics', 'Analytics Dashboard');
- Restart the server so
load_modules()picks up the new code. - Create route handlers under
routes/analytics/. - Register the routes with
mount_prefix("/analytics", ..., require_module_mw("analytics")). - Assign the module to users via their
modules_tagsfield.