Tidak Ada Deskripsi

clanker 6f6e16e56f Update installer URL to packages.astrologue.nz 1 Minggu lalu
src 1ec844ecb1 feat!: replace SSR engine with Statum extensions - auth/user-management library, ready-made actions, spry CLI with templates and Docker support 1 Minggu lalu
tools 6f6e16e56f Update installer URL to packages.astrologue.nz 1 Minggu lalu
usm-scripts 57b13d92f0 feat: replace docker integration with USM deployment - spry deploy, MANIFEST scaffolding, repository passthrough and verbose flag 1 Minggu lalu
vapi 13c9f429a0 feat: add expression evaluation and declarative components to template system 5 bulan lalu
.gitignore 8a95cec455 refactor(core): rename spry-template-outlet to spry-content 5 bulan lalu
.usmignore 57b13d92f0 feat: replace docker integration with USM deployment - spry deploy, MANIFEST scaffolding, repository passthrough and verbose flag 1 Minggu lalu
MANIFEST.usm 27a31cbcf5 feat: deploy --system/--spm passthrough with per-system base images 1 Minggu lalu
README.md 6f6e16e56f Update installer URL to packages.astrologue.nz 1 Minggu lalu
meson.build 1ec844ecb1 feat!: replace SSR engine with Statum extensions - auth/user-management library, ready-made actions, spry CLI with templates and Docker support 1 Minggu lalu

README.md

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

var application = new Astralis.Application(...);

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>()

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):

var scope = application.container.create_scope();
yield SpryAuth.ensure_admin(scope.resolve<UserService>(),
    "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

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
            ...
        }
        ...
    }
}
  • 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<T>() (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

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);

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<UserService>()); 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 <name> 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 <app>-<version>.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 <app> 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 (https://packages.astrologue.nz/install-usm.sh) the image installs USM from; 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