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
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");
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_idlives in the slot's encrypted private data and never reaches the browser; a stolen public copy cannot impersonate anyone. - Authoritative guards.
SpryAuth.require_loginandrequire_permissionre-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 virtualproperties. - 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.
UserServicewith idempotent permission grants, targeted revokes, and an authenticate path with no account-enumeration side channel. - The spry CLI.
spry newscaffolds a packaged application,spry addgrows it,spry keysmaintains key material,spry devis the watch loop,spry deploybuilds 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
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
Then compile against it with Vala's usual package mechanism:
valac --pkg spry-0.2 my-app.vala
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
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
...
}
...
}
}
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);
}
}
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);
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
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(fromMANIFEST.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.soandlibinvercargill-sql-inversion.so. - Source
- The repository
Tilo15/Spryon 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.