zynagent-first application framework

Authentication#

Managed email authentication#

Enable magic links, passwords, or both in src/app.zig:

pub const auth: zyn.auth.Config = .{
    .registration = .open,
    .magic_link = .{},
    .password = .{},
};

Registration defaults to .closed. Omitted methods are disabled. Mount the framework table exactly once, below a non-root path:

.mount("/auth", zyn.auth.routes, &.{}),
.get("/account", account, &.{.guard(zyn.auth.signed_in)}),

The guard redirects HTML clients to sign-in with a local return destination; other clients receive 401. When native auth is disabled, the guard returns 401 without referencing a sign-in route. Routes for disabled methods and closed registration are absent. Password registration requires email verification. Verification alone does not create a browser session.

Set a trusted origin in zyn.zon; links never derive their origin from request headers. Production requires HTTPS:

.auth = .{
    .origin = "https://app.example.com",
    .return_to = "/",
    .sessions = .{
        .max_age_seconds = 2_592_000,
        .idle_timeout_seconds = 604_800,
        .recent_auth_seconds = 600,
    },
    .tokens = .{
        .sign_in_seconds = 900,
        .verify_identity_seconds = 86_400,
        .reset_password_seconds = 900,
    },
},

The values above are the defaults except origin, which is required. A null idle timeout disables idle expiration. For example, max_age_seconds = 630_720_000 with idle_timeout_seconds = null allows approximately twenty years; browser cookie retention is not guaranteed. Absolute expiration never slides. Activity extends idle expiration only, with persisted updates at most once per minute. Revocation, account disabling, and method disabling reject subsequent requests regardless of cookie lifetime. In-flight work is not canceled.

Runtime capabilities#

Actions, hooks, and middleware expose mutable auth. Guards receive only a read-only snapshot. Auth state loads before application middleware and guards:

action.auth.user       // ?zyn.auth.User
action.auth.identity   // ?zyn.auth.Identity
action.auth.session    // ?zyn.auth.Session
hook.auth.user
middleware.auth.user
guard.auth.user

try action.auth.sign_out();
try action.auth.sign_out_others();
try action.auth.sign_out_all();
const sessions = try action.auth.sessions();
try action.auth.revoke_session(session_id);

The session list contains at most 100 active sessions, newest first. Operations are scoped to the current user. Public snapshots exclude credential and token hashes. IDs use PostgreSQL UUID's 16-byte representation; timestamps use Unix microseconds. authenticated_at records successful authentication of this session, not activity or authentication on another device.

To add session management to the account page in Add session authentication, add a guarded, CSRF-protected POST action:

// src/actions/account.zig: callbacks
pub fn sign_out_others(action: *zyn.Action) !zyn.Response {
    try action.auth.sign_out_others();
    return .empty(.{ .status = .no_content });
}
// src/routes.zig: table entries
.post("/account/sign-out-others", account.sign_out_others, &.{.guard(zyn.auth.signed_in)}),

sign_out revokes the current session and clears its cookie. sign_out_others retains it while revoking the user's other sessions; sign_out_all revokes all. Revocation rejects subsequent requests even when the client retains its cookie.

Jobs have explicitly targeted administrative capabilities and no current user or browser session:

const user = try job.auth.users.find(user_id);
try job.auth.sessions.revoke(session_id);
try job.auth.sessions.revoke_all(user_id);

Headless operations#

Framework actions use the same request capability:

const sent = try action.auth.magic_link.request(.{ .email = email });
const signed_in = try action.auth.magic_link.consume(token);
const registered = try action.auth.password.sign_up(.{
    .email = email,
    .password = password,
    .password_confirmation = confirmation,
});
const login = try action.auth.password.sign_in(.{
    .email = email,
    .password = password,
});
try action.auth.email.request_verification(.{ .email = email });
const verified = try action.auth.email.verify(token);
try action.auth.password.request_reset(.{ .email = email });
const reset = try action.auth.password.reset(.{
    .token = token,
    .password = password,
    .password_confirmation = confirmation,
});

Magic-link requests return accepted or invalid_email; consumption returns signed_in or invalid. Registration returns check_email, invalid_email, invalid_password, or password_confirmation_mismatch. Sign-in returns signed_in, invalid_credentials, or verification_required. Verification returns verified or invalid; reset returns reset, invalid, invalid_password, or password_confirmation_mismatch. Operational failures propagate as errors. Public recovery/verification responses do not disclose account existence.

try action.auth.password.set(.{
    .password = password,
    .password_confirmation = confirmation,
});
try action.auth.password.change(.{
    .current_password = old_password,
    .password = password,
    .password_confirmation = confirmation,
});
try action.auth.password.remove(.{ .current_password = password });

Passwords must contain 12–1024 bytes. Setting requires authentication within recent_auth_seconds and no existing password; changing/removing verifies the current password. Removing the last enabled sign-in method is rejected. Changes revoke existing sessions and issue a fresh current session. Reset likewise revokes all sessions before signing the resetting browser in. Password changes, removal, and reset also invalidate outstanding one-time links for the identity. Reauthentication cannot switch an already authenticated request to another account.

Mutations reject safe HTTP methods and committed response headers. Headless applications must apply CSRF protection and must not consume links on GET. They must also filter credential fields from logs when payload decoding fails before invoking auth. Native auth routes omit request bodies from logging.

Views and notifications#

Bundled pages work without generated files. Override individual files under src/views/auth/: sign_in.html.ez, sign_up.html.ez, check_email.html.ez, confirm_link.html.ez, invalid_link.html.ez, forgot_password.html.ez, and reset_password.html.ez. Views receive @auth with mount, csrf, err, magic_link, password, and registration. POST forms include the csrf hidden field. Framework actions enforce security independently of template copy.

Keep enabled-method branches, escaped error output, and these form inputs when overriding bundled views:

POST target below the auth mountInputs
/magic-linkemail, csrf
/passwordemail, password, csrf
/sign-upemail, password, password_confirmation, csrf

Email inputs use a maximum length of 254; passwords accept 12–1024 bytes. Sign-in accepts a password field with maximum length 1024; account creation requires matching password fields. The sign-up page and its navigation link are present only when password registration is open. The bundled page is headed "Create account"; zyn init does not generate view overrides.

Overrides change markup, not route actions or security. They use the route's layout when configured. Test overridden views for enabled forms, error output, CSRF fields, and registration-only navigation. Opening an emailed link never signs in on GET; the confirmation POST consumes it. The bundled sign-out action is POST /auth/sign-out and requires its auth CSRF field.

Default notification names are auth_magic_link, auth_verify_email, and auth_password_reset. Override their .email.txt.ez and optional .email.html.ez files under src/notifications/. All receive @link with email, purpose, url, and expires_at (Unix seconds). Existing email transport configuration and notification retries apply. Default handlers skip links already expired before rendering. Frozen transport retries can still deliver an expired link; retrying never extends its validity.

For behavior overrides, reference callbacks directly:

.notifications = .{
    .magic_link = notifications.sign_in_link,
    .verification = notifications.verify_email,
    .password_reset = notifications.reset_password,
},

Callbacks have signature fn (*zyn.Notification, zyn.auth.Link) !void. They retain the reserved auth notification names and template names, and must not be registered a second time. Auth does not select delivery providers.

Persistence and lifecycle#

Zyn owns users, identities, sessions, and one_time_tokens in the reserved zyn schema. Applications may reference zyn.users(id) but do not modify auth rows. Internal row types and PostgreSQL enums are generated through the normal database generator and excluded from application db/types.zig.

Emails are trimmed, validated as ASCII mailbox identifiers, and lowercased; dots and plus tags are preserved. Values use database encryption and blind indexes; bearer tokens use hashes of random secrets. Tokens retain their original destination and purpose. Unknown magic-link users are created only after proof. Unverified password hashes belong to verification tokens and are not activated by magic-link authentication.

Token issuance and notification enqueueing commit together. GET stages a link in an encrypted cookie and redirects to a token-free confirmation page; POST consumes it atomically. Replays, expiration, supersession, and purpose mismatches are rejected. Request auth is distinct from application Session state.

Maintenance prunes terminal tokens and sessions through the existing job pruning path. Expiry checks do not depend on maintenance running. Auth requires a separate application rate-limiting policy: add .rate_limit to the mounted auth routes or configure an edge policy. No rate limit is enabled for auth by default. The application limiter does not replace edge traffic protection.

High-entropy tokens#

zyn.auth.token.generate(action.io) returns a 33-character Base62 token backed by 24 bytes of operating-system entropy without allocating. digest returns the raw SHA-256 digest of a high-entropy token:

const token = try zyn.auth.token.generate(action.io);
const token_digest = zyn.auth.token.digest(token[0..]);
try action.db.exec(
    "INSERT INTO access_tokens (digest) VALUES ($1)",
    .{token_digest},
);

Return the token once and store only its digest. These helpers are independent of managed authentication. They are not password hashing; use managed authentication for human-chosen passwords.