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

The stages a request passes through inside an OpsAPI nginx workerClientAuthorization: Bearer <JWT or opsk_ key>ONE NGINX WORKER (OPENRESTY + LUAJIT + LAPIS)CORS + global rate limitCorsMiddleware.enable, GlobalRateLimitbefore_filter (app.lua)public allow-list or authenticateRouteronly routes whose feature is onrequireAuthsets self.current_userrequireNamespaceself.namespace + permissionsrequirePermission(module, action)optional; 403 if missingHandler → Queries → Modelsscoped by namespace_idreturns { status, json }PostgreSQLpgmoon over cosocketPublic paths skip auth/, /health, /ready, /live, /swagger, /openapi.json,/metrics, /auth/login, /auth/signin, …, /api/v2/public/*Feature off: no route registeredload_if(feature, "routes.x") skipped the require,so the router answers 404 for everyoneEarly exits401 no or bad token · 400 no workspace header404 workspace not found · 403 not a member,inactive member or suspended workspace
Each stage can end the request early. The coloured stages are the ones you touch when writing a module: the middleware wrappers and the handler.

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/info and the public auth routes (/auth/login, /auth/2fa/verify, /auth/google, /auth/signin and 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" } })
end

Two 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:

WrapperSetsEnds the request with
AuthMiddleware.requireAuthself.current_user, self.api_key_auth401 when there is no valid principal
NamespaceMiddleware.requireNamespaceself.namespace, namespace_permissions, is_namespace_owner, is_platform_admin400 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

The four layers a module is built from, left to rightroutes/x.luaparse body (cjson)wrap in middlewarecheck ownershipshape the responsequeries/XQueries.luabusiness rulesvalidationpaginationraw SQL via lapis.dbmodels/XModel.luaModel:extend(table)timestampsthin, no logicautoloadedPostgreSQLnamespace_id onevery business tableindexes start with it
Every module follows the same split. The route file never holds SQL; the model never holds rules.

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 CrmAccounts

Routes 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.

How an error becomes a catalogue-backed JSON envelopeErrors.raise(code)or Errors.responseUncaught Lua errormaps to SYSTEM_500install_handler (app.lua)locale + catalogue lookuperror_occurrences rowadmin audit, sanitised{ error: { … } } + X-Request-IDcode, message, correlation_id
Any uncaught Lua error is mapped to SYSTEM_500 and logged in full on the server, so internals never reach the client.
{
  "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

  1. Put the route file behind load_if with its feature, and gate its migration on the same feature.
  2. Wrap the handler in requireAuth and requirePermission(module, action).
  3. Pass self.namespace.id into every query, and check row ownership before update or delete.
  4. Return { success, data, meta }, and raise catalogue codes for errors.
  5. If the route must be public, add it to the before_filter allow-list on purpose, never by accident.