Web stack · Users at the door

Spry

Authentication and user management for Statum applications. Identity is a signed Statum slot — no cookies, no bearer tokens — with user ids sealed in encrypted private data, guards that re-validate against the store on every request, and a set of ready-made actions built for subclassing. The spry CLI scaffolds an application, maintains its keys and ships it as a container image.

Version
0.2
Licence
LGPL-3.0-or-later
Package
usm install spry
Language
Vala

Overview

Who is asking?

Spry answers the first question every application with users must answer, and it answers with the machinery Statum already provides rather than adding a parallel one. It renders nothing itself — pages, layouts and resources are Statum's job, and the auth UI arrives as app-owned templates generated by the CLI. Its place in the stack:

astralis                     HTTP request handling
└─ Statum                    pages, slots/state, directives, client JS
   └─ Spry                   THIS LIBRARY: users, auth slot, actions, guards
      ├─ Invercargill-Sql    spry_users / spry_user_permissions schema
      └─ Inversion           module + service wiring
The stack, from Spry's README.

Adding it to an application is one module; seeding an administrator at startup is idempotent, so the same code runs on every boot:

application.add_module<StatumModule>();
application.add_module<SpryModule>();      // UserService + schema + migrations

var statum = application.configure_with<StatumConfigurator>();
SpryAuth.register_actions(statum);          // or hand-pick with statum.action<T>()

var scope = application.container.create_scope();
yield SpryAuth.ensure_admin(scope.resolve<UserService>(),
    "admin", "admin@example.com", "change-me");
From Spry's README — SpryModule registers no endpoints; it wires the migration, entity mappings and the scoped UserService.

Features

What it hands you

  • The signed auth slot. Identity as a SESSION slot of type name "auth" — no cookies, no bearer tokens — written once at login and read on every request.
  • Encrypted user ids. The authoritative user_id lives in the slot's encrypted private data and never reaches the browser; a stolen public copy cannot impersonate anyone.
  • Authoritative guards. SpryAuth.require_login and require_permission re-validate against the store on every guarded request — disabling, deleting or revoking takes effect immediately, and a store failure fails closed.
  • Subclassable actions. Nine shipped actions — login, register, logout, password change, enable, grant, revoke, alter, delete — whose redirect URIs and notification messages are protected virtual properties.
  • Sealed row actions. User-management actions read their target id from encrypted private data authored per row, so management buttons cannot be aimed at another account.
  • A user store. UserService with idempotent permission grants, targeted revokes, and an authenticate path with no account-enumeration side channel.
  • The spry CLI. spry new scaffolds a packaged application, spry add grows it, spry keys maintains key material, spry dev is the watch loop, spry deploy builds the container image.

Installation

Getting the library

With USM on your machine, one command acquires, builds and installs the library, the spry CLI, the C header, Vala binding and introspection:

usm install spry
Installs from the Astrologue package repository.

From source, the build lives at the repository root:

git clone <the Spry repository>
cd Spry
meson setup . build
ninja -C build
ninja -C build install
The same meson invocation the package's own build script uses.

Then compile against it with Vala's usual package mechanism:

valac --pkg spry-0.2 my-app.vala
The installed spry-0.2.deps resolves the Statum, Astralis, Invercargill-Sql and Inversion bindings for you.

Usage & documentation

Identity, guards, actions

Spry's README is its documentation, and it is a thorough one — the stack diagram, every action's form fields and guards, the user store's contract, and the CLI's full command surface. These idioms are lifted from it.

The auth slot

The slot's public fields are signed and browser-visible — bind them for navigation and guards. The private field is signed and encrypted, and never reaches the browser:

username       public   signed    login name; bind for nav/guards
display_name   public   signed    forename + surname, falling back to username
permissions    public   signed    string[] patterns: admin/* match everything
logout         public   signed    embedded ActionDto for logout buttons
user_id        private  signed +  authoritative spry_users.id — never
                        encrypted reaches the browser
The auth slot, field by field, from Spry's README.

Because the whole slot is signed under static keys in web-config.json, a session survives a server restart; because user_id is sealed, editing a browser-side copy gains nothing.

Reading identity, guarding handlers

public class ProfileEntrypoint : StatumEntrypoint {

    protected UserService users = inject<UserService>();

    public override async DirectiveBuilder handle() throws GLib.Error {
        var guard = yield SpryAuth.require_login(directives(), request, users);
        if (guard != null) {
            return (!)guard;                       // navigate("/login")
        }

        var me = SpryAuth.current(request);
        if (!me.has_permission("reports.read")) {  // admin / users.* wildcards work
            ...
        }
        ...
    }
}
From Spry's README — the guard takes the caller's builder and the caller's UserService.

SpryAuth.current returns the cheap display identity — the signed snapshot minted at login, anonymous when no slot is held — while the guards are authoritative: require_login answers null only when the held slot's user id still maps to a live, enabled account, and require_permission additionally re-derives the permission set from the store. Permission patterns keep their exact older semantics: admin and * match everything, prefix* matches any permission with that prefix, everything else is exact.

The shipped actions

Nine actions cover the standard account surface. Each is a StatumAction subclass designed for subclassing — a different landing page or friendlier message is one override away:

public class LoginAction : StatumAction {

    private UserService users = inject<UserService>();

    /** URI navigated to after a successful login. */
    protected virtual string landing_uri { get { return "/"; } }

    /** Notification shown when the credentials do not match an account. */
    protected virtual string invalid_credentials_message {
        get { return "Invalid username or password"; }
    }

    public override async DirectiveBuilder handle() throws GLib.Error {
        ...
        var user = yield users.authenticate_user(username, password);
        if (user == null) {
            return directives().notify("error", invalid_credentials_message);
        }

        return SpryAuth.login(directives(), (!)user, action_registry)
            .navigate(landing_uri);
    }
}
From src/Actions/LoginAction.vala — unknown usernames and wrong passwords answer the same generic notify, so the response leaks no account existence.

The full set: LoginAction, RegisterAction, LogoutAction, ChangePasswordAction (logged-in), and — guarded by require_permission, default admin — SetUserEnabledAction, GrantPermissionAction, RevokePermissionAction, AlterUserAction (refuses self-alter) and DeleteUserAction (refuses self-delete). The five management actions read their target from sealed private data:

var target = new Spry.Actions.UserActionPrivate();
target.user_id = row_user.id;
row.grant = action_registry
    .author_private<Spry.Actions.GrantPermissionAction,
                    Spry.Actions.UserActionPrivate>(target);
From Spry's README — the id travels in an encrypted blob sealed under the action's own namespace.

Register and Alter share one validator — username length, email shape, required names, and a strict YYYY-MM-DD date-of-birth parse that rejects impossible calendar dates by round-tripping the parsed date. Both map uniqueness failures to the same friendly duplicate messages, so raw database errors never reach the client.

The user store

UserService is injected per request, every method async and throws Error. Its authenticate path returns null for an unknown username, a wrong password or a disabled account — the same generic failure each time, with a dummy hash check burned for unknown usernames so both paths take comparable time. Permission grants are idempotent (no duplicate row when already held); revocation is a single targeted delete of the matching rows; and stale ids raise UserServiceError.USER_NOT_FOUND instead of crashing.

The spry CLI

spry new my-app      # scaffold an application, packaged for USM
spry add page        # grow it inside marker blocks
spry add action
spry add resource
spry add login       # auth UI bound to Spry.Actions
spry add register
spry add user-management
spry keys            # maintain the static key pairs
spry dev             # build, run, watch — a failed build keeps the old process
spry deploy          # build the USM container image
The command surface, from Spry's README — spry --help lists it in full.

spry new includes the USM packaging — MANIFEST.usm, usm-scripts/, .usmignore — so a generated application is installable from birth. spry deploy delegates to usm manifest deploy: the application's manifest drives an in-container install where the system package manager resolves the platform libraries and USM repositories resolve the Web-Stack, and the image is saved as <app>-<version>.image.tar.xz for podman load -i. Target systems include Fedora, Debian, Ubuntu, Alpine and Gentoo. Keys are never packaged — .usmignore excludes web-config.json — so an image serves plain pages with ephemeral keys, or full authentication when the config is bind-mounted read-only.

Package & source

The ledger

Version
0.2 (from MANIFEST.usm)
Licence
LGPL-3.0-or-later — free to run, study, share and improve; the LGPL keeps it free to link from any program.
Package
usm install spry
Provides
libspry-0.2.so, spry (the CLI), spry-0.2.h, spry-0.2.vapi + .deps, spry-0.2.gir, spry-0.2.pc
Needs at runtime
GLib and GObject, json-glib, libsodium, plus the stack beneath: libinvercargill-1.so, libinversion-0.1.so, libastralis-0.1.so, libstatum-0.1.so, libinvercargill-sql.so and libinvercargill-sql-inversion.so.
Source
The repository Tilo15/Spry on the Astrologue forge (git over ssh, port 222). Free software: the licence guarantees your right to the source.

One honest gap: the package ships a GIR but no typelib. Spry's migration classes derive from a class in a nested namespace of Invercargill-Sql, and the Vala 0.56 GIR writer drops the intermediate namespace segment when naming a parent from another library — so g-ir-compiler cannot resolve the parent type and the typelib cannot be built. The GIR is installed, and the packaging records the blocker; Vala and C consumers are unaffected.