zynagent-first application framework

Routing#

Route modules declare a prefix, defaults, and a table:

const zyn = @import("zyn");

pub const prefix = "/articles";
pub const defaults: zyn.RouteDefaults = &.{
    .csrf(.protected),
    .layout("layouts/application.html"),
};
pub const table: zyn.RouteTable = &.{
    .get("/:id", show, &.{}),
    .post("/", create, &.{.guard(zyn.auth.signed_in)}),
};

fn show(action: *zyn.Action) !zyn.Response {
    return action.json(.{ .id = action.param("id") }, .{});
}

get, post, put, patch, and delete declare common methods. match accepts a standard or custom method string, and mount nests another route table. Paths are normalized and duplicate effective method/path pairs fail at startup. Routes with the same method and path shape also conflict; renaming a path parameter does not resolve the conflict.

For example, mount a route table beneath /admin:

const admin_routes: zyn.RouteTable = &.{
    .get("/users", list_users, &.{}),
};

pub const table: zyn.RouteTable = &.{
    .mount("/admin", admin_routes, &.{.guard(zyn.auth.signed_in)}),
};

Actions have exactly this shape:

fn action(action: *zyn.Action) !zyn.Response

The framework/actions guide defines the action's request data, services, and response operations.

URL generation#

zyn.url(allocator, comptime pattern, params) returns an allocator-owned URL for a declared application route:

const location = try zyn.url(
    action.arena,
    "/articles/:id?page=:page#comments",
    .{
        .id = article.id,
        .page = 2,
    },
);

The path portion before the first query or fragment delimiter must exactly match a flattened route pattern after the route-module prefix and every mount prefix are applied. Matching ignores the route method, so one path shared by GET, PATCH, and DELETE is valid. Static suffix parameters such as /:event_id.ics are supported. Wildcard routes cannot be generated.

The params value must be a struct containing exactly the named placeholders across the path, query, and fragment. Each value must be a non-empty string or an integer. Path values are encoded as individual path segments, query values as query components, and fragment values as fragment components. Static URL text remains unchanged. Action.param, Hook.param, and Guard.param expose the decoded path parameter value; queries and fragments do not participate in route matching.

Query and fragment placeholder names begin with a letter, _, or @, followed by letters, digits, or _. Percent-encode a literal colon followed by such an identifier as %3A.

A dot separates a final path parameter from a static suffix only when the parameter name before it and suffix after it are both non-empty. Otherwise the dot remains part of the parameter name. For example, /files/:id. requires the Zig field .@"id.", generates /files/value, and exposes the decoded value through action.param("id.").

Formats and parameters#

Route formats follow these rules:

  • A final extension on a bare parameter becomes the sole member of action.formats without changing the parameter value.
  • A static suffix constrains the route. /:event_id.ics matches /42.ics and exposes event_id as "42"; it does not match /42 or /42.json.
  • Matching prefers static segments, constrained parameters, bare parameters, then glob segments.
  • Known formats are html, json, markdown, xml, and ics. Both .md and .markdown extensions select the markdown format. Unknown extensions retain their exact name.
  • Without an extension, the Accept field supplies ordered candidates and zyn adds Vary: Accept.

Defaults#

Defaults attach CSRF handling, layouts, guards, and hooks to a route scope:

pub const defaults: zyn.RouteDefaults = &.{
    .csrf(.protected),
    .layout("layouts/application.html"),
    .hook(private_cache),
    .guard(zyn.auth.signed_in),
};

Guards and hooks accumulate in declaration order across module, outer mount, inner mount, and route defaults. CSRF settings replace the inherited setting.

Route guards#

A route guard decides whether its route is allowed to continue. A route guard receives only *const zyn.Guard and returns a decision:

fn custom_guard(guard: *const zyn.Guard) !zyn.GuardDecision {
    return if (guard.auth.user == null) .unauthenticated else .allow;
}

Use zyn.auth.signed_in for managed authentication; custom guards can inspect guard.auth alongside application-owned authorization state.

zyn.Guard provides logging and immutable request data: the request ID, headers, cookies, locals, session, method, URL, formats, body, authorization, route parameters, and query parameters. It does not provide a database, general I/O, allocator, typed payload decoder, response writer, or continuation method.

Decisions are .allow, .unauthenticated, .forbidden, .not_found, .redirect(status, location), and .challenge(scheme, options). Every decision except .allow stops later guards, hooks, and the action.

Rate limits#

Add .rate_limit to route, mount, or module defaults:

.post("/login", login, &.{
    .rate_limit(.{ .name = "login", .requests = 5, .per = .minute }),
}),

Limits are optional. name consists of 1–128 ASCII letters, digits, _, or -. requests and burst must be positive and at most 1,000,000; per is .minute, .hour, .day, or .{ .seconds = n } with n from 1 through 2,592,000. The interval implied by requests and per is rounded up to a microsecond. burst is the maximum immediate allowance and defaults to requests. For example, 120 requests per minute with a burst of 20 accepts up to 20 immediate requests and replenishes at approximately two per second. This is not an exact rolling-window maximum.

The default key is the trusted client IP address, excluding the port. HTTP forwarding headers do not select the key. For a key derived from validated request facts, supply a callback that takes *const zyn.Guard:

.get("/account", account, &.{
    .rate_limit(.{
        .name = "account",
        .requests = 120,
        .per = .minute,
        .burst = 20,
        .key = account_key,
    }),
}),

fn account_key(guard: *const zyn.Guard) ?[]const u8 {
    return if (guard.auth.user) |user| user.id else null;
}

The callback runs after preceding hooks and guards. null returns 401 without falling back to IP. A nearer rate-limit declaration replaces an inherited one; the last declaration wins within one scope. Ordinary hooks and guards continue to accumulate. The same name and settings on multiple routes share a counter for each key. Changing settings starts a separate counter. CSRF protection runs before route limits. Unmatched routes and automatic 405 responses do not consume an allowance.

The limit uses gradual replenishment with a burst allowance, backed by an atomic PostgreSQL update in the separate cache database. Counter rows are stored in zyn.rate_limits, not zyn.cache_entries; zyn cache clear does not delete them. The stored SHA-256 key digest bounds index size but does not anonymize low-entropy keys such as IP addresses. Dropping or restoring the cache database can reset allowances. Exceeded limits return 429 Too Many Requests, an integer-seconds Retry-After, and Cache-Control: no-store. An unavailable rate-limit store returns 503 Service Unavailable without proceeding to the action. The framework performs bounded indexed cleanup of fully replenished rows after successful checks. With no traffic, expired rows remain until another check.

Session values can describe anonymous route-admission state such as a cart or onboarding flow. Treat current permissions and resource ownership as authoritative only after middleware or a hook validates them into request locals. Guard constness is shallow for application-defined pointers, so session and local slice fields intended for guard use should use immutable child types such as []const u8.

.not_found conceals a route during direct dispatch. Automatic missing-route handling does not run guards and therefore does not use guard decisions to filter Allow.

Hooks#

A hook receives only *zyn.Hook and returns !void:

fn private_cache(hook: *zyn.Hook) !void {
    try hook.headers.set("Cache-Control", "private");
    try hook.next();
}

hook.next() continues through later hooks to the action and refreshes request and response state when it returns. The continuation is single-use, including when downstream code returns an error; another call returns error.ContinuationAlreadyCalled without running downstream code again. Returning without next short-circuits the pipeline. Use hook.respond(response) for an explicit terminal response:

fn pause_writes(hook: *zyn.Hook) !void {
    if (hook.method == .POST) {
        return hook.respond(try .text("Service unavailable", .{
            .status = .service_unavailable,
        }));
    }
    try hook.next();
}

Hooks can perform response work both before and after next. Guards own authorization decisions; hooks establish request facts and perform wrapping work. Hooks share the action's headers and cookies capabilities and its request and response methods. Passing an outcome to hook.respond transfers its ownership to the framework.

CSRF protection#

.csrf(.protected) applies Fetch Metadata checks to unsafe methods. A protected request must contain exactly one Sec-Fetch-Site field whose value is same-origin or same-site; a missing, duplicate, cross-site, or unrecognized value returns 403 Forbidden before guards, hooks, or the action run. Safe GET, HEAD, and OPTIONS requests continue without that requirement. Responses vary on Sec-Fetch-Site.

.csrf(.exempt) explicitly disables these checks for a scope such as a third-party webhook. CSRF settings inherit through mounts and route defaults override them. CORS permission does not bypass CSRF: configure cross-origin unsafe endpoints explicitly and exempt only routes which enforce an appropriate independent authenticity policy.

Wrong methods and HEAD#

When no exact method-and-path route matches, zyn inspects only the registered route table. It runs no route hooks, guards, actions, layouts, or CSRF checks. If no path template matches, zyn returns 404 Not Found. If a template does match, zyn returns 405 Method Not Allowed with Allow derived from every registered method for that path. GET adds HEAD unless an explicit HEAD route already handles it.

Automatic Allow describes route-level capability only. It does not assert that a particular resource exists or that the requester is authorized to use the listed methods. Requests which match a method and path continue through their normal CSRF, guard, hook, layout, and action pipeline. GET fallback actions for matched HEAD requests observe HEAD; zyn suppresses response bytes while retaining the GET representation length.