Parcourir la source

Phase 3 checkin

clanker il y a 2 semaines
Parent
commit
0bef276ed2

+ 15 - 47
example/Actions.vala

@@ -6,7 +6,7 @@ namespace Example {
 
     /**
      * `POST /_statum/action/{guid}` — logs in: reads the submitted name, builds a
-     * session `auth` slot (with a typed private `is_admin` flag) carrying a
+     * session `auth` slot from typed models (public + private), embedding a
      * `logout` action reference, and redirects home.
      */
     public class LoginAction : StatumAction {
@@ -21,27 +21,23 @@ namespace Example {
             }
             var is_admin = name == "admin";
 
-            var auth_public = new PropertyDictionary();
-            auth_public.set_native<string>("name", name);
-            auth_public.set_native<string>("role", is_admin ? "admin" : "member");
-            auth_public["logout"] = action_registry.author<LogoutAction>().to_element();
+            var pub = new AuthPublic();
+            pub.name = name;
+            pub.role = is_admin ? "admin" : "member";
+            pub.logout = action_registry.author<LogoutAction>();
 
-            var auth_private = new PropertyDictionary();
-            auth_private.set_native<bool>("is_admin", is_admin);
+            var priv = new AuthPrivate();
+            priv.is_admin = is_admin;
 
-            var slot = state_service.new_slot(Scope.SESSION, new State() {
-                type_name = "auth",
-                public_data = auth_public,
-                private_data = auth_private
-            });
-
-            return directives().set(slot).navigate("/");
+            return directives()
+                .set_private_typed<AuthPublic, AuthPrivate>("auth", Scope.SESSION, pub, priv)
+                .navigate("/");
         }
     }
 
     /**
-     * `POST /_statum/action/{guid}` — logs out: clears the held `auth` slot
-     * (server cache + clear directive) and redirects home.
+     * `POST /_statum/action/{guid}` — logs out: clears the held `auth` slot and
+     * redirects home.
      */
     public class LogoutAction : StatumAction {
 
@@ -57,42 +53,14 @@ namespace Example {
 
     /**
      * `POST /_statum/action/{guid}` — bumps the counter by the typed private
-     * `delta`, updating the held `counter` slot (sign + auto-push to subscribed
-     * channels) and re-embedding the `bump` reference in the new public data.
+     * `delta`, using the typed {@link DirectiveBuilder.update_held} so the
+     * `bump` action reference is preserved automatically across the update.
      */
     public class BumpCounterAction : TypedStatumAction<CounterPrivate> {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var delta = request_private != null ? ((!)request_private).delta : 1;
-
-            HeldSlot held_counter;
-            if (!request.held.try_get("counter", out held_counter)) {
-                return directives().error("No counter slot held");
-            }
-
-            var slot = state_service.get_slot(held_counter.key);
-            if (slot == null || ((!)slot).current_state == null) {
-                return directives().error("Counter slot not found");
-            }
-
-            var current = ((!)slot).current_state;
-            var value = GObjectMapping.get_int(current.public_data, "value") ?? 0;
-
-            var new_public = new PropertyDictionary();
-            new_public.set_native<int>("value", value + delta);
-            var bump_private = new CounterPrivate();
-            bump_private.delta = delta;
-            new_public["bump"] = action_registry
-                .author_private<BumpCounterAction, CounterPrivate>(bump_private)
-                .to_element();
-
-            var new_state = new State() {
-                type_name = "counter",
-                public_data = new_public,
-                private_data = new PropertyDictionary()
-            };
-
-            return directives().update(held_counter.key, new_state);
+            return directives().update_held<CounterPublic>("counter", c => c.value += delta);
         }
     }
 }

+ 35 - 33
example/HomeEntrypoint.vala

@@ -4,56 +4,58 @@ using Invercargill.DataStructures;
 
 namespace Example {
 
-    /**
-     * Private data carried by the bump-counter action (strongly typed). The
-     * framework seals/reads it via GObject introspection — no mapper.
-     */
+    // Typed slot models — GObject properties mapped to/from Properties via
+    // GObjectMapping. ActionDto fields round-trip as nested JSON.
+
     public class CounterPrivate : Object {
         public int delta { get; set; }
     }
 
+    public class CounterPublic : Object {
+        public int value { get; set; }
+        public Model.ActionDto bump { get; set; }
+    }
+
+    public class PagePublic : Object {
+        public Model.ActionDto login { get; set; }
+    }
+
+    public class AuthPublic : Object {
+        public string name { get; set; }
+        public string role { get; set; }
+        public Model.ActionDto logout { get; set; }
+    }
+
+    public class AuthPrivate : Object {
+        public bool is_admin { get; set; }
+    }
+
     /**
-     * Entrypoint for the home page.
-     *
-     * Demonstrates authoring action references (one with typed private data) and
-     * embedding them in state so the client can invoke them via `stm-action`,
-     * then returning them with the fluent directive builder.
+     * Entrypoint for the home page: hydrates counter + page slots with typed
+     * models, embedding action references as fields.
      */
     public class HomeEntrypoint : StatumEntrypoint {
 
         public override async DirectiveBuilder handle() throws GLib.Error {
-            // A counter slot whose public data carries a `bump` action reference
-            // sealed with typed private data ({delta: 1}).
-            var counter_public = new PropertyDictionary();
-            counter_public.set_native<int>("value", 0);
             var bump_private = new CounterPrivate();
             bump_private.delta = 1;
-            counter_public["bump"] = action_registry
-                .author_private<BumpCounterAction, CounterPrivate>(bump_private)
-                .to_element();
-            var counter_slot = state_service.new_slot(Scope.PAGE, new State() {
-                type_name = "counter",
-                public_data = counter_public,
-                private_data = new PropertyDictionary()
-            });
+            var bump_ref = action_registry.author_private<BumpCounterAction, CounterPrivate>(bump_private);
+
+            var counter = new CounterPublic();
+            counter.value = 0;
+            counter.bump = bump_ref;
 
-            // A page slot whose public data carries a private-less `login`
-            // action reference.
-            var page_public = new PropertyDictionary();
-            page_public["login"] = action_registry.author<LoginAction>().to_element();
-            var page_slot = state_service.new_slot(Scope.PAGE, new State() {
-                type_name = "page",
-                public_data = page_public,
-                private_data = new PropertyDictionary()
-            });
+            var page = new PagePublic();
+            page.login = action_registry.author<LoginAction>();
 
-            return directives().set(counter_slot).set(page_slot);
+            return directives()
+                .set_typed<CounterPublic>("counter", Scope.PAGE, counter)
+                .set_typed<PagePublic>("page", Scope.PAGE, page);
         }
     }
 
     /**
-     * Entrypoint for the guarded dashboard page. The page is protected by a
-     * client-side `stm-guard`; the entrypoint just greets the held `auth` slot.
+     * Entrypoint for the guarded dashboard page.
      */
     public class DashboardEntrypoint : StatumEntrypoint {
 

+ 253 - 0
plans/phase-3-plan.md

@@ -0,0 +1,253 @@
+# Phase 3 — Typed slot models, static keys, route templates, hard-fail action private
+
+> Named phase-3 to avoid clobbering the already-implemented `phase-2-plan.md`
+> (Actions API). Rename if you prefer.
+
+## Goals
+
+1. **Typed slot models** — author and read a slot's public (and private) data as
+   ordinary GObject models, including action-reference fields, with typed held
+   access and typed set/update on the directive builder.
+2. **Static signing/encryption keys** — configure stable key pairs via Astralis'
+   `web-config.json` so frames/private blobs survive a server restart.
+3. **`<pstm-uri>` templates** — routes like `/cats/{species}/details`, with
+   entrypoints reading the captured values (typed) like Astralis route params.
+4. **Hard-fail action private** — when an action's `X-Statum-Private` can't be
+   decrypted/verified, return an `error` directive and do **not** run the handler.
+5. **Author public state from a GObject model** — covered by (1).
+
+### Decisions (confirmed)
+
+- **Static keys**: explicit secret+public key pairs in a `statum` section of
+  `web-config.json`; a `statum-genkeys` tool prints them; **no libsodium vapi
+  change** (the providers accept configured keys instead of generating).
+- **Typed models**: full — models may declare action-ref (`ActionDto`) fields that
+  round-trip as nested JSON. `GObjectMapping` gains a `Type → mapper` registry so
+  `Object`-typed properties (ActionDto, and any app-registered model) map
+  recursively; primitives stay boilerplate-free.
+- **Bad private**: for `TypedStatumAction<TPrivate>`, an absent or undecryptable
+  `X-Statum-Private` yields an `error` directive (message
+  "Action private data could not be verified", code `"bad_private"`), overridable
+  via a virtual member; the handler is not called. Plain `StatumAction` is
+  unaffected.
+
+## Design
+
+### A. `GObjectMapping` registry (nested objects) — `src/GObjectMapping.vala`
+
+Today `to_properties`/`from_properties` only handle GValue-primitive properties.
+Extend them so an `Object`-typed property whose `Type` is **registered** maps via
+that type's `PropertyMapper`:
+
+- Add a non-generic adapter + registry:
+  ```
+  internal interface ObjectPropertyMapper : Object {
+      public abstract Properties map_from_object(Object o) throws GLib.Error;
+      public abstract Object materialise(InvercargillJson.JsonObject o) throws GLib.Error;
+  }
+  public static void register_mapper<T>(PropertyMapper<T> mapper)  // wraps in adapter, keyed by typeof(T)
+  ```
+- `to_properties`: when a property's `spec.value_type` is a registered `Object`
+  type, call `map_from_object(get_property value)` → `Properties` →
+  `JsonElement.from_properties(...)` and store that (mirrors today's manual
+  `props["bump"] = dto.to_element()`). Primitives unchanged.
+- `from_properties`: for a registered `Object` property, read the stored
+  `JsonElement`, `materialise` it, `set_property`. Primitives unchanged.
+- Unregistered `Object` properties: error at (de)serialise time (catches mistakes
+  early) rather than silently dropping.
+- The framework registers `Model.ActionDto`:
+  `GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper())`
+  in `StatumModule`.
+
+A model therefore declares action refs as typed fields:
+```vala
+public class CartPublic : Object {
+    public int value { get; set; }
+    public Model.ActionDto bump { get; set; }   // round-trips as a nested ActionDto
+}
+```
+
+### B. Typed slot models — `src/StatumHandlers.vala`, `src/DirectiveBuilder.vala`, `src/StateService.vala`
+
+Authoring & reading typed public/private data, all backed by `GObjectMapping`:
+
+- **Typed held access** on `StatumRequest`:
+  ```vala
+  protected TPublic? held<TPublic>(string type_name)  // maps held[type].@public → TPublic, or null
+  ```
+  (Mirrors the existing typed `request_private`.)
+- **Typed set** on `DirectiveBuilder`:
+  ```vala
+  directives().set<TPublic>(string type, Scope scope, TPublic pub)
+  directives().set<TPublic, TPrivate>(string type, Scope scope, TPublic pub, TPrivate priv)
+  ```
+  These map the model(s) to `Properties` (private via `encryption_provider`),
+  `state_service.new_slot` + sign, and emit a `set` — collapsing today's
+  `PropertyDictionary` + `set_native` + `new State` + `sign_slot` + `.set(slot)`.
+- **Typed update** on `DirectiveBuilder`:
+  ```vala
+  directives().update_held<TPublic>(string type, owned UpdateMutator<TPublic> mutator)
+  public delegate void UpdateMutator<T>(T current);
+  ```
+  Reads the held slot, maps its current public data → `TPublic` (action-ref
+  fields included, via the registry), runs the mutator, maps back to
+  `Properties`, and `state_service.update` (sign + auto-push) → `set`. If the
+  slot isn't held, an `error` directive is emitted instead.
+- Keep the existing untyped `.set(Slot)` / `.update(key, State)` for advanced use.
+
+The bump-counter action becomes:
+```vala
+public class CounterPublic : Object {
+    public int value { get; set; }
+    public Model.ActionDto bump { get; set; }
+}
+public override async DirectiveBuilder handle() throws GLib.Error {
+    var delta = request_private != null ? ((!)request_private).delta : 1;
+    return directives().update_held<CounterPublic>("counter", c => c.value += delta);
+}
+```
+and the entrypoint:
+```vala
+var bump = action_registry.author_private<BumpCounterAction, CounterPrivate>(new CounterPrivate { delta = 1 });
+return directives().set<CounterPublic>("counter", Scope.PAGE, new CounterPublic { value = 0, bump = bump });
+```
+
+### C. Static keys — `src/Cryptography/*.vala`, `src/Statum.vala`, `tools/statum-genkeys/`
+
+- `SigningProvider.with_keys(uint8[] secret_key, uint8[] public_key)` (sets both,
+  `signer_id = Base64.encode(public_key)`); the parameterless `construct` keeps
+  generating for dev/no-config.
+- `EncryptionProvider.with_keys(signing_sk, signing_pk, sealing_sk, sealing_pk)`.
+- `StatumModule` injects Astralis' `WebConfig`, reads the `statum` section; if the
+  keys are present it constructs the providers with them, otherwise generates and
+  logs a warning ("no static keys configured; frames/private blobs will not
+  survive a restart"). Keys are base64 in `web-config.json`:
+  ```json
+  {
+    "statum": {
+      "signing_secret_key": "...", "signing_public_key": "...",
+      "encryption_signing_secret_key": "...", "encryption_signing_public_key": "...",
+      "encryption_sealing_secret_key": "...", "encryption_sealing_public_key": "..."
+    }
+  }
+  ```
+- **`statum-genkeys`** tool (mirrors `statum-mkres`): generates the three
+  keypairs (Ed25519 signing; Ed25519 + X25519 for encryption) and prints the six
+  base64 values plus a ready-to-paste `statum` config block. Wired in
+  `tools/meson.build` (`install: true`).
+
+### D. `<pstm-uri>` templates — verify + typed access — `src/StatumRequest.vala`, `src/EntrypointEndpoint.vala`
+
+Route-template matching already works (the configurator derives the route from
+`<pstm-uri>`; `EntrypointRouteTable.match` extracts `{param}`; the entrypoint
+receives `request.route_params`). This task:
+
+- Verifies a page route `/cats/{species}/details` serves the static page on
+  navigation **and** that `/_statum/entrypoint?uri=/cats/Tabby/details` captures
+  `species=Tabby` into `request.route_params`.
+- Adds typed route-param access on `StatumRequest`:
+  ```vala
+  protected string? route(string name)                 // raw
+  protected T? route<T>(string name)                   // parsed (int/double/bool/string)
+  ```
+- Documents `<pstm-uri>` templates in `model.md` (HTML API + entrypoint access).
+
+### E. Hard-fail action private — `src/StatumHandlers.vala`
+
+- `TypedStatumAction<TPrivate>` gains overridable members:
+  ```vala
+  protected virtual string private_error_message { get { return "Action private data could not be verified"; } }
+  protected virtual string? private_error_code   { get { return "bad_private"; } }
+  ```
+- `StatumAction.handle_request` decrypts `X-Statum-Private` under the action's
+  namespace; for a `TypedStatumAction`, if the header is absent **or** decryption
+  fails, it returns `directives().error(private_error_message, private_error_code)`
+  and does **not** call `handle()`. (The current swallow-to-empty behaviour is
+  removed for typed actions.) Plain `StatumAction` (no typed private) keeps
+  reading `request.action_private` as before.
+
+## Tasks
+
+### Subsystem A — Nested-object mapping
+- **A1** `ObjectPropertyMapper` adapter + `GObjectMapping.register_mapper<T>` and
+  recursive `to_properties`/`from_properties` for registered `Object` properties
+  (error on unregistered `Object` properties). Unit-test the round-trip for a
+  primitive+`ActionDto`-field model.
+- **A2** `StatumModule` registers `Model.ActionDto`.
+
+### Subsystem B — Typed models
+- **B1** `StatumRequest.held<TPublic>(type)` typed accessor.
+- **B2** `DirectiveBuilder.set<TPublic>` / `set<TPublic,TPrivate>` /
+  `update_held<TPublic>(mutator)` (+ `UpdateMutator<T>` delegate).
+- **B3** Migrate the example (`HomeEntrypoint`, `BumpCounterAction`,
+  `LoginAction`) to typed models (`CounterPublic`, `AuthPublic`, …) and confirm
+  the bump action preserves its `bump` ref across updates.
+
+### Subsystem C — Static keys
+- **C1** `SigningProvider.with_keys` / `EncryptionProvider.with_keys`.
+- **C2** `StatumModule` reads `WebConfig` `statum` section; static-or-generate
+  with a warning.
+- **C3** `statum-genkeys` tool + `tools/meson.build`.
+- **C4** Document the `web-config.json` keys + restart-stable-frame behaviour.
+
+### Subsystem D — Route templates
+- **D1** Verify `{param}` page serving + entrypoint capture (add a templated
+  example page if useful).
+- **D2** `StatumRequest.route<T>(name)` typed accessor.
+- **D3** Document `<pstm-uri>` templates in `model.md`.
+
+### Subsystem E — Hard-fail private
+- **E1** `TypedStatumAction` overridable error members; `handle_request`
+  short-circuits to an `error` directive on absent/undecryptable private.
+- **E2** Test: an action invoked without/with a bad `X-Statum-Private` returns
+  the error and does not run the handler; a valid one runs.
+
+## API sketch (end-to-end)
+
+```vala
+// Public model with a typed action reference
+public class CartPublic : Object {
+    public int value { get; set; }
+    public Model.ActionDto bump { get; set; }
+}
+public class CartPrivate : Object {
+    public int delta { get; set; }
+}
+
+// Entrypoint: typed set, action ref authored and embedded as a field
+public class CartEntrypoint : StatumEntrypoint {
+    public override async DirectiveBuilder handle() throws GLib.Error {
+        var bump = action_registry.author_private<BumpAction, CartPrivate>(new CartPrivate { delta = 1 });
+        return directives().set<CartPublic>("cart", Scope.PAGE, new CartPublic { value = 0, bump = bump });
+    }
+}
+
+// Action: typed private + typed update (action ref preserved automatically)
+public class BumpAction : TypedStatumAction<CartPrivate> {
+    public override async DirectiveBuilder handle() throws GLib.Error {
+        var delta = request_private != null ? ((!)request_private).delta : 1;
+        return directives().update_held<CartPublic>("cart", c => c.value += delta);
+    }
+}
+
+// Reading a held slot, typed, in any handler
+CartPublic cart = request.held<CartPublic>("cart");
+string species = request.route<string>("species");   // /cats/{species}/details
+```
+
+## Breaking changes (pre-release API)
+
+- `TypedStatumAction` now **errors** on bad/absent private instead of yielding an
+  empty model — handlers that relied on `request_private == null` for the
+  "no private sent" case must instead be plain `StatumAction` if private is
+  genuinely optional.
+- New typed builder methods are additive; the untyped `.set(Slot)`/`.update` stay.
+- Static keys are opt-in (absent config ⇒ today's generated-key behaviour, with a
+  warning).
+
+## Out of scope / future
+
+- Key rotation / multiple signer keysets (verify against any configured signer,
+  not just the local one) — `verify_frame` currently checks only the local key.
+- A non-generic `Properties` literal/builder for ad-hoc (untyped) data.
+- Meson per-page helper to cut `custom_target` boilerplate.

+ 13 - 0
src/Cryptography/EncryptionProvider.vala

@@ -50,6 +50,19 @@ namespace Statum.Cryptography {
             Sodium.Asymmetric.Sealing.generate_keypair(sealing_public_key, sealing_secret_key);
         }
 
+        /**
+         * Constructs the provider with explicit key pairs (e.g. read from static
+         * configuration so private blobs survive a restart).
+         */
+        public EncryptionProvider.with_keys(
+                uint8[] signing_secret_key, uint8[] signing_public_key,
+                uint8[] sealing_secret_key, uint8[] sealing_public_key) {
+            this.signing_secret_key = signing_secret_key;
+            this.signing_public_key = signing_public_key;
+            this.sealing_secret_key = sealing_secret_key;
+            this.sealing_public_key = sealing_public_key;
+        }
+
         /**
          * Serialises a {@link Properties} object to compact JSON, signs and
          * seals it, and returns the opaque base64 string for a DTO's `private`

+ 11 - 0
src/Cryptography/SigningProvider.vala

@@ -102,6 +102,17 @@ namespace Statum.Cryptography {
             }
         }
 
+        /**
+         * Constructs the provider with an explicit key pair (e.g. read from
+         * static configuration so frames survive a restart). The `signer_id` is
+         * derived from the public key.
+         */
+        public SigningProvider.with_keys(uint8[] secret_key, uint8[] public_key) {
+            this.secret_key = secret_key;
+            this.public_key = public_key;
+            signer_id = Base64.encode(public_key);
+        }
+
         /**
          * The Ed25519 public key bytes that correspond to this provider's
          * secret key, for distribution to verifiers.

+ 70 - 22
src/DirectiveBuilder.vala

@@ -4,34 +4,18 @@ using Invercargill.Mapping;
 
 namespace Statum {
 
-    /**
-     * Fluent builder for the directive list an entrypoint/action returns.
-     *
-     * Started via {@link StatumEntrypoint.directives}/{@link StatumAction.directives}
-     * (bound to the handler's {@link StateService}). Each mutator returns the
-     * builder, so directives chain:
-     *
-     * ```
-     * return directives()
-     *     .set(slot)            // fresh slot: sign_slot -> set
-     *     .update(key, state)   // mutate + auto-push to channels + sign -> set
-     *     .clear(other_key)
-     *     .notify("info", "Saved")
-     *     .navigate("/");
-     * ```
-     *
-     * `set`/`update` are intents recorded in declaration order; pure directives
-     * are stored inline. {@link build} is `async` and materialises the intents in
-     * order (`update` yields to push to subscribed channels; `set` calls
-     * `sign_slot`), preserving ordering, then returns the directives.
-     */
+    /** Mutator applied to a typed model during {@link DirectiveBuilder.update_held}. */
+    public delegate void UpdateMutator<T>(T current);
+
     public class DirectiveBuilder : Object {
 
         private StateService state_service;
+        private ReadOnlyAssociative<string, HeldSlot> held;
         private Series<Item> items = new Series<Item>();
 
-        internal DirectiveBuilder(StateService state_service) {
+        internal DirectiveBuilder(StateService state_service, ReadOnlyAssociative<string, HeldSlot> held) {
             this.state_service = state_service;
+            this.held = held;
         }
 
         /** Signs the (fresh) slot and emits a `set` directive for its frame. */
@@ -52,6 +36,70 @@ namespace Statum {
             return this;
         }
 
+        // ------------------------------------------------------------------
+        // Typed slot-model helpers (public/private authored from GObject models)
+        // ------------------------------------------------------------------
+
+        /** Creates a fresh slot from a typed public model and emits a `set`. */
+        public DirectiveBuilder set_typed<TPublic>(string type_name, Scope scope, TPublic pub) throws GLib.Error {
+            var state = new State() {
+                type_name = type_name,
+                public_data = GObjectMapping.to_properties((Object) pub),
+                private_data = new PropertyDictionary()
+            };
+            var slot = state_service.new_slot(scope, state);
+            items.add(new Item() { kind = ItemKind.SLOT, slot = slot });
+            return this;
+        }
+
+        /** Creates a fresh slot from typed public + private models and emits a `set`. */
+        public DirectiveBuilder set_private_typed<TPublic, TPrivate>(string type_name, Scope scope, TPublic pub, TPrivate priv) throws GLib.Error {
+            var state = new State() {
+                type_name = type_name,
+                public_data = GObjectMapping.to_properties((Object) pub),
+                private_data = GObjectMapping.to_properties((Object) priv)
+            };
+            var slot = state_service.new_slot(scope, state);
+            items.add(new Item() { kind = ItemKind.SLOT, slot = slot });
+            return this;
+        }
+
+        /**
+         * Reads the held slot `type_name`, maps its current public data to
+         * `TPublic` (action-ref fields included), applies `mutator`, maps back
+         * and {@link update}s the slot (sign + auto-push). Emits an `error`
+         * directive if the slot is not held or not cached.
+         */
+        public DirectiveBuilder update_held<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
+            HeldSlot held_slot;
+            if (!held.try_get(type_name, out held_slot) || held_slot == null) {
+                items.add(new Item() { kind = ItemKind.DIRECTIVE,
+                    directive = new Model.ErrorDirectiveDto() { message = @"No \"$type_name\" slot held" } });
+                return this;
+            }
+
+            var key = ((!)held_slot).key;
+            var slot = state_service.get_slot(key);
+            if (slot == null || ((!)slot).current_state == null) {
+                items.add(new Item() { kind = ItemKind.DIRECTIVE,
+                    directive = new Model.ErrorDirectiveDto() { message = @"Slot \"$type_name\" not found in cache" } });
+                return this;
+            }
+
+            var current = ((!)slot).current_state;
+            var model = GObjectMapping.from_properties_typed<TPublic>(typeof(TPublic),
+                current.public_data ?? new PropertyDictionary());
+            mutator(model);
+
+            var new_state = new State() {
+                type_name = current.type_name,
+                public_data = GObjectMapping.to_properties((Object) model),
+                private_data = current.private_data ?? new PropertyDictionary()
+            };
+            items.add(new Item() { kind = ItemKind.UPDATE, key = key, update_state = new_state });
+            return this;
+        }
+
         /** Emits a `clear` directive. */
         public DirectiveBuilder clear(string key) {
             items.add(new Item() { kind = ItemKind.DIRECTIVE, directive = new Model.ClearDirectiveDto.with_key(key) });

+ 89 - 9
src/GObjectMapping.vala

@@ -1,24 +1,58 @@
 using Invercargill;
 using Invercargill.DataStructures;
+using Invercargill.Mapping;
+using InvercargillJson;
 
 namespace Statum {
 
+    /**
+     * Non-generic adapter so heterogeneous {@link PropertyMapper}s can be stored
+     * in a single registry keyed by {@link Type}.
+     */
+    internal interface ObjectPropertyMapper : Object {
+        public abstract Properties map_from_object(Object o) throws GLib.Error;
+        public abstract Object materialise_object(JsonObject json) throws GLib.Error;
+    }
+
     /**
      * Maps between GObject instances and {@link Properties} using GObject
      * property introspection, so an application can declare a strongly-typed
-     * private-data model as an ordinary GObject property class (e.g.
-     * `public string order_id { get; set; }`) and have the framework seal/read it
+     * data model as an ordinary GObject property class (e.g.
+     * `public string order_id { get; set; }`) and have the framework map it
      * with no per-model mapper.
      *
-     * Reuses the same introspection pattern as Invercargill's
-     * `ObjectPropertyAccessor` (and {@link Element.to_value}/{@link ValueElement}
-     * for the value conversions).
+     * Primitives are handled via {@link Element.to_value}/{@link ValueElement}.
+     * `Object`-typed properties whose {@link Type} is registered (see
+     * {@link register_mapper}) are mapped recursively via their
+     * {@link PropertyMapper} — this is how typed action-reference fields (e.g.
+     * `public Model.ActionDto bump { get; set; }`) round-trip as nested JSON.
      */
     public class GObjectMapping : Object {
 
+        private static GLib.HashTable<Type, ObjectPropertyMapper> _mappers;
+
+        private static GLib.HashTable<Type, ObjectPropertyMapper> mappers() {
+            if (_mappers == null) {
+                _mappers = new GLib.HashTable<Type, ObjectPropertyMapper>(GLib.direct_hash, GLib.direct_equal);
+            }
+            return _mappers;
+        }
+
+        /**
+         * Registers a {@link PropertyMapper} for `T` so that `Object`-typed
+         * properties of that type are mapped recursively. Called once at startup
+         * for framework types (e.g. {@link Model.ActionDto}); apps may register
+         * their own nested model mappers.
+         */
+        public static void register_mapper<T>(PropertyMapper<T> mapper) {
+            mappers()[typeof(T)] = new MapperAdapter<T>(mapper);
+        }
+
         /**
          * Maps a GObject instance to a {@link Properties} object by reading its
-         * declared (readable) GObject properties.
+         * declared (readable) GObject properties. Registered `Object`-typed
+         * properties are mapped recursively; unregistered `Object` properties are
+         * skipped.
          */
         public static Properties to_properties(Object model) throws GLib.Error {
             var dict = new PropertyDictionary();
@@ -29,6 +63,21 @@ namespace Statum {
                 }
                 var value = Value(spec.value_type);
                 model.get_property(spec.name, ref value);
+
+                if (mappers().lookup(spec.value_type) != null) {
+                    var obj = value.get_object();
+                    if (obj != null) {
+                        var sub_props = (!)(mappers().lookup(spec.value_type)).map_from_object((!)obj);
+                        dict[spec.name] = new JsonElement.from_properties(sub_props);
+                    }
+                    continue;
+                }
+
+                if (spec.value_type.is_a(typeof(Object))) {
+                    // Unregistered Object property: skip (would not serialise).
+                    continue;
+                }
+
                 dict[spec.name] = new ValueElement(value);
             }
             return dict;
@@ -36,8 +85,8 @@ namespace Statum {
 
         /**
          * Builds a new instance of `type` and populates its (writable) GObject
-         * properties from `props`. Property names absent from `props` are left at
-         * their default.
+         * properties from `props`. Registered `Object`-typed properties are
+         * materialised recursively.
          */
         public static Object from_properties(Type type, Properties props) throws GLib.Error {
             var model = Object.new(type);
@@ -50,6 +99,18 @@ namespace Statum {
                 if (!props.try_get(spec.name, out element) || element == null) {
                     continue;
                 }
+
+                if (mappers().lookup(spec.value_type) != null) {
+                    try {
+                        var json_obj = ((!)element).as<JsonObject>();
+                        var obj = (!)(mappers().lookup(spec.value_type)).materialise_object(json_obj);
+                        model.set_property(spec.name, obj);
+                    } catch (GLib.Error e) {
+                        // Skip properties whose nested object cannot be materialised.
+                    }
+                    continue;
+                }
+
                 try {
                     var value = ((!)element).to_value(spec.value_type);
                     model.set_property(spec.name, value);
@@ -68,7 +129,7 @@ namespace Statum {
         }
 
         // ------------------------------------------------------------------
-        // Typed {@link Properties} getters (B4) — remove ad-hoc read boilerplate.
+        // Typed {@link Properties} getters — remove ad-hoc read boilerplate.
         // ------------------------------------------------------------------
 
         public static string? get_string(Properties props, string key) {
@@ -102,5 +163,24 @@ namespace Statum {
             }
             return null;
         }
+
+        /**
+         * Generic adapter wrapping a {@link PropertyMapper} for the registry.
+         */
+        private class MapperAdapter<T> : Object, ObjectPropertyMapper {
+            private PropertyMapper<T> mapper;
+
+            public MapperAdapter(PropertyMapper<T> mapper) {
+                this.mapper = mapper;
+            }
+
+            public Properties map_from_object(Object o) throws GLib.Error {
+                return mapper.map_from((T) o);
+            }
+
+            public Object materialise_object(JsonObject json) throws GLib.Error {
+                return (Object) mapper.materialise(json);
+            }
+        }
     }
 }

+ 48 - 2
src/Statum.vala

@@ -20,8 +20,14 @@ namespace Statum {
     public class StatumModule : Object, Module {
 
         public void register_components(Container container) throws Error {
-            container.register_singleton<Cryptography.SigningProvider>();
-            container.register_singleton<Cryptography.EncryptionProvider>();
+            // Enable recursive mapping of ActionDto fields on typed slot models.
+            GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
+
+            // Sign + encrypt with static keys from web-config.json ("statum"
+            // section) when configured, so frames/private blobs survive a
+            // restart. Fall back to generated keys (with a warning) otherwise.
+            container.register_singleton<Cryptography.SigningProvider>(scope => create_signing_provider(scope));
+            container.register_singleton<Cryptography.EncryptionProvider>(scope => create_encryption_provider(scope));
 
             container.register_singleton<StateService>();
             container.register_singleton<HeldSlotResolver>();
@@ -66,6 +72,46 @@ namespace Statum {
             container.register_startup<ClientWorker>().as<StatumResource>();
         }
 
+        private static Cryptography.SigningProvider create_signing_provider(Inversion.Scope scope) {
+            var section = read_statum_config(scope);
+            if (section != null) {
+                var sk = ((!)section).get_string("signing_secret_key");
+                var pk = ((!)section).get_string("signing_public_key");
+                if (sk.length > 0 && pk.length > 0) {
+                    return new Cryptography.SigningProvider.with_keys(Base64.decode(sk), Base64.decode(pk));
+                }
+            }
+            warning("[Statum] No static signing key configured; frames will not survive a restart.");
+            return new Cryptography.SigningProvider();
+        }
+
+        private static Cryptography.EncryptionProvider create_encryption_provider(Inversion.Scope scope) {
+            var section = read_statum_config(scope);
+            if (section != null) {
+                var s = (!)section;
+                var ssk = s.get_string("encryption_signing_secret_key");
+                var spk = s.get_string("encryption_signing_public_key");
+                var esk = s.get_string("encryption_sealing_secret_key");
+                var epk = s.get_string("encryption_sealing_public_key");
+                if (ssk.length > 0 && spk.length > 0 && esk.length > 0 && epk.length > 0) {
+                    return new Cryptography.EncryptionProvider.with_keys(
+                        Base64.decode(ssk), Base64.decode(spk),
+                        Base64.decode(esk), Base64.decode(epk));
+                }
+            }
+            warning("[Statum] No static encryption keys configured; private blobs will not survive a restart.");
+            return new Cryptography.EncryptionProvider();
+        }
+
+        private static Astralis.WebConfigSection? read_statum_config(Inversion.Scope scope) {
+            try {
+                var config = scope.resolve<Astralis.WebConfig>();
+                return config.get_section("statum");
+            } catch {
+                return null;
+            }
+        }
+
     }
 
     /**

+ 35 - 6
src/StatumHandlers.vala

@@ -39,9 +39,9 @@ namespace Statum {
          */
         public abstract async DirectiveBuilder handle() throws GLib.Error;
 
-        /** Starts a fluent {@link DirectiveBuilder} bound to the state service. */
+        /** Starts a fluent {@link DirectiveBuilder} bound to the state service and held slots. */
         protected DirectiveBuilder directives() {
-            return new DirectiveBuilder(state_service);
+            return new DirectiveBuilder(state_service, request.held);
         }
     }
 
@@ -78,15 +78,24 @@ namespace Statum {
          */
         protected virtual string private_namespace { get { return ACTION_PRIVATE_NAMESPACE; } }
 
+        /** Whether valid `X-Statum-Private` is required (true for typed actions). */
+        protected virtual bool requires_valid_private { get { return false; } }
+
+        /** Error message returned when private data can't be verified (overridable). */
+        protected virtual string private_error_message { get { return "Action private data could not be verified"; } }
+
+        /** Error code returned when private data can't be verified (overridable). */
+        protected virtual string? private_error_code { get { return "bad_private"; } }
+
         /**
          * Returns the directive builder for this action's response (e.g. a
          * refreshed `set` for a mutated slot, plus any `notify`/`navigate`).
          */
         public abstract async DirectiveBuilder handle() throws GLib.Error;
 
-        /** Starts a fluent {@link DirectiveBuilder} bound to the state service. */
+        /** Starts a fluent {@link DirectiveBuilder} bound to the state service and held slots. */
         protected DirectiveBuilder directives() {
-            return new DirectiveBuilder(state_service);
+            return new DirectiveBuilder(state_service, request.held);
         }
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
@@ -106,29 +115,46 @@ namespace Statum {
                 form = yield FormDataParser.parse(http_context.request.request_body, http_context.request.content_type);
             }
 
+            bool private_failed;
+            var action_private = decrypt_action_private(http_context, out private_failed);
+
             request = new StatumRequest() {
                 uri = route_context.requested_path,
                 route_params = route_context.mapped_parameters,
                 held = resolved.held,
                 query = http_context.request.query_params,
-                action_private = decrypt_action_private(http_context),
+                action_private = action_private,
                 form = form
             };
             scope.register_local_scoped<StatumRequest>(() => request);
 
+            // For typed actions, a missing or undecryptable X-Statum-Private is a
+            // hard failure: return an error directive and do not run the handler.
+            if (requires_valid_private && private_failed) {
+                var directives = new Series<Model.DirectiveDto>();
+                directives.add(new Model.ErrorDirectiveDto() {
+                    message = private_error_message,
+                    code = private_error_code
+                });
+                return StatumDirectives.to_result(directives, resolved.invalid_signatures);
+            }
+
             var builder = yield handle();
             var directives = yield builder.build();
             return StatumDirectives.to_result(directives, resolved.invalid_signatures);
         }
 
-        private Properties decrypt_action_private(HttpContext http_context) {
+        private Properties decrypt_action_private(HttpContext http_context, out bool failed) {
+            failed = false;
             var blob = http_context.request.get_header("X-Statum-Private");
             if (blob == null || ((!)blob).length == 0) {
+                failed = true;
                 return new PropertyDictionary();
             }
             try {
                 return encryption_provider.read_properties(private_namespace, (!)blob);
             } catch (GLib.Error e) {
+                failed = true;
                 return new PropertyDictionary();
             }
         }
@@ -151,6 +177,9 @@ namespace Statum {
      */
     public abstract class TypedStatumAction<TPrivate> : StatumAction {
 
+        /** Typed actions require valid `X-Statum-Private`; bad/absent → error directive. */
+        protected override bool requires_valid_private { get { return true; } }
+
         private TPrivate? _private_data = null;
         private bool _private_resolved = false;
 

+ 21 - 7
src/StatumRequest.vala

@@ -7,12 +7,6 @@ namespace Statum {
     /**
      * The per-request Statum context, shared by page, entrypoint and action
      * handlers.
-     *
-     * Carries the matched {@link uri} and {@link route_params}, the slots the
-     * client currently {@link held} (indexed by type name, public AND private),
-     * and the request {@link query}. Action handlers additionally populate
-     * {@link action_private} (decrypted from the `X-Statum-Private` header) and
-     * the parsed {@link form}.
      */
     public class StatumRequest : Object {
 
@@ -37,6 +31,26 @@ namespace Statum {
         /** Parsed form body for POST/PUT/PATCH actions, or `null`. */
         public FormData? form { get; set; }
 
+        /**
+         * Reads a held slot's public data as a typed model, or `null` if the slot
+         * is not held or cannot be mapped.
+         */
+        public TPublic? held_as<TPublic>(string type_name) {
+            HeldSlot slot;
+            if (!held.try_get(type_name, out slot) || slot == null) {
+                return null;
+            }
+            try {
+                return GObjectMapping.from_properties_typed<TPublic>(typeof(TPublic), ((!)slot).@public);
+            } catch (GLib.Error e) {
+                return null;
+            }
+        }
+
+        /** Raw route parameter (from a `<pstm-uri>` template like `/cats/{species}`), or `null`. */
+        public string? route(string name) {
+            return route_params.get_or_default(name);
+        }
     }
-
 }
+

+ 1 - 0
tools/meson.build

@@ -2,3 +2,4 @@
 
 subdir('statum-mkres')
 subdir('statum-mkpstm')
+subdir('statum-genkeys')

+ 9 - 0
tools/statum-genkeys/meson.build

@@ -0,0 +1,9 @@
+statum_genkeys_sources = files(
+    'statum-genkeys.vala'
+)
+
+executable('statum-genkeys',
+    statum_genkeys_sources,
+    dependencies: [glib_dep, gobject_dep, sodium_deps],
+    install: true
+)

+ 45 - 0
tools/statum-genkeys/statum-genkeys.vala

@@ -0,0 +1,45 @@
+using Sodium;
+
+namespace Statum.Tools {
+
+    /**
+     * `statum-genkeys` — generates the static key pairs for signing and
+     * encryption and prints a ready-to-paste `statum` section for
+     * `web-config.json`.
+     *
+     * With these keys configured, signed frames and encrypted private blobs
+     * survive a server restart (the same keys verify/decrypt across restarts).
+     */
+    public class Genkeys : Object {
+
+        public static int main(string[] args) {
+            // Signing (Ed25519) key pair.
+            var sign_sk = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+            var sign_pk = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Signing.generate_keypair(sign_pk, sign_sk);
+
+            // Encryption signing (Ed25519) key pair.
+            var esign_sk = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+            var esign_pk = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Signing.generate_keypair(esign_pk, esign_sk);
+
+            // Encryption sealing (X25519) key pair.
+            var seal_sk = new uint8[Sodium.Asymmetric.Sealing.SECRET_KEY_BYTES];
+            var seal_pk = new uint8[Sodium.Asymmetric.Sealing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Sealing.generate_keypair(seal_pk, seal_sk);
+
+            stdout.printf("{\n");
+            stdout.printf("  \"statum\": {\n");
+            stdout.printf("    \"signing_secret_key\": \"%s\",\n", Base64.encode(sign_sk));
+            stdout.printf("    \"signing_public_key\": \"%s\",\n", Base64.encode(sign_pk));
+            stdout.printf("    \"encryption_signing_secret_key\": \"%s\",\n", Base64.encode(esign_sk));
+            stdout.printf("    \"encryption_signing_public_key\": \"%s\",\n", Base64.encode(esign_pk));
+            stdout.printf("    \"encryption_sealing_secret_key\": \"%s\",\n", Base64.encode(seal_sk));
+            stdout.printf("    \"encryption_sealing_public_key\": \"%s\"\n", Base64.encode(seal_pk));
+            stdout.printf("  }\n");
+            stdout.printf("}\n");
+
+            return 0;
+        }
+    }
+}