zynagent-first application framework

Add session authentication#

Mount the bundled sign-in forms and protect an account page. Start from an application created with zyn init --name NAME. Custom forms and session management are optional; the sign-in flow below uses the bundled views.

1. Enable authentication#

Add this declaration to src/app.zig, keeping its existing zyn import and other declarations:

// src/app.zig: exports
pub const auth: zyn.auth.Config = .{
    .magic_link = .{},
    .password = .{},
};

Omit either method to disable it. Public account creation defaults to closed: use an existing account, or enable it as described in step 4. A sign-in request does not onboard an unknown email. Password account creation requires email verification, which does not itself sign the browser in.

Add an auth field to zyn.zon:

// zyn.zon: auth settings
.auth = .{
    .origin = "http://localhost:7532",
    .return_to = "/account",
    .sessions = .{
        .max_age_seconds = 2_592_000,
        .idle_timeout_seconds = 604_800,
        .recent_auth_seconds = 600,
    },
},

Set origin to the address your browser uses, without a trailing slash. The example uses local HTTP development; production requires a trusted HTTPS origin. Email links use this configured origin, not request headers.

Sessions expire after thirty days or seven days without activity with these settings. Activity extends only the idle deadline. Set idle_timeout_seconds to null to disable idle expiration. Recent-authentication checks are separate from session expiration.

Run zyn db migrate after adding the configuration. Zyn owns the auth tables in the reserved zyn schema. Do not create application users or sessions tables, add a loader hook, or put auth identifiers in the application's Session type. Application records can reference zyn.users(id).

2. Add an account page#

Create src/actions/account.zig and src/views/account.html.ez. The page requires the zyn.auth.signed_in guard wired in the next step, so it can use the authenticated identity without handling anonymous requests:

// src/actions/account.zig
const zyn = @import("zyn");

pub fn show(action: *zyn.Action) !zyn.Response {
    try action.headers.set("Cache-Control", "private, no-store");
    return action.render("account.html", .{
        .email = action.auth.identity.?.email,
    }, .{});
}

pub fn sign_out(action: *zyn.Action) !zyn.Response {
    try action.auth.sign_out();
    return .redirect(.see_other, "/");
}
<!-- src/views/account.html.ez -->
<h1>Account</h1>
<p>Signed in as <%= @email %>.</p>
<form method="post" action="<%= url('/account/sign-out', %{}) %>">
    <input type="hidden" name="_method" value="DELETE">
    <button type="submit">Sign out</button>
</form>

Keep zyn.MethodOverride before the logger and route dispatch; it converts the form's URL-encoded POST into the DELETE action below. The route inherits Fetch Metadata CSRF protection. Do not change sign-out to a GET action.

3. Mount the forms and protect the account route#

Add the import and table entries to src/routes.zig, retaining its existing routes and CSRF-protected defaults:

// src/routes.zig: imports
const account = @import("actions/account.zig");
// src/routes.zig: table entries
.mount("/auth", zyn.auth.routes, &.{}),
.get("/account", account.show, &.{.guard(zyn.auth.signed_in)}),
.delete("/account/sign-out", account.sign_out, &.{.guard(zyn.auth.signed_in)}),

Mount zyn.auth.routes exactly once under a non-root path. With both methods enabled, the mounted pages include:

PagePurpose
/auth/sign-inRequest a magic link or sign in with a password
/auth/forgot-passwordRequest a password-reset link

/auth/sign-up is absent unless public account creation is enabled. The signed-in guard redirects HTML clients to sign-in with a local return destination. Other clients receive 401. Authentication does not grant access to every record: constrain application queries by action.auth.user.?.id or apply a resource-specific authorization guard.

Bundled forms include their required CSRF fields. Opening an emailed link stages it for confirmation; a POST consumes it. GET does not sign the browser in. Sign-out is POST /auth/sign-out and also requires the auth CSRF field. Do not turn sign-out or link consumption into GET actions.

4. Try sign-in#

The scaffold captures development email under .zyn/emails. Start the application and its notification worker:

zyn routes
zyn check
zyn dev --http

Match auth.origin to the reported server port.

For a fresh application, temporarily add .registration = .open to the auth declaration. Visit /auth/sign-up, submit matching passwords of 12–1024 bytes, verify the emailed link, then sign in at /auth/sign-in. Leave registration closed if the application uses separately provisioned accounts; this recipe does not implement invitations or administrative provisioning.

Visit /account while signed out. Request a magic link for an existing account, open it from the captured email, and confirm it. The account page should show the verified email. Reusing that link must not establish another session. Password accounts can also use /auth/forgot-password; a successful reset revokes existing sessions and signs in the resetting browser.

Verify#

Run zyn check and cover the browser flow with managed application tests:

  • Anonymous HTML requests redirect to sign-in; JSON requests receive 401.
  • Without public account creation, there is no /auth/sign-up route. When enabled, the account-creation form submits and requires email verification.
  • A requested magic link creates no session until its confirmation POST.
  • Incorrect password credentials fail without disclosing whether an email exists.
  • Signed-in requests can read the rendered account page but not another user's records.
  • Signing out rejects subsequent requests carrying the old session cookie.
  • Expired and replayed links cannot establish sessions.

The managed HTTP client does not follow redirects or maintain a cookie jar. Tests must retain all cookie pairs, pass the forms' CSRF values, and run queued notifications to inspect captured links. See framework/testing.

Optional changes#

  • Custom forms: override individual files such as src/views/auth/sign_in.html.ez. Bundled views work without overrides. Preserve enabled-method branches, input names, CSRF fields, and escaped errors. See Views and notifications.
  • Session management: action.auth.sign_out_others() retains the current session; sign_out_all() revokes every session. Use CSRF-protected, guarded POST actions. See Runtime capabilities.
  • Headless sign-in: use the auth capability, not direct table or cookie writes. See Headless operations.

Before deployment#

Choose account-creation and session-lifetime policies, configure production email delivery, and apply an auth route rate limit or trusted-edge policy. No auth rate limit is enabled by default. CORS does not replace authentication or CSRF protection.