Statum.vala 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. using Inversion;
  2. using Invercargill.DataStructures;
  3. using Astralis;
  4. namespace Statum {
  5. /** Configuration-time errors (e.g. a page registered without a route). */
  6. public errordomain StatumError {
  7. CONFIGURATION,
  8. }
  9. /**
  10. * Inversion module that registers the Statum runtime services and framework
  11. * endpoints.
  12. *
  13. * Register this module with the application container (after which a
  14. * {@link StatumConfigurator} binds pages/entrypoints/resources) to wire up
  15. * the {@link StateService}, signing/encryption providers, realtime channel,
  16. * entrypoint route table, held-slot resolver and the `/_statum/*` endpoints.
  17. */
  18. public class StatumModule : Object, Module {
  19. public void register_components(Container container) throws Error {
  20. // Enable recursive mapping of ActionDto fields on typed slot models.
  21. GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
  22. // Sign + encrypt with static keys from web-config.json ("statum"
  23. // section) when configured, so frames/private blobs survive a
  24. // restart. Fall back to generated keys (with a warning) otherwise.
  25. container.register_singleton<Cryptography.SigningProvider>(scope => create_signing_provider(scope));
  26. container.register_singleton<Cryptography.EncryptionProvider>(scope => create_encryption_provider(scope));
  27. container.register_singleton<StateService>();
  28. container.register_singleton<HeldSlotResolver>();
  29. container.register_singleton<EntrypointRouteTable>();
  30. container.register_singleton<ActionRegistry>();
  31. container.register_singleton<TopicRegistry>();
  32. // The realtime channel endpoint is the singleton ChannelService.
  33. container.register_singleton<ChannelEndpoint>()
  34. .as<ChannelService>()
  35. .as<Endpoint>()
  36. .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/channel"));
  37. container.register_scoped<ChannelSubscriptionEndpoint>()
  38. .as<Endpoint>()
  39. .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/channel/{id}", Method.PATCH));
  40. container.register_scoped<EntrypointEndpoint>()
  41. .as<Endpoint>()
  42. .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/entrypoint"));
  43. container.register_scoped<SlotPostEndpoint>()
  44. .as<Endpoint>()
  45. .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/slots", Method.POST));
  46. // The single action endpoint resolves /_statum/action/{guid} to the
  47. // action type via ActionRegistry. Registered for all common verbs so
  48. // an action may be authored with whichever method suits it.
  49. container.register_scoped<ActionEndpoint>()
  50. .as<Endpoint>()
  51. .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/action/{guid}",
  52. Method.GET, Method.POST, Method.PUT, Method.PATCH, Method.DELETE));
  53. container.register_scoped<ResourceEndpoint>()
  54. .as<Endpoint>()
  55. .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/resource/{name}"));
  56. // Embed the Statum client scripts as default resources, served from
  57. // /_statum/resource/statum.js and /_statum/resource/statum-worker.js
  58. // (precompressed at build time). Apps get them for free and may add
  59. // their own resources via StatumConfigurator.add_resource.
  60. container.register_startup<ClientScript>().as<StatumResource>();
  61. container.register_startup<ClientWorker>().as<StatumResource>();
  62. }
  63. private static Cryptography.SigningProvider create_signing_provider(Inversion.Scope scope) {
  64. var section = read_statum_config(scope);
  65. if (section != null) {
  66. var sk = ((!)section).get_string("signing_secret_key");
  67. var pk = ((!)section).get_string("signing_public_key");
  68. if (sk.length > 0 && pk.length > 0) {
  69. return new Cryptography.SigningProvider.with_keys(Base64.decode(sk), Base64.decode(pk));
  70. }
  71. }
  72. warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
  73. + "[Statum] No static signing key configured (web-config.json, \"statum\" section).\n"
  74. + "[Statum] Frames are signed with an EPHEMERAL key:\n"
  75. + "[Statum] → every client-held slot (including sessions) is INVALIDATED\n"
  76. + "[Statum] the moment this process restarts.\n"
  77. + "[Statum] Run `statum-genkeys` and add the generated keys to web-config.json\n"
  78. + "[Statum] to make frames survive restarts.\n"
  79. + "[Statum] ────────────────────────────────────────────────────────────────────");
  80. return new Cryptography.SigningProvider();
  81. }
  82. private static Cryptography.EncryptionProvider create_encryption_provider(Inversion.Scope scope) {
  83. var section = read_statum_config(scope);
  84. if (section != null) {
  85. var s = (!)section;
  86. var ssk = s.get_string("encryption_signing_secret_key");
  87. var spk = s.get_string("encryption_signing_public_key");
  88. var esk = s.get_string("encryption_sealing_secret_key");
  89. var epk = s.get_string("encryption_sealing_public_key");
  90. if (ssk.length > 0 && spk.length > 0 && esk.length > 0 && epk.length > 0) {
  91. return new Cryptography.EncryptionProvider.with_keys(
  92. Base64.decode(ssk), Base64.decode(spk),
  93. Base64.decode(esk), Base64.decode(epk));
  94. }
  95. }
  96. warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
  97. + "[Statum] No static encryption keys configured (web-config.json, \"statum\" section).\n"
  98. + "[Statum] Private blobs are sealed with EPHEMERAL keys:\n"
  99. + "[Statum] → action/snapshot private data becomes unreadable\n"
  100. + "[Statum] the moment this process restarts.\n"
  101. + "[Statum] Run `statum-genkeys` and add the generated keys to web-config.json\n"
  102. + "[Statum] to make private blobs survive restarts.\n"
  103. + "[Statum] ────────────────────────────────────────────────────────────────────");
  104. return new Cryptography.EncryptionProvider();
  105. }
  106. private static Astralis.WebConfigSection? read_statum_config(Inversion.Scope scope) {
  107. try {
  108. var config = scope.resolve<Astralis.WebConfig>();
  109. return config.get_section("statum");
  110. } catch {
  111. return null;
  112. }
  113. }
  114. }
  115. /**
  116. * Binds application pages, entrypoints and resources into the container.
  117. *
  118. * ```
  119. * var statum = application.configure_with<StatumConfigurator>();
  120. * statum.add_page<HomePage, HomeEntrypoint>();
  121. * statum.add_static_page<AboutPage>();
  122. * statum.add_resource<LogoResource>();
  123. * ```
  124. *
  125. * Each page's route is read from its generated {@link StatumPage.route}
  126. * (set at build time from its `<pstm-uri>`); no route is passed by hand. A
  127. * page without a `<pstm-uri>` (empty route) fails at startup.
  128. */
  129. public class StatumConfigurator : Object {
  130. private Container container = inject<Container>();
  131. private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
  132. private ActionRegistry action_registry = inject<ActionRegistry>();
  133. /** Exposed for background tasks (timers, webhooks) that need to trigger topics. */
  134. public TopicRegistry topic_registry = inject<TopicRegistry>();
  135. public StateService state_service = inject<StateService>();
  136. /**
  137. * Resolves a page's route from its {@link StatumPage.route} property,
  138. * throwing at startup when the page has no `<pstm-uri>`.
  139. */
  140. private static EndpointRoute route_for<TPage>() throws Error {
  141. var page = (StatumPage) Object.new(typeof(TPage));
  142. var path = page.route;
  143. if (path == null || path.length == 0) {
  144. throw new StatumError.CONFIGURATION(@"Page %s has no <pstm-uri> (route is empty)", typeof(TPage).name());
  145. }
  146. return new EndpointRoute(path);
  147. }
  148. /**
  149. * Registers a page AND its entrypoint. The page is served at its
  150. * `<pstm-uri>` route, and the entrypoint is bound to that route in the
  151. * {@link EntrypointRouteTable} so `/_statum/entrypoint?uri=…` dispatches
  152. * to it.
  153. */
  154. public void add_page<TPage, TEntrypoint>() throws Error {
  155. var route = route_for<TPage>();
  156. container.register_scoped<TPage>()
  157. .as<Endpoint>()
  158. .with_metadata<EndpointRoute>(route);
  159. container.register_transient<TEntrypoint>();
  160. route_table.register(route, typeof(TEntrypoint));
  161. }
  162. /** Registers a static page (no entrypoint) served at its `<pstm-uri>` route. */
  163. public void add_static_page<TPage>() throws Error {
  164. var route = route_for<TPage>();
  165. container.register_scoped<TPage>()
  166. .as<Endpoint>()
  167. .with_metadata<EndpointRoute>(route);
  168. }
  169. /** Registers a {@link StatumResource} for serving from `/_statum/resource/{name}`. */
  170. public void add_resource<T>() {
  171. container.register_startup<T>()
  172. .as<StatumResource>();
  173. }
  174. /**
  175. * Registers a global broadcast slot with a deterministic key
  176. * (`"global:" + type_name`). All clients that opt in (via
  177. * {@link DirectiveBuilder.set_global} / {@link DirectiveBuilder.subscribe_global})
  178. * share the same slot and see the same updates. The initial value is
  179. * authored from a typed GObject model.
  180. */
  181. public void global<TPublic>(string type_name, TPublic initial) throws GLib.Error {
  182. var state = new State() {
  183. type_name = type_name,
  184. public_data = GObjectMapping.to_properties((Object) initial),
  185. private_data = new PropertyDictionary()
  186. };
  187. state_service.new_global_slot(type_name, Scope.PAGE, state);
  188. }
  189. /**
  190. * Registers a {@link StatumAction}, auto-assigning it a GUID endpoint at
  191. * `/_statum/action/{guid}` (Spry-style — no hand-picked URI). Invoke the
  192. * action from the client by embedding an authored reference in state (see
  193. * {@link ActionRegistry.author}) and binding it with `stm-action`.
  194. */
  195. public void action<TAction>() {
  196. container.register_scoped<TAction>();
  197. action_registry.register<TAction>();
  198. }
  199. /** Registers a typed topic handler for a key prefix (e.g. "cat" → "cat:42"). */
  200. public void topic<TTopic>(string prefix) {
  201. topic_registry.register_topic<TTopic>(prefix);
  202. }
  203. /** Registers a state modifier that derives a slot type from a topic payload. */
  204. public void topic_modifier<TModifier, TState>(string prefix) {
  205. topic_registry.register_modifier<TModifier, TState>(prefix);
  206. }
  207. }
  208. }