OpsAPI has no separate application server. Lua runs inside the nginx workers of OpenResty, Lapis routes the request, and pgmoon talks to PostgreSQL over nginx's non-blocking sockets. This note follows one authenticated request from the moment it reaches a worker to the JSON that goes back, and names the file that owns each step so you can read along in the repository.
The whole path
1. Process-wide setup
When a worker loads app.lua it installs three things that run for every request: CORS handling, the global rate limiter (OPSAPI_RATE_LIMIT_DEFAULT, which also logs the edge's PoP code) and the catalogue-backed error handler. Route files are required once, at load time, so the set of routes a worker serves is fixed for its lifetime.
2. The before_filter
app:before_filter in app.lua runs before any route. It works on the raw request path (ngx.var.request_uri), not nginx's decoded ngx.var.uri. The comment there explains why: an encoded path like ..%2Fpublic%2Fx could otherwise look public while the router ran a protected route.
It then decides whether the request needs a login at all:
- An exact allow-list:
/,/health,/ready,/live,/swagger,/api-docs,/openapi.json,/metrics,/api/v2/system/infoand the public auth routes (/auth/login,/auth/2fa/verify,/auth/google,/auth/signinand friends). - Patterns: anything under
/api/v2/public/, the active theme's CSS, plugin UI files under/plugin-ui/, and/api/v2/<code>/public/only when<code>is a registered plugin whose prefix that is.
Everything else goes to helper/auth.lua's authenticate(). It reads the Authorization: Bearer header. A token starting opsk_ is an API key and is checked against the api_keys table; anything else is verified as a JWT with JWT_SECRET_KEY. A missing or bad token ends the request with 401. On success the user info lands in ngx.ctx.user and self.current_user.
-- app.lua: fail closed if the auth helper can't load
local ok, auth = pcall(require, "helper.auth")
if not ok then
ngx.log(ngx.ERR, "[auth] helper.auth failed to load: ", tostring(auth))
return self:write({ status = 500, json = { error = "Authentication unavailable" } })
endTwo more checks run here for signed-in users. A user with no workspace yet is given their default one lazily (helper/namespace-resolver.lua, skipped for API keys), and a request made in the context of a suspended or archived workspace is refused on every route by helper/namespace-status.lua.
3. Only enabled modules have routes
Core routes (auth, users, roles, namespaces, API keys, webhooks, plugins and more) are always loaded. Every business module is loaded through load_if, which only requires the route file when the deployment's PROJECT_CODE enables that feature:
local function load_if(feature, route_name)
local enabled
if type(feature) == "table" then
enabled = ProjectConfig.isAnyFeatureEnabled(feature)
else
enabled = ProjectConfig.isFeatureEnabled(feature)
end
if enabled then
return safe_load_routes(route_name)
...
load_if("kanban", "routes.kanban-tasks")
load_if({ "ecommerce", "billing" }, "routes.customers")A module that isn't enabled has no route registered, so the router answers 404 to everyone, platform admins included, and nothing ever queries its tables (which don't exist in that database). The feature gating technote covers this in depth.
4. The middleware chain
Routes compose small wrappers from middleware/. Each returns a function that either answers early or calls the next one:
| Wrapper | Sets | Ends the request with |
|---|---|---|
AuthMiddleware.requireAuth | self.current_user, self.api_key_auth | 401 when there is no valid principal |
NamespaceMiddleware.requireNamespace | self.namespace, namespace_permissions, is_namespace_owner, is_platform_admin | 400 no workspace given, 404 workspace not found, 403 not a member / inactive / suspended |
NamespaceMiddleware.requirePermission(m, a) | nothing new (wraps requireNamespace) | 403 "Permission denied" with the required module and action |
NamespaceMiddleware.requireOwner | — | 403 unless owner or platform admin |
A typical protected route reads, outside in:
app:get("/api/v2/academy/creator/account", AuthMiddleware.requireAuth(
NamespaceMiddleware.requirePermission("courses", "read", function(self)
local ns = self.namespace
...requirePermission calls requireNamespace itself, so you don't nest both. The workspace comes from the X-Namespace-Id or X-Namespace-Slug header, then the JWT, then the subdomain. The multi-tenancy technote walks through resolution and permissions.
5. Routes, queries, models
routes/crm-accounts.lua is the reference CRUD module. The list handler passes the workspace id down and returns the house shape:
app:get("/api/v2/crm/accounts", AuthMiddleware.requireAuth(
NamespaceMiddleware.requireNamespace(function(self)
local result = CrmQueries.getAccounts(self.namespace.id, {
page = self.params.page,
per_page = self.params.per_page,
status = self.params.status,
search = self.params.search
})
return {
status = 200,
json = { success = true, data = result.items, meta = result.meta }
}
end)
))The query builds its WHERE clause starting from namespace_id = ? AND deleted_at IS NULL. The middleware sets the workspace but does not filter your SQL for you, so every query takes the namespace id as its first argument. Read, update and delete handlers also re-check that the loaded row's namespace_id matches before acting. The model is one line:
local Model = require("lapis.db.model").Model
local CrmAccounts = Model:extend("crm_accounts", { timestamp = true })
return CrmAccountsRoutes parse JSON bodies themselves (ngx.req.read_body() then cjson.decode); there is no automatic binding. JSON columns such as metadata are encoded with cjson.encode before they're stored.
6. What comes back
Successful responses use one envelope:
{ "success": true, "data": [ ... ], "meta": { "page": 1, "per_page": 20, "total": 42 } }Errors raised through lib/errors.lua use the catalogue envelope instead. The message comes from the message_catalog table in the caller's locale, a sanitised row is written to error_occurrences for the admin view, and the correlation id is returned both in the body and as X-Request-ID. If the client sent an X-Request-ID, that value is reused.
{
"error": {
"code": "AUTH_INVALID_CREDENTIALS",
"message": "The email or password you entered is incorrect.",
"category": "error",
"title": "Invalid credentials",
"occurrence_uuid": "43555f92-2f0c-45d1-a342-c5c1d8219212",
"correlation_id": "627512de-3867-4a88-9606-bc75ad743d1d"
}
}The catalogue tables must exist in every deployment. Before PR #821 they were only created with the tax module, so a minimal build turned every error, even a wrong password, into a bare 500.
Checklist for a new route
- Put the route file behind
load_ifwith its feature, and gate its migration on the same feature. - Wrap the handler in
requireAuthandrequirePermission(module, action). - Pass
self.namespace.idinto every query, and check row ownership before update or delete. - Return
{ success, data, meta }, and raise catalogue codes for errors. - If the route must be public, add it to the before_filter allow-list on purpose, never by accident.