Example Plugins
Example Plugins
Complete example plugins demonstrating common patterns. Each example includes the full init.lua and an explanation of the key design patterns used.
For a step-by-step walkthrough of building a plugin from scratch, see the tutorial.
Task Tracker
A CRUD API with categories and tasks, demonstrating foreign keys, transactions, and pagination.
Key patterns: Multi-table relationships, transaction-based seeding, paginated list endpoints.
plugin_info = {
name = "task_tracker",
version = "1.0.0",
description = "Task tracking with categories",
author = "Example",
}
-- List categories
http.handle("GET", "/categories", function(req)
local categories = db.query("categories", {
order_by = "name",
limit = 100,
})
return { status = 200, json = categories }
end)
-- Create category
http.handle("POST", "/categories", function(req)
if not req.json or not req.json.name or req.json.name == "" then
return { status = 400, json = { error = "name is required" } }
end
local id = db.ulid()
db.insert("categories", {
id = id,
name = req.json.name,
})
local created = db.query_one("categories", { where = { id = id } })
return { status = 201, json = created }
end)
-- List tasks with pagination
http.handle("GET", "/tasks", function(req)
local page = tonumber(req.query.page) or 1
local per_page = tonumber(req.query.per_page) or 20
if per_page > 100 then per_page = 100 end
local offset = (page - 1) * per_page
local opts = {
order_by = "created_at DESC",
limit = per_page,
offset = offset,
}
-- Optional filters
if req.query.status then
opts.where = { status = req.query.status }
end
if req.query.category_id then
opts.where = opts.where or {}
opts.where.category_id = req.query.category_id
end
local tasks = db.query("tasks", opts)
local total = db.count("tasks", opts.where and { where = opts.where } or {})
return {
status = 200,
json = {
tasks = tasks,
total = total,
page = page,
per_page = per_page,
pages = math.ceil(total / per_page),
},
}
end)
-- Create task
http.handle("POST", "/tasks", function(req)
if not req.json then
return { status = 400, json = { error = "JSON body required" } }
end
local t = req.json
if not t.title or t.title == "" then
return { status = 400, json = { error = "title is required" } }
end
if not t.category_id or t.category_id == "" then
return { status = 400, json = { error = "category_id is required" } }
end
if not db.exists("categories", { where = { id = t.category_id } }) then
return { status = 400, json = { error = "category not found" } }
end
local id = db.ulid()
db.insert("tasks", {
id = id,
title = t.title,
description = t.description or "",
status = "todo",
priority = t.priority or 0,
category_id = t.category_id,
})
local created = db.query_one("tasks", { where = { id = id } })
return { status = 201, json = created }
end)
-- Get task
http.handle("GET", "/tasks/{id}", function(req)
local task = db.query_one("tasks", { where = { id = req.params.id } })
if not task then
return { status = 404, json = { error = "not found" } }
end
return { status = 200, json = task }
end)
-- Update task
http.handle("PUT", "/tasks/{id}", function(req)
if not req.json then
return { status = 400, json = { error = "JSON body required" } }
end
if not db.exists("tasks", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
local updates = {}
local t = req.json
if t.title then updates.title = t.title end
if t.description then updates.description = t.description end
if t.status then updates.status = t.status end
if t.priority then updates.priority = t.priority end
if next(updates) == nil then
return { status = 400, json = { error = "no fields to update" } }
end
db.update("tasks", { set = updates, where = { id = req.params.id } })
local updated = db.query_one("tasks", { where = { id = req.params.id } })
return { status = 200, json = updated }
end)
-- Delete task
http.handle("DELETE", "/tasks/{id}", function(req)
if not db.exists("tasks", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
db.delete("tasks", { where = { id = req.params.id } })
return { status = 204 }
end)
function on_init()
db.define_table("categories", {
columns = {
{ name = "name", type = "text", not_null = true },
},
indexes = {
{ columns = {"name"}, unique = true },
},
})
db.define_table("tasks", {
columns = {
{ name = "title", type = "text", not_null = true },
{ name = "description", type = "text", default = "" },
{ name = "status", type = "text", not_null = true, default = "todo" },
{ name = "priority", type = "integer", default = 0 },
{ name = "category_id", type = "text", not_null = true },
},
indexes = {
{ columns = {"status"} },
{ columns = {"category_id"} },
{ columns = {"status", "priority"} },
},
foreign_keys = {
{
column = "category_id",
ref_table = "plugin_task_tracker_categories",
ref_column = "id",
on_delete = "CASCADE",
},
},
})
-- Seed default categories using a transaction
if db.count("categories", {}) == 0 then
local ok, err = db.transaction(function()
db.insert("categories", { name = "Bug" })
db.insert("categories", { name = "Feature" })
db.insert("categories", { name = "Improvement" })
db.insert("categories", { name = "Documentation" })
end)
if not ok then
log.error("Failed to seed categories", { err = err })
end
end
log.info("task_tracker initialized")
end
Content Validator
Before-hooks that enforce business rules on CMS content. Demonstrates validation logic, conditional abort, and priority ordering.
Key patterns: Before-hooks that call error() to abort transactions, multiple hooks with priority ordering, no db.* calls in before-hooks.
plugin_info = {
name = "content_validator",
version = "1.0.0",
description = "Enforce content business rules",
author = "Example",
}
-- Validate required fields on content creation (runs first)
hooks.on("before_create", "content_data", function(data)
if not data.title or data.title == "" then
error("title is required")
end
if data.title and string.len(data.title) > 200 then
error("title must be 200 characters or fewer")
end
end, { priority = 10 })
-- Validate slug format on content creation (runs second)
hooks.on("before_create", "content_data", function(data)
if data.slug then
-- Only allow lowercase letters, numbers, and hyphens
for i = 1, #data.slug do
local c = data.slug:sub(i, i)
local b = string.byte(c)
local valid = (b >= 97 and b <= 122) -- a-z
or (b >= 48 and b <= 57) -- 0-9
or b == 45 -- hyphen
if not valid then
error("slug contains invalid character: " .. c)
end
end
end
end, { priority = 20 })
-- Validate content before publishing
hooks.on("before_publish", "content_data", function(data)
if not data.title or data.title == "" then
error("cannot publish content without a title")
end
end, { priority = 10 })
-- Log validation passes (after-hook, can use db.*)
hooks.on("after_create", "content_data", function(data)
log.info("Content passed validation", {
id = data.id,
title = data.title,
})
end)
function on_init()
log.info("content_validator initialized")
end
The before_create hooks run at priorities 10 and 20, ensuring the required-fields check runs before the slug format check. Both hooks run inside the CMS transaction. If either calls error(), the create operation is aborted and the client receives HTTP 422 with the error message.
No db.* calls appear in the before-hooks. This is required -- before-hooks block all database operations to prevent transaction deadlocks.
Webhook Relay
After-hooks that POST to external services when content changes. Demonstrates public routes for receiving webhook callbacks and async notification via after-hooks.
Key patterns: After-hooks with database logging, public routes for inbound webhooks, structured error logging.
plugin_info = {
name = "webhook_relay",
version = "1.0.0",
description = "Relay content events to external services",
author = "Example",
}
-- Public endpoint for external services to register webhooks
http.handle("POST", "/register", function(req)
if not req.json then
return { status = 400, json = { error = "JSON body required" } }
end
local w = req.json
if not w.url or w.url == "" then
return { status = 400, json = { error = "url is required" } }
end
if not w.events or type(w.events) ~= "string" then
return { status = 400, json = { error = "events is required (comma-separated)" } }
end
local id = db.ulid()
db.insert("endpoints", {
id = id,
url = w.url,
events = w.events,
active = 1,
secret = w.secret or "",
})
return { status = 201, json = { id = id, url = w.url, events = w.events } }
end, { public = true })
-- List registered endpoints (authenticated)
http.handle("GET", "/endpoints", function(req)
local endpoints = db.query("endpoints", {
where = { active = 1 },
order_by = "created_at DESC",
})
return { status = 200, json = endpoints }
end)
-- Delete an endpoint
http.handle("DELETE", "/endpoints/{id}", function(req)
if not db.exists("endpoints", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
db.delete("endpoints", { where = { id = req.params.id } })
return { status = 204 }
end)
-- Log delivery attempts (authenticated)
http.handle("GET", "/deliveries", function(req)
local deliveries = db.query("deliveries", {
order_by = "created_at DESC",
limit = 50,
})
return { status = 200, json = deliveries }
end)
-- After-hooks: log content events for delivery
-- NOTE: Plugins cannot make outbound HTTP requests directly.
-- This pattern logs events to a plugin table. An external worker
-- (cron job, queue consumer) polls the deliveries table and
-- performs the actual HTTP POST to registered endpoints.
hooks.on("after_create", "content_data", function(data)
local endpoints = db.query("endpoints", { where = { active = 1 } })
for _, ep in ipairs(endpoints) do
if ep.events:find("create") then
db.insert("deliveries", {
endpoint_id = ep.id,
event = "content.created",
payload = '{"id":"' .. (data.id or "") .. '","title":"' .. (data.title or "") .. '"}',
status = "pending",
})
end
end
log.info("Queued webhook deliveries for content.created", { content_id = data.id })
end)
hooks.on("after_update", "content_data", function(data)
local endpoints = db.query("endpoints", { where = { active = 1 } })
for _, ep in ipairs(endpoints) do
if ep.events:find("update") then
db.insert("deliveries", {
endpoint_id = ep.id,
event = "content.updated",
payload = '{"id":"' .. (data.id or "") .. '","title":"' .. (data.title or "") .. '"}',
status = "pending",
})
end
end
end)
hooks.on("after_delete", "content_data", function(data)
local endpoints = db.query("endpoints", { where = { active = 1 } })
for _, ep in ipairs(endpoints) do
if ep.events:find("delete") then
db.insert("deliveries", {
endpoint_id = ep.id,
event = "content.deleted",
payload = '{"id":"' .. (data.id or "") .. '"}',
status = "pending",
})
end
end
end)
function on_init()
db.define_table("endpoints", {
columns = {
{ name = "url", type = "text", not_null = true },
{ name = "events", type = "text", not_null = true },
{ name = "secret", type = "text", default = "" },
{ name = "active", type = "boolean", not_null = true, default = 1 },
},
indexes = {
{ columns = {"active"} },
},
})
db.define_table("deliveries", {
columns = {
{ name = "endpoint_id", type = "text", not_null = true },
{ name = "event", type = "text", not_null = true },
{ name = "payload", type = "text", not_null = true },
{ name = "status", type = "text", not_null = true, default = "pending" },
{ name = "attempts", type = "integer", default = 0 },
{ name = "last_error", type = "text" },
},
indexes = {
{ columns = {"status"} },
{ columns = {"endpoint_id"} },
{ columns = {"event"} },
},
foreign_keys = {
{
column = "endpoint_id",
ref_table = "plugin_webhook_relay_endpoints",
ref_column = "id",
on_delete = "CASCADE",
},
},
})
log.info("webhook_relay initialized")
end
Note that plugins cannot make outbound HTTP requests. This plugin logs delivery records to a database table. An external worker (cron job, queue consumer, or separate service) polls the deliveries table and performs the actual HTTP POST to registered endpoints.
Analytics Logger
Wildcard after-hooks that log all content mutations across all tables. Demonstrates wildcard hooks, structured logging, and high-volume event tracking.
Key patterns: Wildcard hooks ("*" table), structured log context, activity feed with time-based queries.
plugin_info = {
name = "analytics_logger",
version = "1.0.0",
description = "Log all content mutations for analytics",
author = "Example",
}
-- API: query activity feed
http.handle("GET", "/activity", function(req)
local opts = {
order_by = "created_at DESC",
limit = tonumber(req.query.limit) or 50,
offset = tonumber(req.query.offset) or 0,
}
if req.query.action then
opts.where = { action = req.query.action }
end
if req.query.table_name then
opts.where = opts.where or {}
opts.where.table_name = req.query.table_name
end
local events = db.query("events", opts)
local total = db.count("events", opts.where and { where = opts.where } or {})
return {
status = 200,
json = { events = events, total = total },
}
end)
-- API: get summary counts
http.handle("GET", "/summary", function(req)
local creates = db.count("events", { where = { action = "created" } })
local updates = db.count("events", { where = { action = "updated" } })
local deletes = db.count("events", { where = { action = "deleted" } })
local publishes = db.count("events", { where = { action = "published" } })
local archives = db.count("events", { where = { action = "archived" } })
return {
status = 200,
json = {
created = creates,
updated = updates,
deleted = deletes,
published = publishes,
archived = archives,
total = creates + updates + deletes + publishes + archives,
},
}
end)
-- Wildcard after-hooks: log every mutation on every table
hooks.on("after_create", "*", function(data)
db.insert("events", {
action = "created",
table_name = data._table,
entity_id = data.id or "",
})
log.debug("Logged create event", { table_name = data._table, id = data.id })
end)
hooks.on("after_update", "*", function(data)
db.insert("events", {
action = "updated",
table_name = data._table,
entity_id = data.id or "",
})
end)
hooks.on("after_delete", "*", function(data)
db.insert("events", {
action = "deleted",
table_name = data._table,
entity_id = data.id or "",
})
log.info("Entity deleted", { table_name = data._table, id = data.id })
end)
hooks.on("after_publish", "*", function(data)
db.insert("events", {
action = "published",
table_name = data._table,
entity_id = data.id or "",
})
end)
hooks.on("after_archive", "*", function(data)
db.insert("events", {
action = "archived",
table_name = data._table,
entity_id = data.id or "",
})
end)
function on_init()
db.define_table("events", {
columns = {
{ name = "action", type = "text", not_null = true },
{ name = "table_name", type = "text", not_null = true },
{ name = "entity_id", type = "text", not_null = true },
},
indexes = {
{ columns = {"action"} },
{ columns = {"table_name"} },
{ columns = {"action", "table_name"} },
},
})
log.info("analytics_logger initialized")
end
Wildcard hooks (table "*") fire for all CMS tables. At equal priority, table-specific hooks from other plugins run before wildcard hooks. This plugin uses only after-hooks, so it has full db.* access and runs asynchronously without blocking CMS operations.
API Gateway
Middleware-based authentication with API key validation on public routes. Demonstrates http.use middleware, public routes, and key management.
Key patterns: http.use middleware for cross-cutting concerns, public routes with custom auth, API key CRUD.
local validators = require("validators")
plugin_info = {
name = "api_gateway",
version = "1.0.0",
description = "API key management and validation",
author = "Example",
}
-- Middleware: validate API key on requests that include one
http.use(function(req)
local key = req.headers["x-api-key"]
if not key then
return nil -- no key provided, fall through to default auth
end
-- Check if key exists and is active
local api_key = db.query_one("api_keys", {
where = { key_value = key, active = 1 },
})
if not api_key then
return { status = 401, json = { error = "invalid or inactive api key" } }
end
-- Update last-used timestamp
db.update("api_keys", {
set = { last_used_at = db.timestamp(), use_count = api_key.use_count + 1 },
where = { id = api_key.id },
})
return nil -- key valid, continue to handler
end)
-- Public: validate a key without needing a CMS session
http.handle("POST", "/validate", function(req)
if not req.json or not req.json.key then
return { status = 400, json = { error = "key is required" } }
end
local api_key = db.query_one("api_keys", {
where = { key_value = req.json.key, active = 1 },
})
if not api_key then
return { status = 401, json = { error = "invalid" } }
end
return {
status = 200,
json = {
valid = true,
name = api_key.name,
created_at = api_key.created_at,
},
}
end, { public = true })
-- Authenticated: list all API keys
http.handle("GET", "/keys", function(req)
local keys = db.query("api_keys", {
order_by = "created_at DESC",
limit = 100,
})
-- Strip the actual key values from the response
for _, k in ipairs(keys) do
k.key_value = k.key_value:sub(1, 8) .. "..."
end
return { status = 200, json = keys }
end)
-- Authenticated: create a new API key
http.handle("POST", "/keys", function(req)
if not req.json or not req.json.name then
return { status = 400, json = { error = "name is required" } }
end
-- Generate a random-looking key using ULIDs
local key_value = "mck_" .. db.ulid() .. db.ulid()
local id = db.ulid()
db.insert("api_keys", {
id = id,
name = req.json.name,
key_value = key_value,
active = 1,
use_count = 0,
last_used_at = "",
})
-- Return the full key value only on creation
local created = db.query_one("api_keys", { where = { id = id } })
return { status = 201, json = created }
end)
-- Authenticated: revoke a key
http.handle("DELETE", "/keys/{id}", function(req)
if not db.exists("api_keys", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
db.update("api_keys", {
set = { active = 0 },
where = { id = req.params.id },
})
return { status = 200, json = { revoked = true } }
end)
function on_init()
db.define_table("api_keys", {
columns = {
{ name = "name", type = "text", not_null = true },
{ name = "key_value", type = "text", not_null = true },
{ name = "active", type = "boolean", not_null = true, default = 1 },
{ name = "use_count", type = "integer", default = 0 },
{ name = "last_used_at", type = "text", default = "" },
},
indexes = {
{ columns = {"key_value"}, unique = true },
{ columns = {"active"} },
},
})
log.info("api_gateway initialized")
end
The lib/validators.lua module for this plugin:
-- lib/validators.lua
local M = {}
function M.not_empty(s)
return type(s) == "string" and #s > 0
end
return M
The middleware runs before every route handler. It checks for an x-api-key header and validates it against the api_keys table. Routes without the header fall through to the CMS session authentication (for authenticated routes) or proceed directly (for public routes).
The /validate endpoint is public, allowing external services to verify API keys without a CMS session. The key management endpoints (/keys) require CMS authentication. The key value is only returned in full on creation -- list endpoints mask it.