phase-3-plan.md 12 KB

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

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:

    protected TPublic? held<TPublic>(string type_name)  // maps held[type].@public → TPublic, or null
    

    (Mirrors the existing typed request_private.)

  • Typed set on DirectiveBuilder:

    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:

    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:

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:

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:

    {
    "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:

    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:

    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)

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