|
|
@@ -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.
|