# 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. **`` 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`, 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(PropertyMapper 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.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(string type_name) // maps held[type].@public → TPublic, or null ``` (Mirrors the existing typed `request_private`.) - **Typed set** on `DirectiveBuilder`: ```vala directives().set(string type, Scope scope, TPublic pub) directives().set(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(string type, owned UpdateMutator mutator) public delegate void UpdateMutator(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("counter", c => c.value += delta); } ``` and the entrypoint: ```vala var bump = action_registry.author_private(new CounterPrivate { delta = 1 }); return directives().set("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. `` templates — verify + typed access — `src/StatumRequest.vala`, `src/EntrypointEndpoint.vala` Route-template matching already works (the configurator derives the route from ``; `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(string name) // parsed (int/double/bool/string) ``` - Documents `` templates in `model.md` (HTML API + entrypoint access). ### E. Hard-fail action private — `src/StatumHandlers.vala` - `TypedStatumAction` 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` 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(type)` typed accessor. - **B2** `DirectiveBuilder.set` / `set` / `update_held(mutator)` (+ `UpdateMutator` 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(name)` typed accessor. - **D3** Document `` 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(new CartPrivate { delta = 1 }); return directives().set("cart", Scope.PAGE, new CartPublic { value = 0, bump = bump }); } } // Action: typed private + typed update (action ref preserved automatically) public class BumpAction : TypedStatumAction { public override async DirectiveBuilder handle() throws GLib.Error { var delta = request_private != null ? ((!)request_private).delta : 1; return directives().update_held("cart", c => c.value += delta); } } // Reading a held slot, typed, in any handler CartPublic cart = request.held("cart"); string species = request.route("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.