# Spry Spry is a library of **extensions to Statum**: authentication and user-management services plus ready-made, subclassable Statum actions. Spry no longer renders anything — pages, layouts and static resources are Statum's job, and auth UI arrives as app-owned templates generated by the `spry` CLI (`spry add login`, `spry add register`, `spry add user-management`). ## 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 ``` ## Module usage ```vala var application = new Astralis.Application(...); application.add_module(); application.add_module(); // UserService + schema + migrations var statum = application.configure_with(); SpryAuth.register_actions(statum); // or hand-pick with statum.action() ``` `SpryModule` registers no endpoints. It wires the M0001 migration (`spry_users`, `spry_user_permissions`), the `UserEntity`/`UserPermissionEntity` mappings, the `UserProjection` projection and the scoped `UserService`. Seed an admin at startup (idempotent — safe on every boot): ```vala var scope = application.container.create_scope(); yield SpryAuth.ensure_admin(scope.resolve(), "admin", "admin@example.com", "change-me"); ``` `ensure_admin` looks the account up by username (not a paged listing), so it stays correct past any user count, and grants `admin` only when absent (`set_user_permission` is itself idempotent). ## The `auth` slot Identity is a signed Statum SESSION slot of type name `"auth"` — no cookies, no bearer tokens. It is written once at login (`SpryAuth.login`) and read on every request (`SpryAuth.current`). | Field | Location | Protection | Notes | |---|---|---|---| | `username` | public | signed (tamper-proof, browser-visible) | login name; bind for nav/guards | | `display_name` | public | signed | forename + surname, falling back to username | | `permissions` | public | signed | `string[]` of permission patterns; `admin`/`*` match everything, `prefix*` wildcards | | `logout` | public | signed | embedded `ActionDto` reference to `LogoutAction` for logout buttons | | `user_id` | private | **signed + encrypted** (never reaches the browser) | authoritative `spry_users.id` | Because `user_id` lives in the slot's encrypted private data, a stolen browser copy of the public data cannot impersonate another user, and because the whole slot is signed (static keys in `web-config.json`), sessions survive a server restart. ## Resolving identity and guarding handlers ```vala public class ProfileEntrypoint : StatumEntrypoint { protected UserService users = inject(); 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 ... } ... } } ``` - `SpryAuth.current(request)` → `CurrentUser` (`user_id`, `username`, `display_name`, `permissions`, `is_anonymous`, `has_permission(string)`). Anonymous when no `auth` slot is held. This is the cheap **display identity**: it reflects the signed slot snapshot minted at login, so a disabled/revoked account still resolves until its slot is cleared. - `SpryAuth.require_login(directives, request, users, redirect = "/login")` → `null` when the held slot's user id still maps to a live, enabled account, else a `navigate(redirect)` builder. Async — `yield` it. - `SpryAuth.require_permission(directives, request, users, permission, redirect = "/login")` → same shape, with the permission set **re-derived from the store**. - Guards are authoritative: they take the caller's `UserService` and re-validate on every guarded request, so disabling, deleting or revoking takes effect immediately (no stale login-time snapshot). A store failure fails closed (treated as logged out) after logging a warning. - Guards take the caller's `DirectiveBuilder` (from the framework-provided `directives()`) because `DirectiveBuilder` construction is internal to Statum; the builder-in/builder-out shape keeps chaining fluent. `has_permission` (and the guards' matcher `SpryAuth.permission_matches`) preserve the old `PermissionMatcher` semantics exactly: `admin` and `*` are super-user patterns, `prefix*` matches any permission with that prefix, everything else is an exact match. ## Shipped actions (Spry.Actions) All are `StatumAction` subclasses designed for subclassing: redirect URIs and every notification message are `protected virtual` properties, and `handle()` stays a thin delegate to `SpryAuth`/`UserService`. | Action | Form fields | Guard | |---|---|---| | `LoginAction` | `username`, `password` | — | | `RegisterAction` | `username`, `email`, `forename`, `surname`, `date_of_birth`, `password`, `confirm_password` | — | | `LogoutAction` | — | — | | `ChangePasswordAction` | `current_password`, `new_password`, `confirm_password` | logged-in | | `SetUserEnabledAction` | `enabled`; target from sealed private data or `user_id` | `require_permission` (default `admin`) | | `GrantPermissionAction` | `permission`; target from sealed private data or `user_id` | `require_permission` (default `admin`) | | `RevokePermissionAction` | `permission`; target from sealed private data or `user_id` | `require_permission` (default `admin`); removes only the matching rows | | `AlterUserAction` | `username`, `email`, `forename`, `surname`, `date_of_birth`, `enabled`, `new_password` (optional); target from sealed private data or `user_id` | `require_permission` (default `admin`); refuses self-alter | | `DeleteUserAction` | — (sealed private data or `user_id`) | `require_permission` (default `admin`); refuses self-delete | Register them all with `SpryAuth.register_actions(statum)`, or individually with `statum.action()` (e.g. your own `LoginAction` subclass with a different `landing_uri`). Register and Alter share one validator (`Spry.Actions.ProfileValidation`): username length, email shape, required names and a strict YYYY-MM-DD date of birth parse that rejects impossible calendar dates (31 February and friends) by round-tripping the parsed date. Both actions map uniqueness failures to the same friendly duplicate messages, so raw database errors never reach the client. ### Sealed row actions (user management) The five management actions read their target `user_id` from **sealed private data**: a row action reference authored with ```vala var target = new Spry.Actions.UserActionPrivate(); target.user_id = row_user.id; row.grant = action_registry.author_private(target); ``` carries the id in an encrypted `X-Statum-Private` blob sealed under the action's own namespace, which the shipped action decrypts directly (`Spry.Actions.UserActionPrivate` is the shared private model — no app-side subclasses needed). The plain `user_id` form field still works when a request carries no blob, so hand-written forms keep functioning. `ChangePasswordAction` verifies its `current_password` form field against the stored hash (`UserService.authenticate_user`) before writing the new password. ## The user store (`UserService`) Injected per-request (`inject()`); every method is `async` and `throws Error`: | Method | Behaviour | |---|---| | `authenticate_user(username, password)` | `UserProjection?` — `null` for unknown username, wrong password **or disabled account** (same generic failure, no enumeration side channel; unknown usernames burn a dummy hash check so both paths take comparable time) | | `get_user(user_id)` / `get_user_by_username(username)` | `UserProjection?` with the user's permission patterns | | `register_user(...)` | inserts and returns the new `UserEntity` | | `set_password`, `alter_user`, `set_user_enabled`, `delete_user` | throw `UserServiceError.USER_NOT_FOUND` for a stale id instead of crashing | | `set_user_permission(user_id, permission)` | idempotent — no duplicate row when already held | | `remove_user_permission(user_id, permission)` | single targeted delete of the matching rows (other permissions untouched) | | `clear_user_permissions`, `get_user_permissions`, `list_users` | bulk read/clear, unchanged | Guards (`SpryAuth.require_login`/`require_permission`) build on `get_user` for their per-request re-validation. ## The `spry` CLI `spry new ` scaffolds an application — including its USM packaging (`MANIFEST.usm` + `usm-scripts/` + `.usmignore`); `spry add page|action|resource` grows it inside marker blocks; `spry add login|register|user-management` adds auth UI bound to `Spry.Actions`; `spry keys` maintains the static key pairs; `spry deploy` builds the USM container image. `spry dev` is the development loop: build, run, watch — saving a file rebuilds and restarts the app (a failed build keeps the previous process running), and Statum's client-carried state makes restarts transparent to the browser. Run `spry --help` for the full command surface. ## Deployment `spry deploy` delegates to `usm manifest deploy` in the application directory: the application's `MANIFEST.usm`/`usm-scripts/` drive an in-container `usm install` where the system package manager resolves the platform libraries and toolchain and USM repositories resolve the Web-Stack, then the image is saved as `-.image.tar.xz` (`podman load -i` to import). ``` spry deploy [--exec CMD] ([--system SYSTEM] | [--spm SPM] [--base IMAGE]) [--repository FILE]... [--installer-url URL] [--no-build] [--usm FILE] ``` - `--exec CMD` (default ` 8080`) — the container entrypoint. - `--system SYSTEM` — target system: `fedora`, `debian`, `ubuntu`, `alpine` or `gentoo`. Expands to the system's `:latest` base image plus its SPM (`dnf`, `apt`, `apt`, `apk`, `emerge` respectively) and is therefore **mutually exclusive with `--spm` and an explicit `--base`** — combined invocations fail with an error naming the conflict. - `--spm SPM` — the system package manager wired into the image (`dnf|apt|apk|emerge|none`), passed through to `usm manifest deploy`. - `--base IMAGE` — the base image (default usm's `fedora:43`). - When none of `--system`/`--spm`/`--base` is given, spry deploys the fedora equivalent — `--spm dnf` with **no** `--base`, so usm's own fedora base constant keeps deciding the image and default deploys are unchanged from before the flags existed. - `--repository FILE` (repeatable) — resolve from exactly these `.usmr` repositories instead of the machine's configured set; a local Web-Stack repository is the usual choice while the canonical one is unavailable. - `--installer-url URL` — override the canonical USM installer source the image installs USM from; until that URL is hosted, its `file://` form carries a locally built installer into the image (the sanctioned local-testing path). - `--no-build` — stop after generating the deploy context. Keys are never packaged (`.usmignore` excludes `web-config.json`): run with ephemeral keys for plain page serving, or bind-mount the config read-only (`ASTRALIS_CONFIG_PATH` points the app at it) for full authentication. ## Migrating from old Spry | Old (≤ 0.1) | New (0.2) | |---|---| | `AuthorisationContext` (injected) | `SpryAuth.current(request)` | | `AuthorisationService`/`AuthorisationToken`, cookies | the signed `auth` slot; `SpryAuth.login(...)` | | `authenticate_user` returning a token | returns `UserProjection?` | | `LoginComponent`/`UserManagementComponent` UI | `spry add login` / `spry add user-management` CLI templates bound to `Spry.Actions` | | `AuthenticationModule` | `SpryModule` | | `PermissionMatcher` | `CurrentUser.has_permission` | | `CryptographyProvider`, `UserIdentityProvider`, continuations, `spry-mkssr`/`spry-mkconst`, htmx resources | deleted — Statum handles signing/encryption, pages, resources and client JS |