zypher — Complete Developer Guide

Version: 0.1.0-beta · Zig 0.17.0-dev.704+ · SQLite 3.53.0 · Apache 2.0 Repo: https://github.com/zypher-org/zypher

This guide covers two completely different ways to use zypher, both with full step-by-step walkthroughs:

  • Part A — The Package Approach: add zypher as a Zig dependency to your own project, wire build.zig by hand, write every file yourself.
  • Part B — The CLI Approach: install the zypher binary, use zypher new to scaffold a project, and use CLI commands to run, migrate, and manage your app.

Each part builds the same application: a polling HTTP chat room with persistent messages in SQLite, session-based usernames, and an admin panel to moderate messages. Comparing them side-by-side reveals the real trade-offs of each approach.


Table of Contents

  1. Understanding the Two Approaches
  2. Part A — zypher as a Package (Manual Wiring)
  3. Part B — zypher via the CLI
  4. Side-by-Side Comparison
  5. Complete CLI API Reference
  6. Complete Library API Reference

1. Understanding the Two Approaches

zypher is, fundamentally, a Zig library. Like any Zig library, it can be imported as a package in your build.zig.zon. That is the package approach: you control every file, every structure, and every dependency decision from scratch.

The zypher repository also ships a standalone executable called zypher (built from src/cli/main.zig). That binary provides scaffolding, migration running, superuser creation, and a development server launcher. That is the CLI approach: you lean on the tool for the repetitive setup work and focus on your application logic.

Critical fact about the CLI approach: zypher new does not generate a project that uses build.zig.zon to fetch zypher from the internet. Instead, the generated build.zig has a zypher-root build option that defaults to "../..", meaning it expects the zypher source tree to be two directories above your project. This is intentional for the current development phase, where zypher is not yet published to a package registry.

This means the CLI workflow assumes you have the zypher source tree on disk. zypher run infers the zypher root from the binary's location and passes it to zig build.

The package approach, by contrast, uses build.zig.zon to declare zypher as a path dependency (pointing at your local clone) or will eventually use a URL hash when zypher is published.

Neither approach is strictly better — they serve different needs. The comparison is laid out in detail in Section 4.


Part A — zypher as a Package (Manual Wiring)

A-1 Prerequisites

You need:

  • Zig 0.17.0-dev (build 704+b8cb78023 or later). The framework uses std.Io, std.process.Init, and other APIs that do not exist in older versions.
  • A local clone of the zypher repository (for its bundled SQLite amalgamation). The SQLite C source at vendor/sqlite-amalgamation-3530000/sqlite3.c is compiled as a C library by your build.zig. zypher does not rely on a system-installed SQLite.
  • A text editor and a terminal.
# Clone zypher somewhere on your machine
git clone https://github.com/zypher-org/zypher.git ~/code/zypher

A-2 Project Initialization

Create your project directory next to your zypher clone. The relative path ../zypher will be used in build.zig.zon.

mkdir ~/code/chat-pkg
cd ~/code/chat-pkg
zig init         # creates src/main.zig, build.zig, build.zig.zon

Your directory tree so far:

~/code/
├── zypher/          ← the cloned framework
└── chat-pkg/        ← your new project
    ├── src/
    │   └── main.zig
    ├── build.zig
    └── build.zig.zon

A-3 Wiring build.zig.zon

Open build.zig.zon and replace the generated content with:

.{
    .name = .chat-pkg,
    .version = "0.1.0",
    .fingerprint = 0x0000000000000001,
    .minimum_zig_version = "0.17.0-dev.704+b8cb78023",
    .dependencies = .{
        .zypher = .{
            // Path dependency: points to your local clone.
            // Change this to .url and .hash once zypher is published.
            .path = "../zypher",
        },
    },
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
    },
}

The .path field resolves relative to your build.zig.zon file. When zypher eventually publishes a package hash, you will replace this with .url and .hash fields.

A-4 Wiring build.zig

Replace the generated build.zig with the following. Every step is explained in the comments.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target   = b.standardTargetOptions(.{ .default_target = .{ .abi = .musl } });
    const optimize = b.standardOptimizeOption(.{});

    // 1. Resolve the zypher dependency declared in build.zig.zon.
    const zypher_dep = b.dependency("zypher", .{
        .target   = target,
        .optimize = optimize,
    });

    // 2. Get the "zypher" module that the dependency exposes.
    //    This module already links sqlite3 and sets link_libc = true internally.
    const zypher_mod = zypher_dep.module("zypher");

    // 3. Create your application executable.
    const exe = b.addExecutable(.{
        .name        = "chat-pkg",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target           = target,
            .optimize         = optimize,
            .imports          = &.{
                .{ .name = "zypher", .module = zypher_mod },
            },
        }),
    });
    b.installArtifact(exe);

    // 4. `zig build run` starts the server.
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    run_cmd.addPassthruArgs();
    b.step("run", "Run the chat server").dependOn(&run_cmd.step);

    // 5. `zig build test` runs unit tests.
    const unit_tests = b.addTest(.{
        .root_module = exe.root_module,
    });
    b.step("test", "Run tests").dependOn(&b.addRunArtifact(unit_tests).step);

    // 6. `zig build doc` generates documentation.
    const app_docs = b.addTest(.{ .root_module = exe.root_module });
    const install_docs = b.addInstallDirectory(.{
        .source_dir    = app_docs.getEmittedDocs(),
        .install_dir   = .prefix,
        .install_subdir = "docs",
    });
    b.step("doc", "Generate documentation").dependOn(&install_docs.step);
}

Verify the dependency resolves:

zig build --dry-run 2>&1
# Should print a build graph without errors

A-5 Designing the Chat Schema

Our chat room needs three database tables:

  • users — the standard zypher user table for the admin panel (username, password_hash, role, is_active). We will use zypher createsuperuser from the CLI or hand-write the SQL.
  • chat_users — stores display names keyed by session ID, so people can pick a nickname without registering a full account.
  • messages — the chat messages with content, sender display name, and timestamp.

These tables are separate. The users table holds real accounts used only for the admin. The chat_users table holds lightweight anonymous nicknames for chatting.

A-6 The Message Model

Create src/models.zig:

const zypher = @import("zypher");
const schema = zypher.orm.schema;
const Field  = schema.Field;
const Model  = schema.Model;

// ── Chat user (nickname + session link) ──────────────────────────────────
pub const ChatUserFields = struct {
    id:         schema.FieldDef = Field("id",         .integer, .{ .primary  = true }),
    session_id: schema.FieldDef = Field("session_id", .text,    .{ .required = true, .unique = true }),
    nickname:   schema.FieldDef = Field("nickname",   .text,    .{ .required = true }),
    joined_at:  schema.FieldDef = Field("joined_at",  .integer, .{ .required = true }),
};
pub const ChatUser = Model("chat_users", ChatUserFields);

// ── Message ───────────────────────────────────────────────────────────────
pub const MessageFields = struct {
    id:         schema.FieldDef = Field("id",         .integer, .{ .primary  = true }),
    nickname:   schema.FieldDef = Field("nickname",   .text,    .{ .required = true }),
    body:       schema.FieldDef = Field("body",       .text,    .{ .required = true }),
    created_at: schema.FieldDef = Field("created_at", .integer, .{ .required = true }),
};
pub const Message = Model("messages", MessageFields);

Model generates every SQL statement at compile time. You can inspect them:

  • Message.create_table_sql — "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, nickname TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL)"
  • Message.insert_sql — "INSERT INTO messages (nickname,body,created_at) VALUES (?,?,?)"
  • Message.select_all_sql — "SELECT id, nickname, body, created_at FROM messages"

No runtime string building, no ORM magic — you can read the SQL right out of the type system.

A-7 The User Name Form

Create src/forms.zig to define form types for setting a nickname and submitting a message:

const zypher    = @import("zypher");
const form      = zypher.forms.form;
const validators = zypher.forms.validators;

// Form for choosing a chat nickname.
// The field named "nickname" uses the minLength and maxLength validators.
const NicknameFields = struct {
    nickname: form.FieldDef = form.Field("nickname", .text, .{
        .required  = true,
        .validator = validators.minLength(2),
    }),
};
pub const NicknameForm = form.Form("NicknameForm", NicknameFields);

// Form for posting a chat message.
const MessageFields = struct {
    body: form.FieldDef = form.Field("body", .text, .{
        .required  = true,
        .validator = validators.maxLength(500),
    }),
};
pub const MessageForm = form.Form("MessageForm", MessageFields);

A-8 Templates

Create a templates/ directory at the root of your project. We will embed templates using @embedFile.

templates/layout.html — base template:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="refresh" content="10">
  <title>zypher Chat</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 700px; margin: 0 auto; padding: 1rem; }
    nav  { display: flex; justify-content: space-between; border-bottom: 1px solid #eee; padding-bottom: .5rem; margin-bottom: 1rem; }
    .msg { border-left: 3px solid #ddd; padding: .4rem .8rem; margin: .4rem 0; }
    .msg strong { color: #555; }
    .msg time   { font-size: .8em; color: #aaa; margin-left: .5rem; }
    form { margin-top: 1rem; }
    input[type=text], textarea { width: 100%; padding: .4rem; border: 1px solid #ccc; border-radius: 4px; }
    button { padding: .4rem 1rem; background: #0070f3; color: white; border: none; border-radius: 4px; cursor: pointer; }
    .error { color: red; font-size: .9em; }
  </style>
</head>
<body>
  <nav>
    <strong>zypher Chat</strong>
    {% if has_nickname %}
      <span>👤 {{ nickname }} · <a href="/set-name">Change name</a></span>
    {% else %}
      <a href="/set-name">Set your nickname to chat</a>
    {% endif %}
    {% if is_admin %}
      <a href="/admin/">Admin</a>
    {% endif %}
  </nav>
  {% block content %}{% endblock %}
</body>
</html>

templates/home.html — message list and post form:

{% extends "layout.html" %}
{% block content %}
<h1>Messages</h1>

{% if has_nickname %}
<form method="post" action="/messages">
  {{ csrf_field|safe }}
  <textarea name="body" rows="3" placeholder="Say something..." required></textarea>
  <button type="submit">Send</button>
  {% if form_error %}
  <p class="error">{{ form_error }}</p>
  {% endif %}
</form>
{% else %}
<p><a href="/set-name">Set a nickname</a> to start chatting.</p>
{% endif %}

<div id="messages">
{% for msg in messages %}
  <div class="msg">
    <strong>{{ msg.nickname }}</strong>
    <time>{{ msg.created_at }}</time>
    <div>{{ msg.body }}</div>
  </div>
{% endfor %}
{% if not messages %}
  <p>No messages yet. Be the first!</p>
{% endif %}
</div>
{% endblock %}

templates/set_name.html — nickname picker:

{% extends "layout.html" %}
{% block content %}
<h1>Set Your Nickname</h1>
<form method="post" action="/set-name">
  {{ csrf_field|safe }}
  <label>
    Nickname
    <input type="text" name="nickname" value="{{ current_nickname }}" required minlength="2" maxlength="40">
  </label>
  {% if form_error %}
  <p class="error">{{ form_error }}</p>
  {% endif %}
  <button type="submit">Save</button>
</form>
{% endblock %}

A-9 Request Handlers

Create src/handlers.zig. This file contains one function per route. Each function reads from the database, uses the template engine, and writes a response. State that must persist across requests (database, template engine, session store) is kept in threadlocal variables, a pattern used throughout the zypher demo examples.

const std    = @import("std");
const zypher = @import("zypher");
const models = @import("models.zig");
const forms  = @import("forms.zig");

const Request        = zypher.core.Request;
const Response       = zypher.core.Response;
const query          = zypher.orm.query;
const sqlite         = zypher.orm.sqlite;
const TE             = zypher.template.renderer.TemplateEngine;
const Context        = zypher.template.renderer.Context;
const Value          = zypher.template.renderer.Value;
const Session        = zypher.auth.session.Session;
const SessionStore   = zypher.auth.session.SessionStore;
const csrf           = zypher.middleware.csrf;

// ── Thread-local application state ─────────────────────────────────────────
// Using threadlocal variables is the idiomatic zypher pattern for shared
// mutable state. This works because the server is single-threaded in v0.x.
pub threadlocal var db:     ?*sqlite.Db    = null;
pub threadlocal var engine: ?*TE           = null;
pub threadlocal var store:  ?*SessionStore = null;

// ── Helper: get unix timestamp ──────────────────────────────────────────────
fn now() i64 {
    var ts: std.posix.timespec = undefined;
    _ = std.posix.system.clock_gettime(std.posix.CLOCK.REALTIME, &ts);
    return ts.sec;
}

// ── Helper: get session from req.user ──────────────────────────────────────
fn getSession(req: *const Request) ?*Session {
    return if (req.user) |ptr| @ptrCast(@alignCast(ptr)) else null;
}

// ── Helper: get nickname from session ──────────────────────────────────────
fn getNickname(req: *const Request) ?[]const u8 {
    const s = getSession(req) orelse return null;
    return s.get("nickname");
}

// ── Helper: get session ID as hex for chat_users lookup ───────────────────
fn getSessionHexId(req: *const Request) ?[]const u8 {
    const s = getSession(req) orelse return null;
    // Session IDs are stored in the store keyed by hex — we need to find the
    // hex key. We use the nickname as the lookup key via the chat_users table.
    return s.get("session_hex");
}

// ── Helper: check if current user is admin ────────────────────────────────
fn isAdmin(req: *const Request) bool {
    const s = getSession(req) orelse return false;
    const role = s.get("role") orelse return false;
    return std.mem.eql(u8, role, "admin");
}

// ── Helper: render a template to a Response ───────────────────────────────
fn render(res: *Response, name: []const u8, ctx: *Context) void {
    const eng = engine orelse {
        _ = res.status(500);
        res.text("no template engine") catch {};
        return;
    };
    var aw = std.Io.Writer.Allocating.init(res.allocator);
    defer aw.deinit();
    eng.render(name, ctx, &aw.writer) catch {
        _ = res.status(500);
        res.text("template render failed") catch {};
        return;
    };
    var buf = aw.toArrayList();
    const html_content = buf.toOwnedSlice(res.allocator) catch return;
    defer res.allocator.free(html_content);
    res.html(html_content) catch {};
}

// ── Helper: build common context variables ────────────────────────────────
fn baseCtx(req: *Request, ctx: *Context) void {
    const nickname    = getNickname(req);
    const has_nickname = nickname != null;
    ctx.put("has_nickname", .{ .bool  = has_nickname })         catch {};
    ctx.put("nickname",     .{ .string = nickname orelse "" })   catch {};
    ctx.put("is_admin",     .{ .bool   = isAdmin(req) })         catch {};
    // CSRF field for forms
    const field = csrf.formFieldForRequest(req.allocator, req) catch "";
    defer if (field.len > 0) req.allocator.free(field);
    ctx.put("csrf_field", .{ .string = field }) catch {};
}

// ── Message list ───────────────────────────────────────────────────────────
// Route: GET /
pub fn home(req: *Request, res: *Response) void {
    const gpa = res.allocator;
    const d   = db orelse {
        _ = res.status(500);
        res.text("database unavailable") catch {};
        return;
    };

    // Fetch all messages, most recent first.
    var rows = query.order(
        models.Message, d, gpa,
        "created_at DESC",
    ) catch {
        _ = res.status(500);
        res.text("failed to load messages") catch {};
        return;
    };
    defer {
        for (rows.items) |*r| query.freeRow(models.Message, gpa, r);
        rows.deinit(gpa);
    }

    // Build a list of Value.map for the template.
    // Each map has: nickname, body, created_at (formatted)
    var msg_list = std.ArrayList(Value).init(gpa);
    defer msg_list.deinit();

    for (rows.items) |row| {
        var msg_ctx = Context.init(gpa);
        // row tuple: (id=0, nickname=1, body=2, created_at=3)
        msg_ctx.put("nickname",   .{ .string = row[1] })                 catch continue;
        msg_ctx.put("body",       .{ .string = row[2] })                 catch continue;
        var ts_buf: [32]u8 = undefined;
        const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{row[3]}) catch "0";
        msg_ctx.put("created_at", .{ .string = ts_str })                 catch continue;
        const ctx_ptr = gpa.create(Context) catch continue;
        ctx_ptr.* = msg_ctx;
        msg_list.append(.{ .map = ctx_ptr }) catch continue;
    }

    var ctx = Context.init(gpa);
    defer ctx.deinit();
    baseCtx(req, &ctx);
    ctx.put("messages",   .{ .list = msg_list.items }) catch {};
    ctx.put("form_error", .{ .string = "" })            catch {};

    render(res, "home.html", &ctx);

    // Free message contexts
    for (msg_list.items) |v| {
        if (v == .map) {
            v.map.deinit();
            gpa.destroy(v.map);
        }
    }
}

// ── Post a message ─────────────────────────────────────────────────────────
// Route: POST /messages
pub fn postMessage(req: *Request, res: *Response) void {
    const nickname = getNickname(req) orelse {
        res.redirect("/set-name", 302) catch {};
        return;
    };
    const d = db orelse {
        _ = res.status(500);
        return;
    };

    // Bind and validate the MessageForm.
    var bound = forms.MessageForm.bindRequest(res.allocator, req) catch {
        _ = res.status(500);
        return;
    };
    defer bound.deinit();

    if (!bound.validate()) {
        // Validation failed. Re-render home with error.
        const err_msg = bound.errors.get("body") orelse "Invalid message.";
        _ = res.status(422);
        var ctx = Context.init(res.allocator);
        defer ctx.deinit();
        baseCtx(req, &ctx);
        ctx.put("messages",   .{ .list   = &.{} }) catch {};
        ctx.put("form_error", .{ .string = err_msg }) catch {};
        render(res, "home.html", &ctx);
        return;
    }

    const data = bound.cleanedData();
    const body = data[0]; // MessageForm: first (and only) field is body

    _ = query.create(models.Message, d, &.{
        sqlite.Value{ .text = nickname },
        sqlite.Value{ .text = body },
        sqlite.Value{ .int  = now() },
    }) catch {
        _ = res.status(500);
        res.text("failed to save message") catch {};
        return;
    };

    res.redirect("/", 302) catch {};
}

// ── Set nickname (GET) ─────────────────────────────────────────────────────
// Route: GET /set-name
pub fn setNameForm(req: *Request, res: *Response) void {
    var ctx = Context.init(res.allocator);
    defer ctx.deinit();
    baseCtx(req, &ctx);
    ctx.put("current_nickname", .{ .string = getNickname(req) orelse "" }) catch {};
    ctx.put("form_error",       .{ .string = "" })                          catch {};
    render(res, "set_name.html", &ctx);
}

// ── Set nickname (POST) ────────────────────────────────────────────────────
// Route: POST /set-name
pub fn setName(req: *Request, res: *Response) void {
    var bound = forms.NicknameForm.bindRequest(res.allocator, req) catch {
        _ = res.status(500);
        return;
    };
    defer bound.deinit();

    if (!bound.validate()) {
        const err = bound.errors.get("nickname") orelse "Invalid nickname.";
        _ = res.status(422);
        var ctx = Context.init(res.allocator);
        defer ctx.deinit();
        baseCtx(req, &ctx);
        ctx.put("current_nickname", .{ .string = "" })     catch {};
        ctx.put("form_error",       .{ .string = err })    catch {};
        render(res, "set_name.html", &ctx);
        return;
    }

    const nickname = bound.cleanedData()[0];

    // Store the nickname in the session.
    if (getSession(req)) |s| {
        s.put(res.allocator, "nickname", nickname) catch {};
        if (store) |st| st.save(s) catch {};
    }

    res.redirect("/", 302) catch {};
}

// ── 404 Not Found ──────────────────────────────────────────────────────────
pub fn notFound(_: *Request, res: *Response) void {
    _ = res.status(404);
    res.text("Page not found.") catch {};
}

A-10 Middleware and Session Configuration

Create src/middleware.zig to centralize the middleware chain and session store setup:

const zypher = @import("zypher");
const handlers = @import("handlers.zig");

const Request      = zypher.core.Request;
const Response     = zypher.core.Response;
const Router       = zypher.router.Router;
const Chain        = zypher.middleware.Chain;

// Custom rate limiter: 200 requests per minute.
pub const RL = zypher.middleware.rate_limit.middlewareWith(.{
    .max_requests   = 200,
    .window_seconds = 60,
});

// The full middleware chain.
// Order matters:
//  1. logger    — logs every request
//  2. RL.handle — enforces rate limits before expensive handlers run
//  3. session   — loads/creates session, sets req.user
//  4. csrf      — reads/validates CSRF token (requires session to be loaded first)
//  5. security  — adds security headers to every response
pub const MW = Chain(.{
    zypher.middleware.logger.middleware,
    RL.handle,
    zypher.middleware.session.middleware,
    zypher.middleware.csrf.middleware,
    zypher.middleware.security_headers.middleware,
});

// Thread-local router reference (same pattern as handlers.zig).
threadlocal var router_ptr: ?*const Router = null;

pub fn setRouter(r: *const Router) void {
    router_ptr = r;
}

// The terminal handler — dispatches to the router.
pub fn routerDispatch(req: *Request, res: *Response) void {
    (router_ptr orelse return).dispatch(req, res);
}

// The middleware dispatcher — entry point for App.middlewareHandler.
pub fn dispatch(req: *Request, res: *Response) void {
    MW.run(req, res, routerDispatch);
}

A-11 The Admin Panel

Create src/admin.zig to define which models appear in the admin UI:

const zypher   = @import("zypher");
const models   = @import("models.zig");
const AdminSite   = zypher.admin.AdminSite;
const Registration = zypher.admin.Registration;

// Register both models so admins can view and delete messages
// and inspect the chat_users table.
pub const Site = AdminSite(.{
    .messages   = Registration(models.Message,  .{ .verbose_name_plural = "Messages" }),
    .chat_users = Registration(models.ChatUser, .{ .verbose_name_plural = "Chat Users" }),
});

A-12 Wiring main.zig

This is the entry point that connects everything together. Replace the generated src/main.zig:

const std      = @import("std");
const zypher   = @import("zypher");
const models   = @import("models.zig");
const handlers = @import("handlers.zig");
const mw       = @import("middleware.zig");
const admin    = @import("admin.zig");

const Router      = zypher.router.Router;
const Route       = zypher.router.Route;
const sqlite      = zypher.orm.sqlite;
const TE          = zypher.template.renderer.TemplateEngine;
const SessionStore = zypher.auth.session.SessionStore;

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;

    // ── 1. Database ──────────────────────────────────────────────────────
    // Open (or create) the SQLite database. The path is relative to where
    // the binary is executed from. In development this is your project root.
    var database = try sqlite.Db.open(gpa, "chat.db");
    defer database.close();

    // Create tables if they don't exist. The SQL is generated at compile time.
    try database.exec(models.ChatUser.create_table_sql);
    try database.exec(models.Message.create_table_sql);

    // The admin also needs the standard users table for login.
    // We create it manually because the user table is not an ORM model.
    try database.exec(
        \\CREATE TABLE IF NOT EXISTS users (
        \\  id            INTEGER PRIMARY KEY AUTOINCREMENT,
        \\  username      TEXT NOT NULL UNIQUE,
        \\  email         TEXT UNIQUE,
        \\  password_hash TEXT NOT NULL,
        \\  role          TEXT NOT NULL DEFAULT 'user',
        \\  is_active     INTEGER NOT NULL DEFAULT 1,
        \\  reset_code    TEXT,
        \\  reset_code_expires_at INTEGER
        \\)
    );

    // Wire the database reference into handlers.
    handlers.db = &database;

    // ── 2. Template Engine ───────────────────────────────────────────────
    // Load all templates from embedded strings. In a larger app you might
    // read them from the filesystem at startup using @embedFile.
    var tpl_engine = TE.init(gpa);
    defer tpl_engine.deinit();

    _ = try tpl_engine.load("layout.html", @embedFile("../templates/layout.html"));
    _ = try tpl_engine.load("home.html",   @embedFile("../templates/home.html"));
    _ = try tpl_engine.load("set_name.html", @embedFile("../templates/set_name.html"));

    // Load admin templates (the admin panel generates its own HTML).
    admin.Site.loadTemplates(&tpl_engine);

    // Wire into handlers and the zypher admin subsystem.
    handlers.engine = &tpl_engine;
    zypher.admin.setDb(&database);
    zypher.admin.setEngine(&tpl_engine);

    // ── 3. Sessions ──────────────────────────────────────────────────────
    var sessions = SessionStore.init(gpa);
    defer sessions.deinit();

    handlers.store = &sessions;
    zypher.middleware.session.setStore(&sessions);
    zypher.middleware.session.setCookieConfig(.{
        .httponly = true,
        // IMPORTANT: set .secure = true in production (HTTPS only).
        // In local development over HTTP, secure cookies will not be sent.
        .secure   = false,
        .samesite = "Strict",
        .path     = "/",
        .max_age  = 86400 * 7, // 1 week
    });

    // ── 4. Router ────────────────────────────────────────────────────────
    // Application routes come first.
    const app_routes = [_]Route{
        Router.route(.get,  "/",          handlers.home),
        Router.route(.post, "/messages",  handlers.postMessage),
        Router.route(.get,  "/set-name",  handlers.setNameForm),
        Router.route(.post, "/set-name",  handlers.setName),
    };

    // Admin routes are generated from the Site definition.
    const admin_routes = admin.Site.routes();

    // Concatenate at comptime. Note: `++` on arrays requires both to be
    // comptime-known types — using initFromSlice works for runtime slices.
    const all_routes = app_routes ++ admin_routes;
    var router = Router.initFromSlice(&all_routes, handlers.notFound);

    // Wire the router into the middleware dispatcher.
    mw.setRouter(&router);

    // ── 5. Parse port from command-line arguments ─────────────────────
    const port = parsePort(init);

    // ── 6. Start the App ─────────────────────────────────────────────────
    var app = zypher.core.App.init(gpa, .{
        .host = "127.0.0.1",
        .port = port,
    });
    defer app.deinit();
    app.database(&database);

    // middlewareHandler has the highest priority. It wraps the router.
    app.middlewareHandler(mw.dispatch);

    std.log.info("chat-pkg listening on http://127.0.0.1:{d}/", .{port});
    try app.listenAndServe(init.io);
}

fn parsePort(init: std.process.Init) u16 {
    var args = std.process.Args.Iterator.init(init.minimal.args);
    defer args.deinit();
    _ = args.skip(); // skip argv[0] (binary name)
    while (args.next()) |arg| {
        if (std.mem.eql(u8, arg, "--port")) {
            const raw = args.next() orelse return 8080;
            return std.fmt.parseInt(u16, raw, 10) catch 8080;
        }
    }
    return 8080;
}

test "models are well-formed" {
    // These are compile-time checks that the model SQL is non-empty.
    try std.testing.expect(models.Message.create_table_sql.len > 0);
    try std.testing.expect(models.ChatUser.create_table_sql.len > 0);
    try std.testing.expectEqualStrings("messages", models.Message.table_name);
}

A-13 Running the Application

Your final directory structure:

chat-pkg/
├── build.zig
├── build.zig.zon
├── src/
│   ├── main.zig
│   ├── models.zig
│   ├── forms.zig
│   ├── handlers.zig
│   ├── middleware.zig
│   └── admin.zig
└── templates/
    ├── layout.html
    ├── home.html
    └── set_name.html

Build and run:

zig build run
# Compiles zypher, sqlite3.c, and your app; then starts the server.
# Output: chat-pkg listening on http://127.0.0.1:8080/

On a different port:

zig build run -- --port 9000

To create an admin user so you can log into the admin panel, you have two options:

Option 1 — use the zypher CLI (if you have built it):

zypher createsuperuser --username admin --email admin@chat.dev --password Admin12345 --db chat.db

Option 2 — insert directly:

# You can write a small Zig script, or use sqlite3 CLI:
sqlite3 chat.db "INSERT INTO users (username, email, password_hash, role, is_active)
  VALUES ('admin', 'admin@chat.dev',
    '\$pbkdf2-sha256\$100000\$<salt_hex>\$<hash_hex>', 'admin', 1);"
# Note: you need a properly hashed password. Using the CLI tool is easier.

Visit:

  • http://localhost:8080/ — chat room
  • http://localhost:8080/set-name — pick a nickname
  • http://localhost:8080/admin/ — admin panel (after creating a superuser)

Run tests:

zig build test

Part B — zypher via the CLI

The CLI approach inverts the workflow. Instead of writing build.zig from scratch, you run zypher new to generate a ready-to-run scaffold, then modify it.

A critical design choice to understand up front: zypher new creates projects that reference zypher by a local path, not by a package registry URL. The generated build.zig looks for the zypher source tree and compiles it directly. This means the CLI is designed to be used while the zypher source is on your machine — typically as a sibling directory of your project.

B-1 Building and Installing the CLI

The CLI is built from the zypher source tree itself. You do not download a separate installer.

# Navigate to your zypher clone
cd ~/code/zypher

# Build everything, including the zypher CLI binary
zig build

# The binary is now at:
ls zig-out/bin/zypher

# Optional: add it to your PATH so you can run it from anywhere
export PATH="$HOME/code/zypher/zig-out/bin:$PATH"

# Verify
zypher --version
# Output: zypher 0.1.0-beta

When you run zypher run from a scaffolded project, the CLI uses inferZypherRoot to locate the zypher source tree. It looks (in order):

  1. The ZYPHER_ROOT environment variable.
  2. The directory two levels above the zypher binary (../../ from zig-out/bin/).
  3. A packaged install in <prefix>/share/zypher.
  4. The current working directory.

Because the binary is at zypher/zig-out/bin/zypher, and the zypher source root is zypher/, going up two directories from bin/ gives zypher/ — the source root. This is intentional.

If you install the binary somewhere else, you can set ZYPHER_ROOT explicitly:

export ZYPHER_ROOT=/home/you/code/zypher

B-2 Scaffolding the Project

Choose a scaffold template. List available templates:

zypher templates
# Available embedded templates:
#   clean-arch
#   mvc
#   mvp-api
#   single-file
#   mvc-api
#   mvp
#   single-file-api
#   clean-arch-api

For a chat room, the single-file template is the cleanest starting point — everything in one main.zig file. We will expand it.

# Create the project directory. By convention it should sit next to the
# zypher source, because the generated build.zig defaults to "../.." as
# the zypher root.
cd ~/code

zypher new chat-cli --template single-file
# Output:
# Created project 'chat-cli' from embedded template 'single-file'
#   cd chat-cli
#   zypher run

cd chat-cli

The project was created at ~/code/chat-cli/.

B-3 Understanding What zypher new Creates

Let's look at exactly what was generated:

chat-cli/
├── .gitignore
├── build.zig
└── src/
    └── main.zig

.gitignore:

.zig-cache/
zig-out/
*.db

build.zig (generated verbatim — note {{project_name}} was replaced with chat-cli):

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target   = b.standardTargetOptions(.{ .default_target = .{ .abi = .musl } });
    const optimize = b.standardOptimizeOption(.{});

    // This is the key: zypher-root defaults to "../.." (the zypher source tree)
    // but can be overridden at build time with -Dzypher-root=<path>.
    const zypher_root  = b.option([]const u8, "zypher-root",
        "Path to the Zypher source tree") orelse "../..";
    const sqlite_root  = b.pathJoin(&.{ zypher_root,
        "vendor/sqlite-amalgamation-3530000" });

    // Compile SQLite as a C static library.
    const sqlite3_mod = b.createModule(.{ .target = target, .optimize = optimize });
    sqlite3_mod.addCSourceFiles(.{
        .root  = .{ .cwd_relative = sqlite_root },
        .files = &.{"sqlite3.c"},
        .flags = &.{ "-std=c11", "-DSQLITE_THREADSAFE=0",
                     "-DSQLITE_OMIT_LOAD_EXTENSION" },
    });
    sqlite3_mod.addIncludePath(.{ .cwd_relative = sqlite_root });
    sqlite3_mod.link_libc = true;
    const sqlite3_lib = b.addLibrary(.{ .name = "sqlite3",
                                        .root_module = sqlite3_mod });

    // Create the zypher module from source.
    const zypher_mod = b.createModule(.{
        .root_source_file = .{ .cwd_relative =
            b.pathJoin(&.{ zypher_root, "src/zypher.zig" }) },
        .target   = target,
        .optimize = optimize,
    });
    zypher_mod.linkLibrary(sqlite3_lib);
    zypher_mod.addIncludePath(.{ .cwd_relative = sqlite_root });
    zypher_mod.link_libc = true;

    // Your app executable.
    const exe = b.addExecutable(.{
        .name        = "chat-cli",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target           = target,
            .optimize         = optimize,
            .imports          = &.{
                .{ .name = "zypher", .module = zypher_mod },
            },
        }),
    });
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    run_cmd.addPassthruArgs();
    b.step("run", "Run the app").dependOn(&run_cmd.step);

    // ... doc and test steps omitted for brevity
}

The critical difference from Part A's build.zig: this file does not use build.zig.zon to fetch zypher. It compiles zypher from source directly using b.pathJoin(&.{ zypher_root, "src/zypher.zig" }). The zypher root defaults to "../.." — two directories up from the chat-cli/ project directory, which is where ~/code/zypher/ sits.

src/main.zig (generated single-file scaffold with {{project_name}} replaced): The generated file is ~200 lines and includes a working main, a ManagedItem model, the admin panel with login/logout, and the full session/CSRF middleware chain. It is immediately runnable.

Key structures in the generated scaffold (that we will build on):

  • threadlocal var tl_db: ?*sqlite.Db and tl_router for shared state
  • The full Chain(.{session, security_headers}) middleware
  • Admin routes at /admin/...
  • Login handlers at /admin/login
  • A parsePort function that reads --port from argv

B-4 Designing the Schema Manifest

For the CLI's makemigrations command, you write a schema.zypher manifest file instead of (or in addition to) defining Zig ORM models. The manifest describes your tables in a simple whitespace-delimited text format:

Create schema.zypher in the project root:

# chat-cli schema manifest
# Syntax:
#   table <table_name>
#   field <name> <kind> [primary] [required] [unique] [foreign=<ref>]
#
# Kinds: integer, float, text, boolean

table chat_users
field id         integer primary
field session_id text    required unique
field nickname   text    required
field joined_at  integer required

table messages
field id         integer primary
field nickname   text    required
field body       text    required
field created_at integer required

B-5 Running makemigrations

zypher makemigrations
# Reads schema.zypher (default), compares to .zypher_schema (state snapshot).
# On first run, no snapshot exists — generates CREATE TABLE migrations.
# Output:
# Created migration migrations/0001_create_chat_users.sql: create_chat_users
# Created migration migrations/0002_create_messages.sql: create_messages

What was created:

chat-cli/
├── migrations/
│   ├── 0001_create_chat_users.sql
│   └── 0002_create_messages.sql
├── .zypher_schema      ← state snapshot (committed to git)
├── schema.zypher
├── build.zig
└── src/main.zig

migrations/0001_create_chat_users.sql:

CREATE TABLE IF NOT EXISTS chat_users (id INTEGER PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, nickname TEXT NOT NULL, joined_at INTEGER NOT NULL);

migrations/0002_create_messages.sql:

CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, nickname TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL);

To add a field later (e.g., adding edited_at to messages), add field edited_at integer to schema.zypher and re-run makemigrations. It will detect the diff and generate an ALTER TABLE migration.

# After adding "field edited_at integer" to schema.zypher:
zypher makemigrations
# Created migration migrations/0003_add_edited_at_to_messages.sql: add_edited_at_to_messages

B-6 Running migrate

Now apply the migrations to the database:

zypher migrate
# Reads migrations/ directory, sorts by numeric prefix, applies pending migrations.
# Output:
# zypher: database 'db.sqlite' — applied 2 migration(s), skipped 0

zypher migrate
# Idempotent: already applied migrations are skipped.
# Output:
# zypher: database 'db.sqlite' — applied 0 migration(s), skipped 2

The migration state is tracked in zypher_migrations table in the database. You can pass a custom database path and migrations directory:

zypher migrate --db chat-cli.db --dir migrations

B-7 Modifying the Scaffold for Chat

Now we expand the generated src/main.zig to add chat-specific models, handlers, and forms. The key difference from Part A: rather than creating separate files, the single-file template puts everything in one file. We will split into multiple files here for clarity, exactly as in Part A — the CLI approach does not prevent you from using multiple source files.

Add the same src/models.zig, src/forms.zig, src/handlers.zig, and src/admin.zig from Part A. Then modify the generated src/main.zig to wire these in.

The generated main.zig already has:

  • The Chain and dispatch infrastructure
  • The admin site
  • Session store setup
  • The parsePort function

You will modify these sections:

Replace the ManagedItem admin with your chat admin (same admin.zig as Part A).

Add your app routes before the admin routes:

// In main(), replace the generated app_routes with:
const app_routes = [_]Route{
    Router.route(.get,  "/",         handlers.home),
    Router.route(.post, "/messages", handlers.postMessage),
    Router.route(.get,  "/set-name", handlers.setNameForm),
    Router.route(.post, "/set-name", handlers.setName),
};

Add table creation right after sqlite.Db.open:

// The migrate command already ran the SQL from migrations/, but we also
// call exec() for the users table which is managed outside the manifest.
// exec() is idempotent: CREATE TABLE IF NOT EXISTS will do nothing if the
// table already exists.
try db.exec(models.ChatUser.create_table_sql);
try db.exec(models.Message.create_table_sql);
try db.exec(
    \\CREATE TABLE IF NOT EXISTS users (
    \\  id INTEGER PRIMARY KEY AUTOINCREMENT,
    \\  username TEXT NOT NULL UNIQUE,
    \\  email TEXT UNIQUE,
    \\  password_hash TEXT NOT NULL,
    \\  role TEXT NOT NULL DEFAULT 'user',
    \\  is_active INTEGER NOT NULL DEFAULT 1,
    \\  reset_code TEXT,
    \\  reset_code_expires_at INTEGER
    \\)
);

Load templates. The CLI approach doesn't change how templates work — you embed them the same way:

_ = try engine.load("layout.html",   @embedFile("../templates/layout.html"));
_ = try engine.load("home.html",     @embedFile("../templates/home.html"));
_ = try engine.load("set_name.html", @embedFile("../templates/set_name.html"));

Create the templates/ directory and add the same three HTML files from Part A.

B-8 Creating the Superuser

With the CLI, creating an admin user is one command. No need to hash passwords manually:

# Interactive mode — prompts for username, email, and password
zypher createsuperuser

# Or non-interactive with all flags:
zypher createsuperuser \
  --username admin \
  --email    admin@chat.dev \
  --password Admin12345 \
  --db       db.sqlite

# Output: Superuser 'admin' created (id=1)

The createsuperuser command:

  • Validates that username is non-empty.
  • Validates email format with the same validators.email function used by the library.
  • Validates that the password is at least 8 characters with at least one letter and one digit.
  • Hashes the password with PBKDF2-HMAC-SHA256 (100,000 iterations, random 16-byte salt).
  • Opens the database (defaulting to db.sqlite in the current directory), creates the users table if it doesn't exist, and inserts the row.

B-9 Running the Application with the CLI

# From inside chat-cli/
zypher run
# Equivalent to: zig build -Dzypher-root=<inferred> run -- --port 8080
# The inferred zypher root is found by looking two levels above the zypher binary.

# On a different port:
zypher run --port 9000
# Equivalent to: zig build -Dzypher-root=<inferred> run -- --port 9000

# Pass extra arguments after -- (they go to your app's argv):
zypher run -- --port 9000

# If zypher-root inference fails, specify it:
zypher run --zypher-root /home/you/code/zypher
# Or set the environment variable:
ZYPHER_ROOT=/home/you/code/zypher zypher run

Under the hood, zypher run shells out to zig build -Dzypher-root=<path> run. The zig build run target in the generated build.zig then starts your app. The port argument is passed through addPassthruArgs() to your binary's argv.

After zypher run starts, your app is accessible at http://localhost:8080/.

B-10 Other CLI Commands

zypher shell — opens an arithmetic REPL for quick arithmetic expressions:

zypher shell
# zypher shell
# Context: std, zypher
# Commands: :help, :context, :contract, :quit
# zypher> 2 + 2
# 4
# zypher> 100 * 24 * 60 * 60
# 8640000
# zypher> :quit
# bye

The shell currently evaluates integer arithmetic expressions (+, -, *, /). It is not a Zig REPL — it cannot compile or run Zig code.

zypher doc — builds and serves zypher's own API documentation:

zypher doc
# Building documentation in /home/you/code/zypher
# Serving documentation at http://127.0.0.1:8080/
# (CTRL+C to stop)

zypher doc-user — builds and serves documentation for your app:

cd chat-cli
zypher doc-user .
# Runs `zig build doc` in your project and serves the output at http://127.0.0.1:8080/

zypher demo — scaffolds an MVC demo project:

zypher demo my-demo
# Equivalent to: zypher new my-demo --template mvc

zypher runserver — starts a minimal development server with only a /health endpoint. This is useful for verifying the server itself works before wiring any application code:

zypher runserver --host 127.0.0.1 --port 8080
# Starting zypher server at http://127.0.0.1:8080/
# GET /health → "OK"
# GET /        → "zypher server is running"

4. Side-by-Side Comparison

What each approach actually does differently

Aspect Package Approach CLI Approach
Dependency declaration build.zig.zon with .path or .url Not used; build.zig compiles zypher from source directly
build.zig authorship You write it entirely Generated by zypher new, references zypher by zypher-root option
Project scaffold None — you create every file zypher new creates .gitignore, build.zig, and a src/main.zig scaffold
Table creation Call db.exec(Model.create_table_sql) manually in code Use schema.zypher manifest + zypher makemigrations + zypher migrate
Superuser creation Write SQL by hand or use CLI tool zypher createsuperuser command
Running the server zig build run directly zypher run (which calls zig build run internally)
zypher source location Resolved by build.zig.zon dependency resolution Must be on-disk, inferred from binary location or ZYPHER_ROOT
Code organization You decide (multiple files encouraged) Scaffold starts in single file, you expand it

When to use the Package Approach

  • You are building a production application and want clean, version-pinned dependencies in build.zig.zon.
  • You want to use zypher from a URL/hash rather than a local path.
  • You prefer to control every aspect of the project structure.
  • You are integrating zypher into an existing Zig project.
  • You are comfortable writing build.zig by hand.

Pros:

  • Standard Zig package management — works with zig build --fetch and lockfiles.
  • No dependency on a pre-built zypher binary for development.
  • Explicit about every dependency, every step.
  • Will work even after zypher is published to a package registry.

Cons:

  • More initial boilerplate — you write build.zig, create directories, declare models, set up middleware all from scratch.
  • No makemigrations or migrate workflow — you manage SQL migrations yourself.
  • createsuperuser is not available unless you have also built the CLI.

When to use the CLI Approach

  • You want to get a working zypher app running in under five minutes.
  • You prefer the zypher makemigrations / zypher migrate workflow for schema evolution.
  • You are developing alongside the zypher source tree (e.g., contributing to zypher or building something to share as an example).
  • You want zypher createsuperuser for admin account creation without writing SQL.

Pros:

  • Faster initial setup — scaffold is immediately runnable (zypher new + zypher run).
  • makemigrations generates SQL migration files from a simple text manifest.
  • migrate applies .sql files idempotently with version tracking.
  • createsuperuser eliminates the need to hand-write SQL for admin accounts.
  • runserver provides a minimal sanity-check server.
  • doc and doc-user serve API documentation locally.

Cons:

  • Requires the zypher binary to be built and accessible.
  • The zypher-root inference can fail if the binary is moved or installed globally without setting ZYPHER_ROOT.
  • The schema.zypher manifest is separate from the Zig ORM models — you must keep both in sync manually.
  • makemigrations supports only CREATE TABLE and ADD COLUMN — it cannot generate DROP, RENAME, or other destructive SQL.
  • zypher run is a thin wrapper around zig build run; experienced Zig developers may prefer calling zig build run directly.

The migration system in detail

The CLI migration system has specific capabilities and limitations derived directly from the source:

What makemigrations can detect:

  • New tables (generates CREATE TABLE IF NOT EXISTS ...)
  • New fields added to existing tables (generates ALTER TABLE ... ADD COLUMN ...)

What makemigrations cannot detect:

  • Removed fields (SQLite does not support DROP COLUMN in older versions)
  • Renamed fields or tables
  • Changed field types or constraints
  • Changes to indexes (only basic PRIMARY KEY, NOT NULL, UNIQUE, REFERENCES from the manifest)

For destructive or complex schema changes, write the SQL migration file manually in the migrations/ directory with the next numeric prefix.

How migrate works:

  1. Reads all .sql files from the migrations/ directory.
  2. Sorts them lexicographically by filename (numeric prefix ensures correct order: 0001_ < 0002_ < 0010_).
  3. Parses the numeric ID from the filename prefix.
  4. Checks zypher_migrations table in the database — skips already-applied IDs.
  5. Executes each pending file's SQL against the database.
  6. Records the migration as applied.

The zypher_migrations table is identical to the one created by migration.MigrationRunner.ensureHistoryTable() in the Zig library, so you can freely mix CLI migrations and programmatic MigrationRunner calls.


5. Complete CLI API Reference

Global Usage

zypher <command> [options]

All commands write informational output to stdout and errors to stderr. Non-zero exit codes indicate failure.

zypher help / zypher --help

Prints the help message listing all available commands.

zypher help

No options.


zypher version / zypher --version

Prints the version string and exits.

zypher version
# Output: zypher 0.1.0-beta

zypher new <path> [options]

Scaffolds a new zypher project from a built-in template.

Arguments:

  • <path> — the directory path to create. The basename of the path becomes the project name. The project name must contain only letters, digits, _, and -.

Options:

Flag Description Default
--template <name> Scaffold template name single-file
--style <name> Alias for --template —
--template-dir <dir> Directory to search for templates (overrides embedded templates) templates
--api Append -api to the template name (e.g., single-file-api) off

Available embedded templates:

Template Structure Description
single-file src/main.zig Everything in one file. Best for prototypes.
single-file-api src/main.zig Single file, JSON API only, no HTML templates.
mvc src/main.zig, src/models/, src/views/, src/controllers/ Model-View-Controller layout.
mvc-api Same as mvc but JSON-only routes MVC for REST APIs.
mvp src/main.zig, src/models/, src/presenters/, src/views/ Model-View-Presenter layout.
mvp-api Same as mvp but JSON-only MVP for REST APIs.
clean-arch src/main.zig, src/domain/, src/application/, src/infrastructure/, src/presentation/ Clean architecture.
clean-arch-api Same as clean-arch but JSON-only Clean architecture for REST APIs.

Template substitution: the string {{project_name}} in any file is replaced with the actual project name.

Example:

zypher new my-blog --template mvc
zypher new my-api  --template single-file --api   # generates single-file-api
zypher new my-app  --template-dir ./my-templates   # uses custom template dir

What each generated project contains:

  • build.zig — wires zypher from source with the zypher-root build option
  • src/main.zig — runnable scaffold with sessions, security headers, admin login
  • .gitignore — ignores .zig-cache/, zig-out/, *.db

zypher templates [options]

Lists available scaffold templates.

Options:

Flag Description Default
--template-dir <dir> Directory to list instead of embedded —
zypher templates
# Available embedded templates:
#   clean-arch
#   mvc
#   mvp-api
#   single-file
#   mvc-api
#   mvp
#   single-file-api
#   clean-arch-api

zypher templates --template-dir ./my-templates
# Available templates in ./my-templates:
#   my-custom-template

zypher run [path] [options]

Runs a zypher project's zig build run step with the correct zypher-root injected.

Arguments:

  • [path] — project directory to run (default: ., the current directory).

Options:

Flag Description Default
--port <n> Port to pass to the app via --port <n> in argv 8080
--zypher-root <path> Explicit path to the zypher source tree (inferred from binary location)

After --: any arguments after -- are passed directly to the app's argv.

zypher run                          # runs . on port 8080
zypher run --port 9000              # runs . on port 9000
zypher run my-project --port 3000   # runs my-project/ on port 3000
zypher run -- --port 4000           # passes --port to app via --
zypher run --zypher-root /path/to/zypher

How the port is passed: zypher run calls zig build -Dzypher-root=<root> run -- --port <n>. The addPassthruArgs() in build.zig forwards -- --port <n> to your binary's argv. Your app then parses --port from argv using the parsePort pattern shown in the scaffold.

Inference of zypher root: The root is inferred by:

  1. Checking ZYPHER_ROOT environment variable.
  2. Walking up from the binary's directory looking for src/zypher.zig.
  3. Checking <prefix>/share/zypher/src/zypher.zig.
  4. Falling back to "../..".

zypher makemigrations [options]

Reads a schema manifest file, compares it to the last-known state, and generates SQL migration files for the differences.

Options:

Flag Description Default
--schema <file> Schema manifest file schema.zypher
--state <file> State snapshot file (tracks current schema) .zypher_schema
--dir <dir> Directory to write migration .sql files migrations

Schema manifest format:

# Lines starting with # are comments
# Blank lines are ignored

table <table_name>
field <field_name> <kind> [flags...]

# Flags (any order):
#   primary   — marks the field as INTEGER PRIMARY KEY
#   required  — adds NOT NULL
#   unique    — adds UNIQUE
#   foreign=<table>(<column>)  — adds REFERENCES

# Kind values: integer, float, text, boolean

Example manifest:

table posts
field id         integer primary
field title      text    required
field body       text    required
field author_id  integer required foreign=users(id)
field published  boolean

table tags
field id   integer primary
field name text    required unique

What it generates:

On first run (no .zypher_schema): one CREATE TABLE migration per table.

On subsequent runs: one ALTER TABLE ... ADD COLUMN migration per new field. Removed fields, renamed fields, and changed types produce no output — you must write those migrations by hand.

Migration file naming:

  • CREATE: <dir>/<index:04>_create_<table>.sql
  • ADD COLUMN: <dir>/<index:04>_add_<field>_to_<table>.sql

The index is sequential within the run, starting at 1. It does not account for existing files in the directory.

zypher makemigrations
zypher makemigrations --schema models/schema.zypher --state .schema_snapshot --dir db/migrations

zypher migrate [options]

Applies pending SQL migration files from the migrations directory to the database.

Options:

Flag Description Default
--db <path> SQLite database file db.sqlite
--dir <dir> Directory containing .sql migration files migrations
zypher migrate
zypher migrate --db chat.db --dir db/migrations

Behavior:

  • Creates zypher_migrations table in the database if it does not exist.
  • Reads all .sql files from <dir>, sorts by filename (lexicographic = numeric order due to zero-padded names).
  • Extracts the numeric ID from the start of each filename (0001_create_posts.sql → ID 1).
  • Skips files whose ID is already in zypher_migrations.
  • Executes each pending file's contents as a single SQL statement block via Db.exec.
  • Records each successful migration in zypher_migrations with its ID and filename.
  • Prints how many were applied and how many were skipped.

On failure: migration stops immediately. No rollback is performed — you must fix the SQL and re-run.


zypher createsuperuser [options]

Creates an admin user account in the database.

Options:

Flag Description Default
--username <name> Username for the admin account (prompted interactively)
--email <email> Email address (prompted interactively)
--password <pass> Plain-text password (hashed before storage; never stored in plain text) (prompted interactively, hidden)
--db <path> Database file db.sqlite

Validation rules:

  • Username: non-empty, no leading/trailing whitespace.
  • Email: must contain exactly one @, non-empty local part, non-empty domain with at least one ..
  • Password: at least 8 characters, at least one alphabetic character, at least one digit.
  • Passwords must match (in interactive mode, you are asked to confirm).

Password security: passwords are hashed with PBKDF2-HMAC-SHA256, 100,000 iterations, a random 16-byte salt, stored as $pbkdf2-sha256$<iter>$<salt_hex>$<hash_hex>.

# Interactive (prompts for all fields; password input is hidden)
zypher createsuperuser

# Non-interactive
zypher createsuperuser --username admin --email a@b.com --password Secret12

# Custom database
zypher createsuperuser --db myapp.db --username admin --email a@b.com --password Secret12

What it does in the database:

  • Creates the users table with columns id, username, email, password_hash, role, is_active, reset_code, reset_code_expires_at if it does not exist.
  • Ensures email, reset_code, reset_code_expires_at columns exist (via ALTER TABLE if the table was created without them).
  • Inserts the new user with role = 'admin' and is_active = 1.
  • Prints Superuser '<username>' created (id=<rowid>) on success.
  • Prints a specific error message on failure (duplicate username, duplicate email, validation failure, etc.).

zypher runserver [options]

Starts a minimal HTTP server (without any app logic) for verifying the server works.

Options:

Flag Description Default
--host <host> Bind address 127.0.0.1
--port <n> Port 8080
--max-requests <n> Stop after serving this many requests (testing) unlimited
zypher runserver
zypher runserver --host 0.0.0.0 --port 3000
zypher runserver --max-requests 10   # serves 10 requests then exits

Routes served by runserver:

  • GET /health → "OK" (200)
  • Any other path → "zypher server is running" (200)

The server handles SIGINT (Ctrl+C) for graceful shutdown.


zypher shell [options]

Opens an interactive arithmetic REPL with zypher context metadata.

Options:

Flag Description Default
--eval <expr> Evaluate a single expression and exit (interactive mode)
zypher shell
# zypher> 1 + 2
# 3
# zypher> 24 * 60 * 60
# 86400
# zypher> :help
# Available commands: :help, :context, :contract, :quit
# zypher> :quit
# bye

zypher shell --eval "365 * 24 * 60 * 60"
# 31536000

Shell commands:

  • :help / :h — list commands
  • :context — describe available Zig namespaces (std, zypher)
  • :contract — clarify what the shell is and is not
  • :quit / :q / exit — end session

Supported expressions: integer arithmetic with +, -, *, / (integer division). Parentheses are not supported. The expression must be completely consumed — trailing characters cause an error.


zypher doc [options]

Builds zypher's own API documentation and serves it via HTTP.

Options:

Flag Description Default
--zypher-root <path> Path to zypher source tree (inferred)
--host <host> Bind address for doc server 127.0.0.1
--port <n> Port for doc server 8080
--max-requests <n> Stop after N requests unlimited
zypher doc
zypher doc --port 9090 --zypher-root /home/you/code/zypher

Runs zig build doc in the zypher source tree, then starts a static file server serving zig-out/docs/.


zypher doc-user [path] [options]

Builds and serves documentation for your own project.

Arguments:

  • [path] — project directory containing build.zig (default: .)

Options:

Flag Description Default
--host <host> Bind address 127.0.0.1
--port <n> Port 8080
--max-requests <n> Stop after N requests unlimited
zypher doc-user .
zypher doc-user my-app --port 9090

Runs zig build doc in your project directory (which must have a doc build step), then serves the output.


zypher demo <name>

Scaffolds an MVC demo project. Equivalent to zypher new <name> --template mvc.

zypher demo my-demo
# Same as: zypher new my-demo --template mvc

6. Complete Library API Reference

Module map

Import Contents
zypher.core App, Server, Request, Response, Method, Context
zypher.router Router, Route, RouteParams
zypher.middleware Chain, MiddlewareFn, HandlerFn, all built-in middleware
zypher.template lexer, parser, renderer (Template, TemplateEngine, Context, Value), filters
zypher.orm sqlite (Db, Stmt, Value), schema (Model, Field, FieldDef), query, migration
zypher.forms form (Form, Field, BoundForm), validators
zypher.auth session (Session, SessionStore), password, user
zypher.admin AdminSite, Registration, setDb, setEngine
zypher.errors ZypherError, errorToString
zypher.log setLogLevel, setLogWriter, startCapture, stopCapture
zypher.cli_runner dispatchInner (for testing CLI commands programmatically)

zypher.core.App

The application entry point. Wraps Server, an optional sqlite.Db, and handler registration.

pub const App = struct {
    server:             Server,
    allocator:          std.mem.Allocator,
    handler_fn:         ?Server.HandlerFn = null,
    router_handler:     ?Server.HandlerFn = null,
    middleware_handler: ?Server.HandlerFn = null,
    db:                 ?*sqlite.Db       = null,
};
Method Signature Description
init (gpa, Server.Config) App Create. Does not start listening.
deinit (*App) void Closes db if attached.
handler (*App, HandlerFn) void Register flat handler (lowest priority).
routerHandler (*App, HandlerFn) void Register router handler.
middlewareHandler (*App, HandlerFn) void Register middleware+router handler (highest priority).
database (*App, *sqlite.Db) void Attach database (closes on deinit).
handleRequest (*App, *Request, *Response) void Dispatch without networking (for tests).
listenAndServe (*App, std.Io) !void Bind, listen, and serve requests. Blocks.
shutdown (*App, std.Io) void Signal graceful shutdown.
buildRequestFromHead (*App, head_buffer) !Request Parse a request from raw bytes (for tests).

Server.Config fields:

Field Type Default
host []const u8 "127.0.0.1"
port u16 8080
read_buffer_size usize 8192
write_buffer_size usize 8192
max_body_size usize 1_048_576 (1 MiB)
max_requests ?usize null (unlimited)

Handler priority: middleware_handler > router_handler > handler_fn > built-in 404.


zypher.core.Request

Parsed HTTP request. Created once per connection, freed after the handler returns. All slices point into the parsed data — no extra copies except for URL-decoded strings.

Fields:

Field Type Description
method Method .get, .post, .put, etc.
path []const u8 Request path: /users/42
query StringHashMap([]const u8) Parsed query string parameters
headers StringHashMap([]const u8) HTTP headers (keys as received, values trimmed)
body []const u8 Raw request body
body_owned bool If true, body is heap-allocated and freed in deinit
params RouteParams URL parameters extracted by the router
user ?*anyopaque Cast to *Session when session middleware is used
allocator std.mem.Allocator Per-request allocator

Methods:

Method Signature Description
header (*const Request, name) ?[]const u8 Case-insensitive header lookup
queryParam (*const Request, name) ?[]const u8 Query string lookup
formValue (*const Request, name) ?[]const u8 POST form field or query string
cookie (*const Request, name) ?[]const u8 Cookie value from Cookie: header
file (*const Request, name) ?FileUpload Uploaded file from multipart body
deinit (*Request) void Free all owned memory

Static helpers (called by Server internally):

Method Description
parsePath(target) Extracts path before ?
parseQueryString(gpa, raw) Parses key=val&key2=val2 into map
parseFormUrlEncoded(gpa, body) Same as parseQueryString for POST bodies
parseMultipartFormData(gpa, ct, body) Parses multipart/form-data into fields + files
parseCookies(gpa, header) Parses Cookie: header into name=value map
validateBodySize(len, max) Returns error.BodyTooLarge if exceeded
deinitQueryString(map, gpa) Frees decoded key+value strings
deinitFiles(files, gpa) Frees file upload entries
getHeaderCI(headers, name) Case-insensitive header lookup (static)

FileUpload struct:

Field Type
filename []const u8
content_type []const u8
data []const u8

zypher.core.Response

HTTP response builder. Created with init(gpa) for every request. Headers and body are heap-allocated and freed in deinit. All mutating methods return *Response for chaining.

Fields:

Field Type Initial value
status_code u16 200
reason_phrase ?[]const u8 "OK"
headers StringHashMap([]const u8) empty
set_cookie_headers ArrayList([]const u8) empty
body ?[]const u8 null
use_chunked bool false
allocator std.mem.Allocator per-request allocator

Methods:

Method Signature Description
init (gpa) Response Create
deinit (*Response) void Free body, headers, cookies
status (*Response, code: u16) *Response Set status code + reason phrase
header (*Response, name, value) *Response Set a response header. Sanitizes CR/LF. Chainable.
addSetCookie (*Response, value) *Response Append raw Set-Cookie header value
text (*Response, content) !void Set text/plain; charset=utf-8 body
html (*Response, content) !void Set text/html; charset=utf-8 body
json (*Response, content) !void Set application/json body. Accepts raw []const u8 or any serializable Zig value.
stream (*Response, content) !void Set chunked body with Transfer-Encoding: chunked
redirect (*Response, url, code) !void Set Location header and status
setCookie (*Response, Cookie) *Response Set-Cookie with all attributes
deleteCookie (*Response, name) *Response Set-Cookie with Max-Age=0
send (*Response, gpa, *ArrayList(u8)) !void Serialize full HTTP/1.1 response

Cookie struct fields:

Field Type Default
name []const u8 required
value []const u8 ""
path []const u8 "/"
domain ?[]const u8 null
max_age ?u32 null
secure bool false
http_only bool false
same_site SameSite .Lax

SameSite enum: .Strict, .Lax, .None


zypher.core.Method

pub const Method = enum {
    get, post, put, patch, delete, options, head,
    pub fn fromStdString(m: std.http.Method) Method;
    pub fn toStdString(m: Method) std.http.Method;
};

zypher.router.Router

Compile-time or runtime route table with runtime dispatch.

Method Signature Description
init (comptime routes, not_found_fn) Router Comptime validation of all patterns
initFromSlice (routes: []const Route, not_found_fn) Router Runtime init from slice
route (Method, pattern, handler) Route Create a Route value
group (comptime prefix, comptime routes) []const Route Prefix all patterns
dispatch (*const Router, *Request, *Response) void Route to matching handler

Route struct:

pub const Route = struct {
    method:  Method,
    pattern: []const u8,
    handler: *const fn (*Request, *Response) void,
};

Pattern syntax:

Segment Example Matches Notes
Static /users /users only Exact match
:param /:id /<any> Extracts as string
:param[u64] /:id[u64] /<digits> Validates + extracts as u64 string
* /files/* /files/<anything> Must be last segment

Dispatch algorithm:

  1. Test all routes. Among those whose path matches, pick the most specific one by pattern score (static segments score 100, :param score 10, * scores 1).
  2. If path matches but no route has the right method → 405 with Allow header.
  3. If nothing matches → call not_found_fn.

Duplicate param names in a pattern are caught at compile time.


zypher.router.RouteParams

Zero-allocation URL parameter store backed by fixed-size stack arrays.

Member Description
max_params = 16 Maximum parameters per route
init(gpa) Create empty params
put(name, value) !void Store param (overwrites on duplicate name)
get(name) ?[]const u8 Lookup by name
getAs(T, name) !T Lookup and parseInt to type T
count() usize Number of params stored
reset() Clear without freeing

zypher.middleware

Chain(comptime mws: anytype) type

Returns a struct with one method:

fn run(req: *Request, res: *Response, handler: HandlerFn) void

All middleware must match MiddlewareFn:

pub const MiddlewareFn = *const fn (*Request, *Response,
    *const fn (*Request, *Response) void) void;

The chain is fully unrolled at compile time — no dispatch table, no heap allocation per request.

Built-in middleware

Module Middleware fn Description
logger middleware Logs method, path, status, elapsed µs
cors middleware Default CORS (allow all origins)
cors middlewareWith(Config) CORS with custom config
csrf middleware CSRF token check
csrf formFieldForRequest(gpa, req) ![]u8 Hidden input HTML with token
csrf ensureToken(req) ![]const u8 Get/create CSRF token for session
csrf validateTokenForRequest(req, token) bool Constant-time token comparison
rate_limit middleware 100 req/60s per IP
rate_limit middlewareWith(Config) type Custom rate limiter (returns a type)
session middleware Load/create session, set req.user
session setStore(*SessionStore) void Configure store (call before serving)
session setCookieConfig(CookieConfig) void Configure cookie attributes
session resetCookieConfig() void Restore defaults
static middleware Serve from ./public at /
static middlewareWith(Config) Serve with custom config
compress middleware Gzip response bodies
security_headers middleware X-Content-Type-Options, X-Frame-Options, etc.
security_headers configure(Options) void Enable/disable individual headers
recovery middleware No-op placeholder (panics are OS-level)

cors.Config:

Field Default
allowed_origins: ?[]const []const u8 null (all)
allowed_methods: []const u8 "GET, POST, PUT, DELETE, PATCH, OPTIONS"
allowed_headers: []const u8 "Content-Type, Authorization, X-CSRF-Token"
max_age: []const u8 "86400"
allow_credentials: bool false

rate_limit.Config:

Field Default
max_requests: u32 100
window_seconds: u32 60

static.Config:

Field Default
root_dir: []const u8 "./public"
prefix: []const u8 "/"
serve_index: bool true

security_headers.Options:

Field Default
x_content_type_options: bool true
x_frame_options: bool true
referrer_policy: bool true
x_xss_protection: bool true
content_security_policy: bool false
strict_transport_security: bool false
custom_headers: []const []const u8 &.{} (pairs: name, value, name, value…)

zypher.template

TemplateEngine

Method Signature Description
init (gpa) TemplateEngine Create engine
deinit (*TE) void Free cache
load (*TE, name, source) !*Template Parse and cache
getTemplate (*TE, name) ?*Template Look up by name
render (*TE, name, *Context, *Writer) !void Render to writer

Template

Method Signature Description
fromSource (gpa, source) !Template Parse standalone
render (*Template, *Context, *Writer) !void Render to writer
deinit (*Template) void Free AST

Context

Method Signature Description
init (gpa) Context Create
deinit (*Context) void Free map (does not free Value data)
put (*Context, key, Value) !void Set variable
get (*Context, key) ?Value Read variable

Value

pub const Value = union(enum) {
    string:   []const u8,
    int:      i64,
    float:    f64,
    bool:     bool,
    list:     []const Value,
    map:      *Context,
    null_val: void,
};

isTruthy(): returns false for empty strings, zero, false, empty lists, null_val. True for everything else.

Template syntax

Syntax Effect
{{ varname }} Render variable (HTML-escaped)
{{ varname|safe }} Render without escaping
{{ varname|upper }} Apply upper filter
{{ varname|truncate(100) }} Apply truncate filter with argument
{{ obj.field }} Dot-access nested Context
{{ list.0 }} Index into list
{% if cond %} Conditional block
{% elif cond %} Alternative condition
{% else %} Default block
{% endif %} End conditional
{% for item in list %} Loop over list
{% endfor %} End loop
{% extends "base.html" %} Template inheritance (must be first node)
{% block name %} Named block for overriding
{% endblock %} End named block
{% include "partial.html" %} Include another template

forloop context variables: counter, counter0, first, last.


zypher.orm

sqlite.Db

Method Signature Description
open (gpa, path: [:0]const u8) !Db Open database (":memory:" for in-memory)
close (*Db) void Close connection
isOpen (*Db) bool Check if handle is non-null
exec (*Db, sql: [:0]const u8) !void Execute statement with no parameters
prepare (*Db, sql: [:0]const u8) !Stmt Prepare parameterized statement
lastInsertRowId (*Db) i64 Rowid of last INSERT
changes (*Db) i64 Row count from last UPDATE/DELETE

DbError set: OpenFailed, ExecFailed, PrepareFailed, StepFailed, BindFailed, ColumnFailed, ConstraintViolation, UnexpectedResult.

sqlite.Stmt

Method Signature Description
bind (*Stmt, Value, idx: c_int) !void Bind parameter (1-based)
step (*Stmt) !bool true = row ready, false = done
column (*Stmt, ColumnType, idx: c_int) !Value Read column
columnType (*Stmt, idx: c_int) !ColumnType Column type
reset (*Stmt) !void Reset for re-execution
finalize (*Stmt) void Free resources

sqlite.Value union: .int: i64, .float: f64, .text: []const u8, .null: void

ColumnType enum: .integer, .float, .text, .blob, .null

schema

Field(name, kind, opts) → FieldDef

FieldKind: .integer, .float, .text, .boolean

FieldOptions:

Option Type SQL effect
primary: bool false PRIMARY KEY
required: bool false NOT NULL
unique: bool false UNIQUE
foreign: ?[:0]const u8 null REFERENCES <value>
default: ?DefaultValue null DEFAULT <value>

Model(table, FieldsStruct) → generated type.

Generated SQL constants (all [:0]const u8, all evaluated at compile time):

Constant SQL
create_table_sql CREATE TABLE IF NOT EXISTS <table> (...)
drop_table_sql DROP TABLE IF EXISTS <table>
insert_sql INSERT INTO <table> (<non-pk-cols>) VALUES (?,?,...)
select_all_sql SELECT <all-cols> FROM <table>
select_by_id_sql <select_all> WHERE id = ?
update_by_id_sql UPDATE <table> SET <col>=?,... WHERE id = ?
delete_by_id_sql DELETE FROM <table> WHERE id = ?

Other constants: table_name: [:0]const u8, fields_len: comptime_int, insert_field_count: comptime_int.

query

All functions are generic over a comptime model type M. Row type is a tuple of Zig types matching the model's fields in definition order (primary key is always i64, integers are i64, floats are f64, text is []const u8, boolean is bool).

Function Signature Description
create (M, *Db, []Value) !i64 INSERT → rowid
getById (M, *Db, gpa, id: i64) !RowType(M) SELECT WHERE id=?
all (M, *Db, gpa) !ArrayList(RowType(M)) SELECT all
filter (M, *Db, gpa, where: [:0], []Value) !ArrayList SELECT WHERE
filterLimitOffset (M, *Db, gpa, where, values, limit, offset) !ArrayList Paginated
filterOrderLimitOffset (M, *Db, gpa, where, values, order, limit, offset) !ArrayList Full
order (M, *Db, gpa, order_by: []const u8) !ArrayList SELECT ORDER BY
first (M, *Db, gpa, where, values) !?RowType(M) First match or null
count (M, *Db) !u64 SELECT COUNT(*)
updateById (M, *Db, id, []Value) !void UPDATE WHERE id=?
deleteById (M, *Db, id) !void DELETE WHERE id=?
save (M, *Db, gpa, *RowType(M)) !i64 INSERT if id==0, UPDATE otherwise
freeRow (M, gpa, *RowType(M)) void Free text column memory

QuerySet(M) builder:

var qs = query.QuerySet(M).init(&db, gpa);
defer qs.deinit();

var rows = try qs
    .filterBy("status = ?", &.{ .{ .text = "active" } })
    .orderBy("created_at DESC")
    .limit(10)
    .offset(0)
    .exec();

All methods on QuerySet return *Self for chaining. .exec() executes the built query.

migration

pub const Migration = struct {
    id:       i64,
    name:     [:0]const u8,
    up_sql:   [:0]const u8,
    down_sql: [:0]const u8,
};

pub const MigrationRunner = struct {
    pub fn init(*Db) MigrationRunner;
    pub fn ensureHistoryTable(*MR) !void;       // create zypher_migrations if not exists
    pub fn migrate(*MR, []Migration) !void;     // apply pending
    pub fn rollback(*MR, []Migration, n: usize) !void; // rollback last N
    pub fn status(*MR, gpa, []Migration) ![]MigrationStatus;
    pub fn countApplied(*MR) !u64;
};

zypher.forms

form.Field(name, kind, opts)

FieldKind: .text, .integer, .boolean, .file

opts: { required: bool = false, validator: ?ValidatorFn = null }

form.Form(name, FieldsStruct) → generated type

Method Signature Description
bind (gpa, *StringHashMap([]const u8)) !BoundForm Bind from map
bindRequest (gpa, *Request) !BoundForm Bind from request

BoundForm:

Method Description
validate() bool Run all validators. Populates errors.
cleanedData() Data Typed tuple in field-definition order
getValue(name) []const u8 Raw string value for any field
errors: StringHashMap([]const u8) Field name → error message
csrfFieldForRequest(req) ![]u8 CSRF hidden input HTML
deinit() void Free resources

Data type is a @Tuple of Zig types in field order: .text → []const u8, .integer → i64, .boolean → bool, .file → []const u8.

validators

Validator Signature Description
required ([]const u8) ?[]const u8 Non-empty
requiredOptional (?[]const u8) ?[]const u8 Non-null + non-empty
minLength(n) Returns fn([]const u8) ?[]const u8 At least N characters
maxLength(n) Returns fn([]const u8) ?[]const u8 At most N characters
email ([]const u8) ?[]const u8 Exactly one @, non-empty local and domain, dot in domain
url ([]const u8) ?[]const u8 Must start with http:// or https://, dot in host
regex(pattern) Returns fn([]const u8) ?[]const u8 Glob pattern (* any seq, ? any char)
choices(list) Returns fn([]const u8) ?[]const u8 Value must be in list
min(T, n) Returns fn(T) ?[]const u8 Numeric minimum
max(T, n) Returns fn(T) ?[]const u8 Numeric maximum
custom(T, fn) Returns validator function Wrap arbitrary function

Fields named "email" automatically receive validators.email in addition to any custom validator.


zypher.auth

session.Session

Method Signature Description
put (*Session, gpa, key, value) !void Store value
get (*const Session, key) ?[]const u8 Read value
isExpired (*const Session) bool Check against expires_at
deinit (*Session, gpa) void Free data map

Session ID is [32]u8 (256-bit random) stored as id. Expiry is a Unix timestamp.

session.SessionStore

Method Signature Description
init (gpa) SessionStore Create
deinit (*SS) void Free all sessions
create (*SS) !Session New session with 24h expiry
createWithExpiry (*SS, unix_ts: i64) !Session Custom expiry
save (*SS, *Session) !void Deep-copy into store
get (*SS, raw_id: [32]u8) !?*Session Lookup by raw bytes
getByHexId (*SS, hex: []const u8) !?*Session Lookup by hex string
destroy (*SS, raw_id) !void Delete session
destroyByHexId (*SS, hex) !void Delete by hex string

SESSION_ID_LEN = 32. CookieConfig struct: httponly: bool, secure: bool, samesite: [:0]const u8, path: [:0]const u8, max_age: u32.

password

Function Signature Description
hash (gpa, plaintext) ![]const u8 PBKDF2-HMAC-SHA256, 100K iters, random salt. Returns $pbkdf2-sha256$...
verify (stored_hash, plaintext) !bool Parse + re-derive + constant-time compare

user.User

Method Signature Description
init (gpa, username, plaintext) !User Hash password, dupe strings
deinit (*User) void Free username and hash
authenticate (*User, plaintext) !bool Verify if active and password matches
setRole (*User, role) void Update role (frees previous heap role)
deactivate (*User) void Set is_active = false

Auth guard middleware:

Function Behavior
loginRequired(req, res, next) 302 to /login if req.user == null
superuserRequired(req, res, next) 302 to /login if no user; 403 if role ≠ "admin"

Built-in view handlers (usable as route handlers directly):

Handler Method Description
loginView GET+POST Renders login form; processes POST (stub — sets 200)
logoutView GET Deletes session cookie, redirects to /
registerView GET+POST Renders register form; processes POST (stub)

zypher.admin

Symbol Signature Description
AdminSite(registrations) comptime Generate admin site type
Registration(Model, opts) comptime Register a model with the admin
Site.routes() () []const Route Admin routes
Site.loadTemplates(*engine) void Load admin HTML into engine
setDb(*Db) void Global admin database
setEngine(*TE) void Global admin template engine

Registration options:

Field Type Default
verbose_name_plural []const u8 ""
list_per_page u32 20

Generated admin routes (prefix: /admin):

Method Path Action
GET /admin/ Index of all registered models
GET /admin/<model>/ List records
GET /admin/<model>/add/ Create form
POST /admin/<model>/add/ Create record
GET /admin/<model>/<id>/ Edit form
POST /admin/<model>/<id>/ Update record
GET /admin/<model>/<id>/delete/ Delete confirm
POST /admin/<model>/<id>/delete/ Delete record
GET /admin/login Login form
POST /admin/login Process login
GET /admin/forgot-password Forgot password form
POST /admin/forgot-password Send recovery code
GET /admin/reset-password Reset password form
POST /admin/reset-password Process reset

zypher.errors.ZypherError

A comprehensive error union. All variants and their string representations:

Error String
BadRequest "Bad Request"
BodyTooLarge "Body Too Large"
MethodNotAllowed "Method Not Allowed"
NotFound "Not Found"
Timeout "Timeout"
InvalidRoutePattern "Invalid Route Pattern"
AmbiguousRoute "Ambiguous Route"
CsrfValidationFailed "CSRF Validation Failed"
RateLimitExceeded "Rate Limit Exceeded"
CorsBlocked "CORS Blocked"
PathTraversal "Path Traversal Detected"
TemplateNotFound "Template Not Found"
TemplateSyntaxError "Template Syntax Error"
TemplateRenderFailed "Template Render Failed"
DbConnectionFailed "Database Connection Failed"
DbQueryFailed "Database Query Failed"
DbNotFound "Not Found"
DbConstraintViolation "Constraint Violation"
MigrationFailed "Migration Failed"
ValidationFailed "Validation Failed"
AuthenticationFailed "Authentication Failed"
SessionInvalid "Session Invalid"
PermissionDenied "Permission Denied"
PasswordHashFailed "Password Hash Failed"
AdminAccessDenied "Admin Access Denied"
UnknownCommand "Unknown Command"

Use zypher.errors.errorToString(err) to convert any variant to its human-readable string.


zypher.log

Centralised log level and writer control.

Function Signature Description
setLogLevel (Level) void Set global minimum level
getLogLevel () Level Get current level
setLogWriter (?*Writer) void Redirect output (null = default std.log)
startCapture (gpa, *ArrayList(u8)) void Buffer log output (tests)
stopCapture () void Stop buffering
shouldLog (Level) bool Check if level would emit
writeLog (Level, scope, msg) void Low-level emit

Level enum: .err = 0, .warn = 1, .info = 2, .debug = 3.


zypher.cli_runner (programmatic CLI dispatch)

Useful for testing CLI commands in unit tests without shelling out:

const cli = @import("zypher").cli_runner;

// Call any CLI command programmatically:
try cli.dispatchInner(
    &out_writer.interface,
    &err_writer.interface,
    init,
    "createsuperuser",
    args,
    "0.1.0-beta",
);

Also exports:

  • parseRunserverConfig(args) !RunserverConfig
  • validateSuperuserCredentials(SuperuserInput) !void
  • runCreatesuperuserPrompt(gpa, db_path, reader, out, err) !void
  • runShellSession(reader, out, err) !void
  • parsePort(args) ?u16 — extract --port from args
  • inferZypherRoot(gpa, io) ![]const u8
  • buildRunArgv(gpa, zypher_root, port, app_args) ![][]const u8

Build Steps Reference

All commands run from the project root with zig build <step>.

Package approach (chat-pkg/)

Command Description
zig build Build and install binary to zig-out/bin/
zig build run Build and run the server
zig build run -- --port 9000 Run on custom port
zig build test Run unit tests
zig build doc Generate API docs to zig-out/docs/

CLI-scaffolded projects (chat-cli/)

Command Description
zypher run Build and run via CLI
zypher run --port 9000 Run on custom port
zig build run Direct Zig build (equivalent)
zig build run -- --port 9000 Direct Zig build on custom port
zig build test Run tests
zig build doc Generate docs
zypher migrate Apply pending SQL migrations
zypher makemigrations Generate new migrations from schema.zypher
zypher createsuperuser Create admin account
zypher shell Open arithmetic REPL
zypher doc Serve zypher library docs
zypher doc-user Serve your app's docs

This document was produced from a complete line-by-line reading of every source file in the zypher repository at https://github.com/zypher-org/zypher (main branch, June 2026), including all source files in src/, src/cli/, the embedded templates in src/cli/embedded_templates.zig, all three example applications, all scaffold templates, the full build.zig, and all documentation in docs/.