| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- 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>(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>();
- container.register_singleton<EntrypointRouteTable>();
- container.register_singleton<ActionRegistry>();
- container.register_singleton<TopicRegistry>();
- // The realtime channel endpoint is the singleton ChannelService.
- container.register_singleton<ChannelEndpoint>()
- .as<ChannelService>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/channel"));
- container.register_scoped<ChannelSubscriptionEndpoint>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/channel/{id}", Method.PATCH));
- container.register_scoped<EntrypointEndpoint>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/entrypoint"));
- container.register_scoped<SlotPostEndpoint>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(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<ActionEndpoint>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/action/{guid}",
- Method.GET, Method.POST, Method.PUT, Method.PATCH, Method.DELETE));
- container.register_scoped<ResourceEndpoint>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(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<ClientScript>().as<StatumResource>();
- 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] ────────────────────────────────────────────────────────────────────\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<Astralis.WebConfig>();
- return config.get_section("statum");
- } catch {
- return null;
- }
- }
- }
- /**
- * Binds application pages, entrypoints and resources into the container.
- *
- * ```
- * var statum = application.configure_with<StatumConfigurator>();
- * statum.add_page<HomePage, HomeEntrypoint>();
- * statum.add_static_page<AboutPage>();
- * statum.add_resource<LogoResource>();
- * ```
- *
- * Each page's route is read from its generated {@link StatumPage.route}
- * (set at build time from its `<pstm-uri>`); no route is passed by hand. A
- * page without a `<pstm-uri>` (empty route) fails at startup.
- */
- public class StatumConfigurator : Object {
- private Container container = inject<Container>();
- private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
- private ActionRegistry action_registry = inject<ActionRegistry>();
- /** Exposed for background tasks (timers, webhooks) that need to trigger topics. */
- public TopicRegistry topic_registry = inject<TopicRegistry>();
- public StateService state_service = inject<StateService>();
- /**
- * Resolves a page's route from its {@link StatumPage.route} property,
- * throwing at startup when the page has no `<pstm-uri>`.
- */
- private static EndpointRoute route_for<TPage>() 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 <pstm-uri> (route is empty)", typeof(TPage).name());
- }
- return new EndpointRoute(path);
- }
- /**
- * Registers a page AND its entrypoint. The page is served at its
- * `<pstm-uri>` route, and the entrypoint is bound to that route in the
- * {@link EntrypointRouteTable} so `/_statum/entrypoint?uri=…` dispatches
- * to it.
- */
- public void add_page<TPage, TEntrypoint>() throws Error {
- var route = route_for<TPage>();
- container.register_scoped<TPage>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(route);
- container.register_transient<TEntrypoint>();
- route_table.register(route, typeof(TEntrypoint));
- }
- /** Registers a static page (no entrypoint) served at its `<pstm-uri>` route. */
- public void add_static_page<TPage>() throws Error {
- var route = route_for<TPage>();
- container.register_scoped<TPage>()
- .as<Endpoint>()
- .with_metadata<EndpointRoute>(route);
- }
- /** Registers a {@link StatumResource} for serving from `/_statum/resource/{name}`. */
- public void add_resource<T>() {
- container.register_startup<T>()
- .as<StatumResource>();
- }
- /**
- * 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<TPublic>(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<TAction>() {
- container.register_scoped<TAction>();
- action_registry.register<TAction>();
- }
- /** Registers a typed topic handler for a key prefix (e.g. "cat" → "cat:42"). */
- public void topic<TTopic>(string prefix) {
- topic_registry.register_topic<TTopic>(prefix);
- }
- /** Registers a state modifier that derives a slot type from a topic payload. */
- public void topic_modifier<TModifier, TState>(string prefix) {
- topic_registry.register_modifier<TModifier, TState>(prefix);
- }
- }
- }
|