Declarative Routing & Authentication (ComptimeRouter)

This reference defines zmodu’s compile-time HTTP routing (Router(State) + RouteSpec), Path A catalog JWT authentication, RBAC permission gates, WebSocket/SSE route tables, and production server profiles.

FeatureComptimeRouter + Path A (Recommended)Legacy Auth Middleware
Route declarationpub const routes = [_]http.RouteSpec(State){ ... }Runtime router.scope(p).get(...)
JWT verificationhttp.jwtAuthFromCatalogWithPermissionssecurity.auth.jwtAuth* / rbacJwtMiddleware*
Authorization gatehttp.permissionGateWith(&slot, .{ .mode = .rbac })ctx.auth_info + manual requirePermission
Handler statectx.user_data holds *State (read via ctx.state(T))ctx.user_data untouched (AuthInfo in ctx.auth_info)
Identity attributesctx.userId(), ctx.tenantId(), ctx.permissionMatches(...)ctx.authInfo()

Use the left column (ComptimeRouter + Path A catalog JWT/RBAC) for all new applications and zmodu scaffold --with-auth projects.

2. Core Routing Types

2.1 RouteMeta and Auth

pub const Auth = enum { inherit, public, optional, jwt };

pub const RouteMeta = struct {
    auth: Auth = .inherit,
    /// Permission code (`|` = OR); checked when `permissionGateWith` runs in `.mode = .rbac`
    permission: ?[]const u8 = null,
    /// Coarse portal/role name (`|` = OR); checked before `permission`
    roles: ?[]const u8 = null,
    /// Defaults to the mounted module's `module_name`
    module: ?[]const u8 = null,
    /// OpenAPI annotations (do not affect runtime authentication)
    summary: ?[]const u8 = null,
    description: ?[]const u8 = null,
    request_body: ?[]const u8 = null,
    sse: bool = false,
    openapi_params: []const http.ApiParam = &.{},
};

2.2 RouteSpec(State)

pub fn RouteSpec(comptime State: type) type {
    return struct {
        method: http.Method,
        /// Relative path without leading slash (e.g. "{id}" or "login")
        path: []const u8,
        /// Typed handler receiving `*http.Context` and `*State`
        handler: *const fn (*http.Context, *State) anyerror!void,
        meta: RouteMeta = .{},
    };
}

Path parameter limit: each route supports up to 8 {...} segments (RouteParams.MAX). Exceeding 8 parameters returns error.TooManyRouteParams at route registration time.

2.3 Module Route Table Contract

const http = @import("zmodu").http;

pub fn OrderApi(comptime Service: type) type {
    return struct {
        const Self = @This();
        service: *Service,

        pub const module_name = "order";
        pub const nest = .{"orders"};
        pub const State = Self;

        pub const routes = [_]http.RouteSpec(State){
            .{ .method = .GET, .path = "{id}", .handler = getOrder, .meta = .{ .auth = .jwt } },
            .{ .method = .DELETE, .path = "{id}", .handler = cancelOrder, .meta = .{ .permission = "order:cancel" } },
            .{ .method = .POST, .path = "login", .handler = login, .meta = .{ .auth = .public } },
        };

        // WebSocket routes MUST explicitly set `.meta.auth = .public` (enforced at compile time)
        pub const ws_routes = [_]http.WsSpec(State){
            .{
                .path = "ws",
                .on_connect = onConnect,
                .on_message = onMessage,
                .on_close = onClose,
                .meta = .{ .auth = .public, .module = "order" },
            },
        };

        fn getOrder(ctx: *http.Context, self: *State) !void {
            const id = try ctx.paramInt(i64, "id");
            _ = self;
            try ctx.jsonStruct(200, .{ .id = id, .tenant = ctx.tenantId() });
        }

        fn cancelOrder(ctx: *http.Context, _: *State) !void {
            try ctx.jsonStruct(200, .{ .cancelled = true });
        }

        fn login(ctx: *http.Context, _: *State) !void {
            try ctx.jsonStruct(200, .{ .token = "..." });
        }
    };
}

2.4 Mounting Modules & Compile-Time Duplicate Detection

comptime http.assertNoDupes(.{ OrderApi, UserApi });

var router = http.Router(AppState).init(init.io, allocator, &server, &app_state);
defer router.deinit();

var api_v1 = router.scope("/api/v1");
try api_v1.mountAll(.{
    .{ .Mod = OrderApi, .state = &order_api },
    .{ .Mod = UserApi, .state = &user_api },
});

catalog_slot.set(try router.finish());

For dynamic runtime routes (plugins, ad-hoc admin endpoints), use router.scope("/api").get("ping", handler, null). Runtime routes do not enter RouteCatalog and are not inspected by permissionGate, OpenAPI export, or Testkit.auditAuthCoverage.

3. Path A Authentication & Multi-Portal RBAC

3.1 Wiring Catalog JWT + RBAC Gate

var catalog_slot = http.CatalogSlot.init();
defer catalog_slot.deinit(allocator);

const table = security.Rbac.RolePermissionTable{
    .rows = &.{
        .{ .role = "admin", .permissions = &.{ "tenant:suspend", "tenant:write" } },
        .{ .role = "user", .permissions = &.{"tenant:read"} },
    },
};

try server.addMiddleware(try http.jwtAuthFromCatalogWithPermissions(
    &sec,
    &catalog_slot,
    try http.catalogLoaderFromTable(&table),
    .{},
));
try server.addMiddleware(try http.moduleGate(&catalog_slot, .{ .unknown = .allow }));
try server.addMiddleware(try http.permissionGateWith(&catalog_slot, .{ .mode = .rbac }));

// Mount all modules, then populate catalog_slot:
try api_v1.mountAll(.{ ... });
catalog_slot.set(try router.finish());

For database-backed role permissions, pass try zmodu.security.CatalogPermDb.loaderFromClient(&db_client).

Seed the catalog with CatalogPermDb.ensureSchema(&db_client) + CatalogPermDb.grant(&db_client, role, permission) once at startup; both are dialect-aware, so the same call works on SQLite, PostgreSQL and MySQL (grant is idempotent — re-running it is a no-op).

3.2 Multi-Portal Identity & Custom Permission Loaders

zmodu does not add a custom type enum to JwtPayload. Instead, map multi-portal identities (e.g., storefront customer, merchant staff, supplier, platform admin) onto standard JWT claims:

ConceptJWT / Framework SlotRule
User IDJWT sub → attr "user_id"Read via ctx.userId() / ctx.requireUserId()
Tenant / Store IDJWT aud → attr "tenant_id"Mint with generateTokenWithTenant(sub, roles, aud); read via ctx.tenantId()
Portal / Coarse RoleJWT roles → attr "roles"Gate with RouteMeta.roles = "shop|admin"
Fine-Grained RBACCatalogPermissionLoader → attr "permissions" (CSV)Gate with RouteMeta.permission = "order:cancel"
Route Handler Statectx.user_data (*State)Read via ctx.state(T); never store AuthInfo in user_data

Custom CatalogPermissionLoader signature:

// CatalogPermLoadInput = struct { sub: []const u8, aud: []const u8, roles: []const []const u8 }
pub const CatalogPermissionLoader = *const fn (
    allocator: std.mem.Allocator,
    input: http.CatalogPermLoadInput,
) anyerror![]u8;

3.3 Pluggable AuthBackend, Envelopes & CI Coverage Audit

  • Custom auth backends: Use http.authFromCatalog(&slot, backend, .{}) to wrap non-JWT backends (session stores, Redis tokens) while keeping RouteMeta.auth == .public as the single bypass source of truth.
  • Response envelope dialects: Call ctx.setEnvelope(.thinkphp | .ruoyi | .default) and use ctx.okValue(v), ctx.fail(msg), ctx.failCode(code, msg), ctx.unauth(msg), and ctx.paginated(items, total).
  • CI auth coverage audit: Call http.Testkit.auditAuthCoverage(allocator, &server, &slot) in tests to dispatch unauthenticated requests to every catalog route and verify that all non-public routes return 401 and all .public routes do not return 401.

4. Extractors, resultHandler, and SSE

4.1 Typed Request Extractors

const id = (try http.extractPath(ctx, .{ .id = u64 })).id;
const query = try http.extractQuery(ctx, .{ .page = u32, .size = u32 });
const body = try http.extractJsonValidated(ctx, CreateOrderDto, order_rules);

4.2 Thin Handlers with http.resultHandler

Wrap handlers that return a value directly so the framework serializes the 200 OK envelope automatically:

fn getUser(ctx: *http.Context, self: *State) !UserDto {
    const id = try ctx.paramInt(i64, "id");
    return self.svc.findUser(id);
}

pub const routes = [_]http.RouteSpec(State){
    .{ .method = .GET, .path = "{id}", .handler = http.resultHandler(State, UserDto, getUser) },
};

4.3 Server-Sent Events (SSE)

Declare SSE routes in pub const sse_routes = [_]http.SseSpec(State){ ... } (or set .meta = .{ .sse = true }). Inside the handler:

var sse = try http.sse(ctx);
if (http.lastEventId(ctx)) |last_id| {
    _ = last_id;
}
try sse.sendEvent("update", "{\"progress\":50}");
try sse.done();

5. Production Profile & Backpressure Ordering

Mount http.productionProfile before registering any routes:

LayerConfiguration FieldProtection
Connection limitmax_connections / over_limit_responsePrevents file descriptor and memory exhaustion under connection floods
Header deadlineheader_timeout_msGuards against Slowloris attacks; cleared once headers are parsed
Body deadlinebody_timeout_ms (default 30s)Guards against Slow-POST attacks; returns HTTP 408 on timeout
HTTP/2 limitsInherited via Server.http2ServeOptionsApplies max_body_size, 16 KiB / 100 header limits (SETTINGS_MAX_HEADER_LIST_SIZE), and idle read timeout to h2c/HTTP/2
Handler timeoutrequest_timeout_msBounds handler execution duration
WebSocket outboundws_write_timeout_ms + WsFramer.isWritable()Prevents slow WebSocket readers from blocking writer threads