using Inversion; using Invercargill.DataStructures; using Astralis; namespace Statum { /** Configuration-time errors (e.g. a page registered without a route). */ public errordomain StatumError { CONFIGURATION, } /** * Inversion module that registers the Statum runtime services and framework * endpoints. * * Register this module with the application container (after which a * {@link StatumConfigurator} binds pages/entrypoints/resources) to wire up * the {@link StateService}, signing/encryption providers, realtime channel, * entrypoint route table, held-slot resolver and the `/_statum/*` endpoints. */ public class StatumModule : Object, Module { public void register_components(Container container) throws Error { // Enable recursive mapping of ActionDto fields on typed slot models. GObjectMapping.register_mapper(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(scope => create_signing_provider(scope)); container.register_singleton(scope => create_encryption_provider(scope)); container.register_singleton(); container.register_singleton(); container.register_singleton(); container.register_singleton(); container.register_singleton(); // The realtime channel endpoint is the singleton ChannelService. container.register_singleton() .as() .as() .with_metadata(new EndpointRoute("/_statum/channel")); container.register_scoped() .as() .with_metadata(new EndpointRoute("/_statum/channel/{id}", Method.PATCH)); container.register_scoped() .as() .with_metadata(new EndpointRoute("/_statum/entrypoint")); container.register_scoped() .as() .with_metadata(new EndpointRoute("/_statum/slots", Method.POST)); // The single action endpoint resolves /_statum/action/{guid} to the // action type via ActionRegistry. Registered for all common verbs so // an action may be authored with whichever method suits it. container.register_scoped() .as() .with_metadata(new EndpointRoute("/_statum/action/{guid}", Method.GET, Method.POST, Method.PUT, Method.PATCH, Method.DELETE)); container.register_scoped() .as() .with_metadata(new EndpointRoute("/_statum/resource/{name}")); // Embed the Statum client scripts as default resources, served from // /_statum/resource/statum.js and /_statum/resource/statum-worker.js // (precompressed at build time). Apps get them for free and may add // their own resources via StatumConfigurator.add_resource. container.register_startup().as(); container.register_startup().as(); } 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] ────────────────────────────────────────────────────────────────────\n" + "[Statum] No static signing key configured (web-config.json, \"statum\" section).\n" + "[Statum] Frames are signed with an EPHEMERAL key:\n" + "[Statum] → every client-held slot (including sessions) is INVALIDATED\n" + "[Statum] the moment this process restarts.\n" + "[Statum] Run `statum-genkeys` and add the generated keys to web-config.json\n" + "[Statum] to make frames survive restarts.\n" + "[Statum] ────────────────────────────────────────────────────────────────────"); 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] ────────────────────────────────────────────────────────────────────\n" + "[Statum] No static encryption keys configured (web-config.json, \"statum\" section).\n" + "[Statum] Private blobs are sealed with EPHEMERAL keys:\n" + "[Statum] → action/snapshot private data becomes unreadable\n" + "[Statum] the moment this process restarts.\n" + "[Statum] Run `statum-genkeys` and add the generated keys to web-config.json\n" + "[Statum] to make private blobs survive restarts.\n" + "[Statum] ────────────────────────────────────────────────────────────────────"); return new Cryptography.EncryptionProvider(); } private static Astralis.WebConfigSection? read_statum_config(Inversion.Scope scope) { try { var config = scope.resolve(); return config.get_section("statum"); } catch { return null; } } } /** * Binds application pages, entrypoints and resources into the container. * * ``` * var statum = application.configure_with(); * statum.add_page(); * statum.add_static_page(); * statum.add_resource(); * ``` * * Each page's route is read from its generated {@link StatumPage.route} * (set at build time from its ``); no route is passed by hand. A * page without a `` (empty route) fails at startup. */ public class StatumConfigurator : Object { private Container container = inject(); private EntrypointRouteTable route_table = inject(); private ActionRegistry action_registry = inject(); /** Exposed for background tasks (timers, webhooks) that need to trigger topics. */ public TopicRegistry topic_registry = inject(); public StateService state_service = inject(); /** * Resolves a page's route from its {@link StatumPage.route} property, * throwing at startup when the page has no ``. */ private static EndpointRoute route_for() throws Error { var page = (StatumPage) Object.new(typeof(TPage)); var path = page.route; if (path == null || path.length == 0) { throw new StatumError.CONFIGURATION(@"Page %s has no (route is empty)", typeof(TPage).name()); } return new EndpointRoute(path); } /** * Registers a page AND its entrypoint. The page is served at its * `` route, and the entrypoint is bound to that route in the * {@link EntrypointRouteTable} so `/_statum/entrypoint?uri=…` dispatches * to it. */ public void add_page() throws Error { var route = route_for(); container.register_scoped() .as() .with_metadata(route); container.register_transient(); route_table.register(route, typeof(TEntrypoint)); } /** Registers a static page (no entrypoint) served at its `` route. */ public void add_static_page() throws Error { var route = route_for(); container.register_scoped() .as() .with_metadata(route); } /** Registers a {@link StatumResource} for serving from `/_statum/resource/{name}`. */ public void add_resource() { container.register_startup() .as(); } /** * Registers a global broadcast slot with a deterministic key * (`"global:" + type_name`). All clients that opt in (via * {@link DirectiveBuilder.set_global} / {@link DirectiveBuilder.subscribe_global}) * share the same slot and see the same updates. The initial value is * authored from a typed GObject model. */ public void global(string type_name, TPublic initial) throws GLib.Error { var state = new State() { type_name = type_name, public_data = GObjectMapping.to_properties((Object) initial), private_data = new PropertyDictionary() }; state_service.new_global_slot(type_name, Scope.PAGE, state); } /** * Registers a {@link StatumAction}, auto-assigning it a GUID endpoint at * `/_statum/action/{guid}` (Spry-style — no hand-picked URI). Invoke the * action from the client by embedding an authored reference in state (see * {@link ActionRegistry.author}) and binding it with `stm-action`. */ public void action() { container.register_scoped(); action_registry.register(); } /** Registers a typed topic handler for a key prefix (e.g. "cat" → "cat:42"). */ public void topic(string prefix) { topic_registry.register_topic(prefix); } /** Registers a state modifier that derives a slot type from a topic payload. */ public void topic_modifier(string prefix) { topic_registry.register_modifier(prefix); } } }